Bridging Sync/Async Code in Rust

The border between things is where the most interesting events take place.
— David Holmgren on permaculture

It’s the old conundrum: once you add async code to your project it’s a little like a virus. Async code can freely call sync functions but sync code can’t call async functions. Thus the tendency is to make more and more functions async so that it’s all compatible. This issue was explored, well, colourfully in a fairly popular post from 2015 called “What Color is Your Function?”.

With today’s async/await syntax in Rust this complaint is strictly still valid. An async fn can mostly do what it likes but if you try to .await from anywhere else, the compiler throws an error.

error[E0728]: `await` is only allowed inside `async` functions and blocks
  --> src\main.rs:22:5
   |
21 | fn get_score_sync() -> Result<u32, Box<dyn Error>> {
   |    -------------- this is not `async`
22 |     get_score_async().await
   |     ^^^^^^^^^^^^^^^^^^^^^^^ only allowed inside `async` functions and blocks

It’s true that life is easier when all your code is sync or all your code is async. Despite the above article’s protests, going full async in Rust is actually rather pleasant. For many applications you can pop #[tokio::main] on the main function, stick to tokio’s I/O types and basically have a good time awaiting on whatever you want.

Sadly, going all-in sometimes isn’t practical. Perhaps most of your code is async but it needs to interact with a C FFI, or you’re introducing futures to a previously fully-sync project and it’s important to contain the spread.

The good news is that the Rust ecosystem has simple tools to create an interface between these worlds. With a little indirection we can call async code from a sync context. And if async code has to call a function that will block for a long time, there are ways to do that which avoid gumming up the executor.

Awaiting from synchronous code

The solution is pretty straightforward: use a channel.

  1. Obtain a shared reference or Handle to the async executor – something that you can use to spawn a new task.
  2. Create a synchronous spsc or mpsc channel.
  3. Spawn the async request, moving in the channel Sender.
  4. Perform a blocking recv() on the Receiver until the value arrives.
use std::error::Error;
use tokio::net::TcpStream;
use tokio::runtime::Handle;
use tokio::io::AsyncReadExt;
use crossbeam::channel;

async fn get_score_async() -> Result<u32, Box<dyn Error + Send + Sync>> {
    let mut conn = TcpStream::connect("172.17.66.179:4444").await?;
    let mut score_str = String::new();
    let _ = conn.read_to_string(&mut score_str).await?;
    Ok(score_str.parse()?)
}

fn get_score_sync(handle: Handle) -> Result<u32, Box<dyn Error + Send + Sync>> {
    let (tx, rx) = channel::bounded(1);
    handle.spawn(async move {
        let score_res = get_score_async().await;
        let _ = tx.send(score_res);
    });
    Ok(rx.recv()??)
}

Here we have a very async function using tokio’s networking functionality to read a value from a remote TCP server. Then there is an ordinary non-async function that wants to call it.

It might look a little strange using a sync channel inside an async block. While in principle send() could block, we know in this simple case that it’s going to complete immediately. Meanwhile the thread that made the request can go to sleep until the channel receives a value (or the Sender is dropped, indicating that the task terminated without sending a value).

Where the handle comes from is very application-dependent; maybe when you created the Runtime you saved a handle in an Arc somewhere, or it’s a lazy_static global. In this example I’ve just shown it as a parameter.

Blocking calls from async code

Async tasks are not really supposed to execute for a long time before they yield, which they do either by finishing their work or by awaiting on something else. If all the executor’s worker threads are busy with long-running tasks your application will become unresponsive. In practice on a multi-threaded system you have a bit of wiggle room to do CPU-intensive work and so on provided you don’t go overboard. In particular, tokio recommends that std (blocking) Mutexes continue to be used unless you especially need an async one.

But what if you’re writing async code and you know for sure that some function could take a long time to run? Can we somehow push that work onto its own dedicated thread for a while? Well, yes. In fact we can do the exact inverse of the code above, where we call std::thread::spawn() to do the work and use an async channel to receive the result. This is computationally expensive however. Allocating and spawning a new async task is a cheap operation. Creating a new thread requires setting up a new stack and it’s rather a lot of overhead for the sake of one function call.

The tokio runtime has a special feature to help in this situation: blocking threads. This is a pool of threads separate from the main executor, which automatically scales in size depending on the amount of blocking work. If you use this feature, long-running calls stay off the main worker threads and we can use the same blocking threads over and over, amortising the cost of creating them in the first place.

use std::thread;
use std::time::Duration;

fn long_running_task() -> u32 {
    thread::sleep(Duration::from_secs(5));
    5
}

async fn my_task() {
    let res = tokio::task::spawn_blocking(|| {
        long_running_task()
    }).await.unwrap();
    println!("The answer was: {}", res);
}

Here we have a function that will block for a long time. We can call it from async-land with no ill effects provided we use spawn_blocking.

Conclusion

If you plan carefully and create a well-defined interface between your sync and async components, it’s not too painful or inefficient to move between the sync and async worlds in Rust. If it seems convenient, give it a go.