Lately, I’ve been seeing some common misconceptions about how Rust’s futures and async/await work (“blockers”, haha). There’s an influx of new users excited for the major improvements that async/await brings, but stymied by basic questions. Concurrency is hard, even with async/await. Documentation is still being fleshed out, and the interaction between blocking/non-blocking can be tricky. Hopefully this article will help.

(This blog is mostly about specific pain points; for an overview of async programming in rust, go to the book)

TLDR: Be careful using expensive blocking calls inside async fn! If you aren’t sure, almost everything in the Rust std library is blocking, so watch out if you know if a call will take time!

While I think that anybody can make this mistake (it’s basically silent until you introduce enough load to noticeably block the thread), beginners are especially prone to this. The following scenario may be a bit long-winded, but I think it’s good to show how easy it is to reach for a blocking call inside an async fn.

Don’t sleep on std::thread::sleep

After working through a simple example, probably the first thing a new Rust async user will do is look for some proof that the program is really async. So let’s take this example from the Rust async book:

use futures::join;

async fn get_book_and_music() -> (Book, Music) {
    let book_fut = get_book();
    let music_fut = get_music();
    join!(book_fut, music_fut)
}

Even if you were to log inside get_book and get_music, there’s no easy way to tell that they are run concurrently because any one run may produce output that happens to match the order in the code. You’d have to run the program several times in order to see that the logging order may flip (and what if it didn’t flip?).

If you want to be able to see that get_book and get_music are run concurrently 100% of the time, you might want to log their starting times, and see that the starting times are the same. But wait, what if the starting times are still serial, but the fn runs so fast it still looks concurrent?

Let’s introduce a delay! For example (in pseudocode for clarity):

async fn get_book() {
    println!("book start: time {}", current_time());
    std::thread::sleep(one_second);
    println!("book end: time {}", current_time());
}

With a delay of 1s inside get_book and get_music, we’d expect that in the concurrent case, we’ll see output like:

book start: time 0.00
music start: time 0.00
book end: time 1.00
music start: time 1.00

In the serial case, we’d expect:

book start: time 0.00
book end: time 1.00
music start: time 1.00
music end: time 2.00

Which do you think will happen, the serial or concurrent case?

If you’ve read the title of this post, you might guess that get_book and get_music execute serially. But why!? Isn’t everything inside an async fn supposed to be run concurrently?

Before I go move on to some explanation, here’s a few other times this question was asked:

reddit 1 reddit 2 reddit 3 stackoverflow 1

So if you’ve made this mistake, no worries, many others have walked this path too.

What went wrong? Why even async?

I’m not going to cover futures and async/await in depth here (the book is a good starting place). I just want to point out two possible sources of confusion:

std::thread::sleep is blocking?

It might not be obvious to newcomers that std::thread::sleep is blocking. I think that it’s one of those things that’s probably obvious in hindsight, but when trying to come to grips with a whole new paradigm of program execution, it’s easy to overlook.

Even if you understand concurrency well generally, you might not know how thread::sleep is implemented specifically. Some high level reasoning and some examples (like the above) might get you there. But there’s nothing in the doc that says "This call is BLOCKING and you shouldn't use it in async contexts", and non-systems programmers might not think much of "Puts the current thread to sleep".

(Ironically, thread::sleep may be especially confusing if somebody’s mental model of async programming is letting a Future “sleep” while letting other work happen).

what does async do?

But some might say, “So what if thread::sleep is blocking? Doesn’t putting it in an async fn just take care of it?“.

To paraphrase some online discussions, the thought is that async makes everything inside a block or function async.

First, I’ll just say that it makes sense to think this; part of the pitch of async/await is that it would make async easy for everybody. And if you understood concurrency models at a high level (the event loop, generally trying not to block threads), there might not be a particular reason why async wouldn’t just work by making things async. That would definitely be the easiest and most ergonomic way.

Unfortunately, that’s not how Rust’s async paradigm works. async is powerful, but at its core it just provides a nicer way to handle Futures. And a Future does not just automatically move a blocking call to the side to allow other work to be done; it uses a totally separate system, with polling and async runtimes, to do the async dance. Any blocking call made within that system will still be blocking.

There might be some confusion because async/await is allows us to write code that looks more like regular (blocking) code. That’s where the await part of async/await comes in. When you await a future inside of an async block, it will be able to schedule itself off the thread and make way for another task. Blocking code might look similar, but can’t be awaited because it’s not a future, and won’t be able to make “space” for another task.

So this won’t block, but await lets you write code that looks pretty similar to blocking calls:

async {
    let f = get_file_async().await;
    let resp = fetch_api_async().await;
}

And this will block at each call:

async {
    let f = get_file_blocking();
    let resp = fetch_api_blocking();
}

And this won’t compile:

async {
    let f = get_file_blocking().await;
    let resp = fetch_api_blocking().await;
}

And nothing happens here (you’ve got to await futures inside of async!):

async {
    let f = get_file_async();
    let resp = fetch_api_async();
}

Overall, it’s better to think of async as something that allows await inside a function or a block, but doesn’t actually make anything async.

How to unblock

What if you want to unblock your async fn?

You could find an async alternative: while thread::sleep is blocking, you might use these (depending on which runtime ecosystem you choose):

Both tokio and async_std offer async alternatives for other blocking operations too, such as filesystem and tcp stream access.

The other option is to move the blocking call to another thread.

This requires that your runtime have machinery (such as a thread pool) dedicated to offloading blocking calls.

I’ve also filed some issues to try to prevent others from falling into this trap:

Conclusion

Hope that the this blog was able to clarify some things about how blocking calls interact with Rust’s concurrency model! Feel free to give feedback.