What is the purpose of 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.

The Problem: Blocking in Async Code

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.

tokio::spawn for Async Work

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.

spawn_blocking for Blocking Work

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.

Thread Pool Architecture

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.

CPU-Intensive 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.

Blocking File I/O

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.

Blocking Database Operations

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.

FFI and C Library Calls

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.

Comparing Performance Impact

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.

Async Tasks Remain Responsive

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.

Blocking Thread Pool Configuration

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.

When to Use Each

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.

Cancellation and Timeouts

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.

Moving Data Into spawn_blocking

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.

Error Handling

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.

Summary Comparison

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

Synthesis

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.