Blocking the executor

Most async runtimes only allow IO tasks to run concurrently. This means that CPU blocking tasks will block the executor and prevent other tasks from being executed. An easy workaround is to use async equivalent methods where possible.

  1. use futures::future::join_all;
  2. use std::time::Instant;
  3. async fn sleep_ms(start: &Instant, id: u64, duration_ms: u64) {
  4.     std::thread::sleep(std::time::Duration::from_millis(duration_ms));
  5.     println!(
  6.         "future {id} slept for {duration_ms}ms, finished after {}ms",
  7.         start.elapsed().as_millis()
  8.     );
  9. }
  10. #[tokio::main(flavor = "current_thread")]
  11. async fn main() {
  12.     let start = Instant::now();
  13.     let sleep_futures = (1..=10).map(|t| sleep_ms(&start, t, t * 10));
  14.     join_all(sleep_futures).await;
  15. }

This slide should take about 10 minutes.

  • Run the code and see that the sleeps happen consecutively rather than concurrently.

  • The "current_thread" flavor puts all tasks on a single thread. This makes the effect more obvious, but the bug is still present in the multi-threaded flavor.

  • Switch the std::thread::sleep to tokio::time::sleep and await its result.

  • Another fix would be to tokio::task::spawn_blocking which spawns an actual thread and transforms its handle into a future without blocking the executor.

  • You should not think of tasks as OS threads. They do not map 1 to 1 and most executors will allow many tasks to run on a single OS thread. This is particularly problematic when interacting with other libraries via FFI, where that library might depend on thread-local storage or map to specific OS threads (e.g., CUDA). Prefer tokio::task::spawn_blocking in such situations.

  • Use sync mutexes with care. Holding a mutex over an .await may cause another task to block, and that task may be running on the same thread.