Asynchronous Runtime on a Worker Thread in Rust
First published: July 2, 2023
Last updated: July 2, 2023
Immediate mode GUI’s like eGUI don’t play well with asynchronous runtimes like Rust’s Tokio. When you want to combine the two, you’ll need to run the asynchronous runtime in another thread and communicate with it using concurrency primitives such as channels.
An example of this pattern is:
// [dependencies]
// tokio = {version = "1", features = ["full"] }
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use tokio::runtime;
fn main() {
let (tx, rx) = mpsc::channel::<i32>();
let async_thread = thread::spawn(move || {
let tokio_rt = runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
for i in 1..5 {
tokio_rt.block_on(async {
println!("Async thread sending {i}");
tx.send(i).unwrap()
})
}
});
while let Ok(num) = rx.recv() {
println!("Main thread receiving {num}");
thread::sleep(Duration::from_millis(500));
}
async_thread.join().unwrap();
}
First, we make a multiple producer single consumer (mpsc) channel to send messages between the threads. Next, we spawn the thread (called async_thread
) that will run the asynchronous runtime using Tokio (saved under the handle tokio_rt
). Note that here I configured Tokio to use the current thread (the async_thread
via Builder::new_current_thread()
), rather than creating the usual multi threaded runtime in main (via Builder::new_multi_thread()
). You could still use the multi threaded runtime on the async_thread
worker thread, but it wasn’t necessary for my purposes (see the Tokio runtime docs).
In this example, I am simply sending a range of numbers from the Tokio runtime (mpsc::Sender
) to the main thread and sleeping for half a second (mpsc::Receiver
). Every time I need to run a function in the asynchronous runtime, I can use tokio_rt.block_on(...)
. This will block the async_thread
, which is fine since this is a worker thread that won’t block my eGUI application on the main thread.
In my application, the asynchronous thread is the mpsc::Receiver
, and simply waits for new messages sent by the main thread to write to the database:
//... within async_thread ...
while let Ok(db_message) = rx.recv() {
tokio_rt.block_on(<async db fn here>)
}
Why use an asynchronous runtime at all if I can just use the worker thread? I’m using SQLx as my database driver so I can take advantage of type-checked queries and SQLx requires an asynchronous runtime.
I’m a Rust and concurrency beginner, this method was based on:
- The Tokio docs section on different methods for bridging with synchronous code
- The Rust Book’s section on concurrency
- Programming Rust