Async: What Is Blocking? (2022)

vinhnx1 pts0 comments

Async: What is blocking? – Alice Ryhl

The async/await feature in Rust is implemented using a mechanism known as cooperative<br>scheduling, and this has some important consequences for people who write asynchronous<br>Rust code.

The intended audience of this blog post is new users of async Rust. I will be using the<br>Tokio runtime for the examples, but the points raised here apply to any asynchronous<br>runtime.

If you remember only one thing from this article, this should be it:

Async code should never spend a long time without reaching an .await.

Translations: chinese

Blocking vs. non-blocking code

The naive way to write an application that works on many things at the same time is to<br>spawn a new thread for every task. If the number of tasks is small, this is a perfectly<br>fine solution, but as the number of tasks becomes large, you will eventually run into<br>problems due to the large number of threads. There are various solutions to this problem<br>in different programming languages, but they all boil down to the same thing: very<br>quickly swap out the currently running task on each thread, such that all of the tasks<br>get an opportunity to run. In Rust, this swapping happens when you .await something.

When writing async Rust, the phrase “blocking the thread” means “preventing the runtime<br>from swapping the current task”. This can be a major issue because it means that other<br>tasks on the same runtime will stop running until the thread is no longer being blocked.<br>To prevent this, we should write code that can be swapped quickly, which you do by never<br>spending a long time away from an .await.

Let's take an example:

▶︎<br>use std::time::Duration;

#[tokio::main]<br>async fn main() {<br>println!("Hello World!");

// No .await here!<br>std::thread::sleep(Duration::from_secs(5));

println!("Five seconds later...");

The above code looks correct, and if you run it, it will appear to work. But it has a<br>fatal flaw: it is blocking the thread. In this case, there are no other tasks, so it's<br>not a problem, but this wont be the case in real programs. To illustrate this point,<br>consider the following example:

▶︎<br>use std::time::Duration;

async fn sleep_then_print(timer: i32) {<br>println!("Start timer {}.", timer);

// No .await here!<br>std::thread::sleep(Duration::from_secs(1));

println!("Timer {} done.", timer);

#[tokio::main]<br>async fn main() {<br>// The join! macro lets you run multiple things concurrently.<br>tokio::join!(<br>sleep_then_print(1),<br>sleep_then_print(2),<br>sleep_then_print(3),<br>);

Start timer 1.<br>Timer 1 done.<br>Start timer 2.<br>Timer 2 done.<br>Start timer 3.<br>Timer 3 done.

The example will take three seconds to run, and the timers will run one after the other<br>with no concurrency whatsoever. The reason is simple: the Tokio runtime was not able to<br>swap one task for another, because such a swap can only happen at an .await. Since<br>there is no .await in sleep_then_print, no swapping can happen while it is running.

However if we instead use Tokio's sleep function, which uses an .await to sleep,<br>the function will behave correctly:

▶︎<br>use tokio::time::Duration;

async fn sleep_then_print(timer: i32) {<br>println!("Start timer {}.", timer);

tokio::time::sleep(Duration::from_secs(1)).await;<br>// ^ execution can be paused here

println!("Timer {} done.", timer);

#[tokio::main]<br>async fn main() {<br>// The join! macro lets you run multiple things concurrently.<br>tokio::join!(<br>sleep_then_print(1),<br>sleep_then_print(2),<br>sleep_then_print(3),<br>);

Start timer 1.<br>Start timer 2.<br>Start timer 3.<br>Timer 1 done.<br>Timer 2 done.<br>Timer 3 done.

The code runs in just one second, and properly runs all three functions at the same time<br>as desired.

Be aware that it is not always this obvious. By using tokio::join!, all three tasks<br>are guaranteed to run on the same thread, but if you replace it with tokio::spawn and<br>use a multi-threaded runtime, you will be able to run multiple blocking tasks until you<br>run out of threads. The default Tokio runtime spawns one thread per CPU core, and you<br>will typically have around 8 CPU cores. This is enough that you can miss the issue when<br>testing locally, but sufficiently few that you will very quickly run out of threads when<br>running the code for real.

To give a sense of scale of how much time is too much, a good rule of thumb is no more<br>than 10 to 100 microseconds between each .await. That said, this depends on the kind of<br>application you are writing.

What if I want to block?

Sometimes we just want to block the thread. This is completely normal. There are two<br>common reasons for this:

Expensive CPU-bound computation.

Synchronous IO.

In both cases, we are dealing with an operation that prevents the task from reaching an<br>.await for an extended period of time. To solve this issue, we must move the blocking<br>operation to a thread outside of Tokio's thread pool. There are three variations on<br>this:

Use the tokio::task::spawn_blocking function.

Use the rayon crate.

Spawn a dedicated thread with std::thread::spawn.

Let us go through each solution to...

timer tokio thread await async time

Related Articles