How to handle blocking operations and CPU-bound tasks in Tokio without starving the async runtime
Tokio’s async runtime relies on tasks yielding at .await points. Blocking operations can prevent the runtime from making progress on other tasks. This guide explains how to properly handle blocking code.
When you block a thread in an async context, you prevent the executor from running other tasks:
// ❌ BAD: This blocks the entire worker thread#[tokio::main]async fn main() { tokio::spawn(async { // This blocks the worker thread! std::thread::sleep(std::time::Duration::from_secs(10)); }); // Other tasks can't run on that worker thread}
Never use std::thread::sleep or other blocking operations directly in async code. This starves the runtime.
spawn_blocking runs blocking code on a dedicated thread pool:
use tokio::task;#[tokio::main]async fn main() { let blocking_task = task::spawn_blocking(|| { // This runs on a blocking thread std::thread::sleep(std::time::Duration::from_secs(5)); compute_expensive_value() }); // Await the result let result = blocking_task.await.unwrap(); println!("Result: {}", result);}fn compute_expensive_value() -> i32 { 42}
block_in_place temporarily converts the current worker thread to a blocking thread:
use tokio::task;#[tokio::main]async fn main() { task::block_in_place(|| { // This blocks the current thread std::thread::sleep(std::time::Duration::from_secs(1)); });}
block_in_placeonly works with the multi-threaded runtime. It panics on the current-thread scheduler.
use tokio::sync::oneshot;#[tokio::main]async fn main() { let (tx, rx) = oneshot::channel(); rayon::spawn(move || { let result = expensive_computation(); tx.send(result).unwrap(); }); let result = rx.await.unwrap(); println!("Result: {}", result);}fn expensive_computation() -> i64 { // CPU-intensive work (0..10_000_000).sum()}
Use a semaphore to limit concurrent CPU-bound operations:
use tokio::sync::Semaphore;use tokio::task;use std::sync::Arc;#[tokio::main]async fn main() { let semaphore = Arc::new(Semaphore::new(4)); // Max 4 concurrent let mut handles = vec![]; for i in 0..100 { let permit = semaphore.clone().acquire_owned().await.unwrap(); let handle = task::spawn_blocking(move || { let result = expensive_computation(i); drop(permit); // Release permit result }); handles.push(handle); } for handle in handles { handle.await.unwrap(); }}fn expensive_computation(n: i32) -> i32 { // CPU-intensive work n * n}
use tokio::task;use std::time::Duration;let handle = task::spawn_blocking(|| { std::thread::sleep(Duration::from_secs(10)); 42});// This has NO EFFECT once the task startshandle.abort();// The task will continue running
spawn_blocking tasks cannot be aborted once they start running. The task will run to completion even after abort() is called.