Loading page…
Rust walkthroughs
Loading page…
tokio::task::spawn_blocking and when should you use it instead of tokio::spawn?tokio::task::spawn_blocking offloads blocking operations to a dedicated thread pool separate from Tokio's async runtime, preventing blocking code from starving async tasks. While tokio::spawn schedules async tasks on the runtime's worker threads (which should never block), spawn_blocking runs synchronous, blocking code on threads specifically designed for that purpose. Use spawn_blocking for CPU-intensive computations, blocking I/O (like standard file operations or database drivers without async support), and FFI calls that may block. Use tokio::spawn for async operations that yield back to the runtime, such as async network I/O, timers, and other awaitable futures.
use tokio::task;
use std::time::Duration;
#[tokio::main]
async fn problem_blocking() {
// This is BAD - blocking the async runtime
tokio::spawn(async {
// This blocks the worker thread
std::thread::sleep(Duration::from_secs(1));
println!("Blocking task done");
});
// Other async tasks can't run while thread is blocked
tokio::spawn(async {
println!("I might be delayed!");
});
}Blocking in async code prevents the runtime from making progress on other tasks.
use tokio::task;
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn tokio_spawn_async() {
// spawn schedules async tasks on worker threads
let handle = tokio::spawn(async {
// This yields - other tasks can run during sleep
sleep(Duration::from_secs(1)).await;
"Async task completed"
});
// The result can be awaited
let result = handle.await.unwrap();
println!("{}", result);
// spawn is cheap - can create many tasks
let handles: Vec<_> = (0..1000)
.map(|i| {
tokio::spawn(async move {
sleep(Duration::from_millis(1)).await;
i
})
})
.collect();
for handle in handles {
let _ = handle.await;
}
}tokio::spawn is for async code that yields via .await.
use tokio::task;
use std::time::Duration;
#[tokio::main]
async fn spawn_blocking_example() {
// spawn_blocking runs on a separate thread pool
let handle = tokio::task::spawn_blocking(|| {
// This is OK - runs on blocking thread pool
std::thread::sleep(Duration::from_secs(1));
"Blocking task completed"
});
// Can still run async tasks in parallel
tokio::spawn(async {
println!("Async tasks continue normally");
});
let result = handle.await.unwrap();
println!("{}", result);
}spawn_blocking moves blocking work to threads designed for it.
use tokio::runtime::Runtime;
fn thread_pool_architecture() {
// Tokio has two thread pools:
// 1. Core worker threads (async runtime)
// - Number: usually equal to CPU cores
// - Purpose: Run async tasks
// - Must NOT block
// - Single-threaded per worker (cooperative)
// 2. Blocking thread pool
// - Number: grows as needed (max configurable)
// - Purpose: Run blocking operations
// - Can block freely
// - Traditional one-thread-per-task
let rt = Runtime::new().unwrap();
rt.block_on(async {
// This runs on a core worker thread
println!("Async task on worker");
// This runs on the blocking thread pool
tokio::task::spawn_blocking(|| {
println!("Blocking task on blocking thread");
}).await.unwrap();
});
}The runtime maintains separate pools for async and blocking work.
use tokio::task;
#[tokio::main]
async fn cpu_intensive_work() {
// BAD: CPU work on async runtime
// This starves other tasks
let _bad = tokio::spawn(async {
let mut sum = 0u64;
for i in 0..1_000_000_000 {
sum = sum.wrapping_add(i);
}
sum
});
// GOOD: CPU work on blocking pool
let good = tokio::task::spawn_blocking(|| {
let mut sum = 0u64;
for i in 0..1_000_000_000 {
sum = sum.wrapping_add(i);
}
sum
});
// Other async work continues
tokio::spawn(async {
println!("I can run in parallel!");
});
let result = good.await.unwrap();
println!("CPU result: {}", result);
}CPU-bound work should use spawn_blocking to avoid starving async tasks.
use tokio::task;
use std::fs::File;
use std::io::{Read, Write};
#[tokio::main]
async fn blocking_file_io() {
// std::fs is blocking - use spawn_blocking
// BAD: Blocks the async runtime
// let contents = std::fs::read_to_string("large_file.txt").unwrap();
// GOOD: Offload to blocking pool
let contents = tokio::task::spawn_blocking(|| {
std::fs::read_to_string("large_file.txt")
}).await.unwrap();
println!("Read {} bytes", contents.unwrap().len());
// For writes:
let data = b"Hello, World!".to_vec();
let result = tokio::task::spawn_blocking(move || {
let mut file = File::create("output.txt").unwrap();
file.write_all(&data).unwrap();
"Written"
}).await.unwrap();
println!("{}", result);
}Standard file operations are blocking; use spawn_blocking or async file libraries.
use tokio::task;
// Simulated blocking database operations
mod blocking_db {
use std::time::Duration;
use std::thread;
pub struct Connection;
impl Connection {
pub fn connect() -> Self {
Connection
}
// This is blocking!
pub fn query(&self, sql: &str) -> Vec<String> {
thread::sleep(Duration::from_millis(100));
vec![format!("Result for: {}", sql)]
}
}
}
#[tokio::main]
async fn blocking_database() {
let conn = blocking_db::Connection::connect();
// BAD: Blocks async runtime
// let results = conn.query("SELECT * FROM users");
// GOOD: Use spawn_blocking
// Note: Connection must be Send
let results = tokio::task::spawn_blocking(move || {
conn.query("SELECT * FROM users")
}).await.unwrap();
println!("Results: {:?}", results);
// Better: Use async database driver (like sqlx, sea-orm)
// These use tokio's async APIs internally
}Blocking database drivers need spawn_blocking; prefer async drivers when available.
use tokio::task;
// Hypothetical blocking FFI
mod ffi {
use std::time::Duration;
use std::thread;
// Simulated C library function
pub fn blocking_c_library_call(input: i32) -> i32 {
thread::sleep(Duration::from_millis(50));
input * 2
}
}
#[tokio::main]
async fn ffi_calls() {
let input = 42;
// FFI calls often block - use spawn_blocking
let result = tokio::task::spawn_blocking(move || {
// Safe because this runs on blocking thread
ffi::blocking_c_library_call(input)
}).await.unwrap();
println!("FFI result: {}", result);
}FFI calls that may block should run on the blocking thread pool.
use tokio::task;
use tokio::time::{sleep, Duration, Instant};
#[tokio::main]
async fn performance_comparison() {
let start = Instant::now();
// Scenario 1: Blocking the runtime (BAD)
// Spawn 4 tasks that each block for 100ms
// With 4 core threads, this takes ~400ms total
let handles: Vec<_> = (0..4)
.map(|i| {
tokio::spawn(async move {
// Each blocks a worker thread
std::thread::sleep(Duration::from_millis(100));
i
})
})
.collect();
for h in handles {
h.await.unwrap();
}
println!("Blocking runtime: {:?}", start.elapsed());
// Scenario 2: Using spawn_blocking (GOOD)
let start = Instant::now();
let handles: Vec<_> = (0..4)
.map(|i| {
tokio::task::spawn_blocking(move || {
std::thread::sleep(Duration::from_millis(100));
i
})
})
.collect();
for h in handles {
h.await.unwrap();
}
println!("spawn_blocking: {:?}", start.elapsed());
// spawn_blocking runs in parallel on blocking pool
// Takes ~100ms instead of ~400ms
}Blocking the runtime serializes tasks; spawn_blocking allows parallelism.
use tokio::task;
use tokio::time::{sleep, Duration, Instant};
#[tokio::main]
async fn responsiveness() {
let start = Instant::now();
// Long blocking operation
let blocking_handle = tokio::task::spawn_blocking(|| {
std::thread::sleep(Duration::from_secs(1));
"Blocking done"
});
// This async task can run during the blocking operation
let async_handle = tokio::spawn(async {
let mut count = 0;
while start.elapsed() < Duration::from_secs(1) {
sleep(Duration::from_millis(100)).await;
count += 1;
println!("Async task tick {}", count);
}
count
});
let blocking_result = blocking_handle.await.unwrap();
let async_count = async_handle.await.unwrap();
println!("Blocking: {}, Async ticks: {}", blocking_result, async_count);
}With spawn_blocking, async tasks continue running during blocking operations.
use tokio::runtime::{self, Runtime};
fn configure_blocking_pool() {
// Configure the blocking thread pool
let rt = runtime::Builder::new_multi_thread()
.worker_threads(4) // Async worker threads
.max_blocking_threads(16) // Max blocking threads
.enable_all()
.build()
.unwrap();
// Default: max_blocking_threads = 512
// Threads are created on demand and idle threads are removed
rt.block_on(async {
// spawn_blocking creates threads as needed
// Up to max_blocking_threads
let handles: Vec<_> = (0..20)
.map(|i| {
tokio::task::spawn_blocking(move || {
std::thread::sleep(std::time::Duration::from_millis(100));
i
})
})
.collect();
for h in handles {
h.await.unwrap();
}
});
}The blocking pool grows on demand up to the configured maximum.
use tokio::task;
use tokio::time::sleep;
use std::time::Duration;
#[tokio::main]
async fn when_to_use_what() {
// === USE tokio::spawn ===
// 1. Async I/O (network, timers)
tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
});
// 2. Async file I/O (tokio::fs)
tokio::spawn(async {
tokio::fs::read_to_string("file.txt").await.ok();
});
// 3. Async database drivers (sqlx, etc.)
// tokio::spawn(async {
// sqlx::query("SELECT 1").fetch_one(&pool).await
// });
// 4. Async channels
let (tx, mut rx) = tokio::sync::mpsc::channel::<i32>(10);
tokio::spawn(async move {
while let Some(v) = rx.recv().await {
println!("Received: {}", v);
}
});
// === USE spawn_blocking ===
// 1. CPU-intensive computations
tokio::task::spawn_blocking(|| {
(0..1_000_000).sum::<u64>()
});
// 2. Blocking file I/O (std::fs)
tokio::task::spawn_blocking(|| {
std::fs::read_to_string("file.txt")
});
// 3. Blocking database drivers (diesel, diesel-async in blocking mode)
tokio::task::spawn_blocking(|| {
// diesel::table.select(...)
});
// 4. FFI calls that may block
tokio::task::spawn_blocking(|| {
// unsafe { c_library_function() }
});
// 5. Synchronous library code
tokio::task::spawn_blocking(|| {
// Any sync library that blocks
});
}Use tokio::spawn for async work, spawn_blocking for blocking work.
use tokio::task;
use tokio::time::{timeout, Duration};
#[tokio::main]
async fn cancellation_and_timeouts() {
// spawn_blocking tasks can be awaited with timeouts
let result = timeout(
Duration::from_millis(50),
tokio::task::spawn_blocking(|| {
std::thread::sleep(Duration::from_secs(1));
"Done"
})
).await;
match result {
Ok(Ok(value)) => println!("Completed: {}", value),
Ok(Err(e)) => println!("Task panicked: {}", e),
Err(_) => println!("Timed out - but blocking thread continues!"),
}
// IMPORTANT: spawn_blocking tasks can't be cancelled mid-execution
// The thread continues running even if you timeout or abort the handle
// The task will complete (or panic) but you won't wait for it
// For cancellable CPU work, consider breaking it into chunks:
let handle = tokio::task::spawn_blocking(|| {
for i in 0..100 {
// Check periodically if cancelled (via channel, etc.)
std::thread::sleep(Duration::from_millis(10));
}
"Chunked work done"
});
// Abort doesn't stop the blocking thread
handle.abort();
// Task continues on blocking thread, but await will get error
}Blocking tasks continue running even after timeout; they can't be preemptively cancelled.
use tokio::task;
use std::sync::Arc;
#[tokio::main]
async fn moving_data() {
// spawn_blocking requires Send closure
let data = vec![1, 2, 3, 4, 5];
// Move data into the closure
let result = tokio::task::spawn_blocking(move || {
data.iter().sum::<i32>()
}).await.unwrap();
println!("Sum: {}", result);
// Share data with Arc
let shared = Arc::new(vec![10, 20, 30]);
let shared_clone = Arc::clone(&shared);
let result = tokio::task::spawn_blocking(move || {
shared_clone.iter().sum::<i32>()
}).await.unwrap();
println!("Shared sum: {}", result);
println!("Original still available: {:?}", shared);
}Data moved into spawn_blocking must be Send.
use tokio::task;
use std::io;
#[tokio::main]
async fn error_handling() {
// spawn_blocking returns Result<Result<T, E>, JoinError>
// Outer Result: JoinError if task panicked or was cancelled
// Inner Result: your function's result
// Success case
let result = tokio::task::spawn_blocking(|| {
Ok::<_, io::Error>(42)
}).await;
match result {
Ok(Ok(value)) => println!("Success: {}", value),
Ok(Err(e)) => println!("Function error: {}", e),
Err(e) => println!("Join error: {}", e),
}
// Panic case
let result = tokio::task::spawn_blocking(|| {
panic!("Something went wrong!");
}).await;
match result {
Ok(_) => println!("Task completed"),
Err(e) => println!("Task panicked: {}", e),
}
}Handle both the join result and the inner result appropriately.
| Aspect | tokio::spawn | spawn_blocking |
|--------|---------------|------------------|
| Thread pool | Core workers | Blocking pool |
| Task type | Async (.await) | Synchronous |
| Blocking allowed | No (starves runtime) | Yes (designed for it) |
| Cancellation | Immediately effective | Cannot stop mid-execution |
| Overhead | Very low | Higher (thread allocation) |
| Use case | Async I/O, timers | CPU work, blocking I/O, FFI |
| Scalability | Millions of tasks | Limited by blocking threads |
tokio::spawn and spawn_blocking serve fundamentally different purposes:
tokio::spawn schedules async tasks on the runtime's worker threads. These tasks must yield via .await and must never block. Use for async network I/O, timers, async channels, and other operations that return futures.
spawn_blocking runs synchronous code on a dedicated thread pool designed for blocking operations. Use for CPU-intensive computations, blocking file I/O, blocking database drivers, FFI calls, and any synchronous library code that may block.
Key insight: The async runtime relies on tasks yielding promptly. Blocking a worker thread prevents all other tasks on that thread from making progress. spawn_blocking protects the runtime by isolating blocking code. The trade-off is higher overhead (thread creation and context switching) and inability to cancel mid-execution. When you have blocking code, always use spawn_blocking—the performance impact of blocking the runtime is far worse than the overhead of the blocking thread pool.