What is the difference between tokio::spawn and tokio::task::spawn_blocking for concurrent task execution?

tokio::spawn schedules asynchronous tasks on Tokio's work-stealing runtime, while tokio::task::spawn_blocking offloads blocking operations to a dedicated thread pool. The critical distinction is that tokio::spawn runs tasks that yield control back to the runtime (async code that uses .await), whereas spawn_blocking is for code that would block the runtime thread—CPU-intensive work, synchronous I/O, or FFI calls. Blocking an async runtime thread prevents other tasks from running on that thread, reducing overall throughput. spawn_blocking isolates blocking work from the async runtime, preserving responsiveness.

Basic tokio::spawn Usage

#[tokio::main]
async fn main() {
    // tokio::spawn schedules an async task on the runtime
    let handle = tokio::spawn(async {
        println!("Running on the async runtime");
        42
    });
    
    // The task runs concurrently
    let result = handle.await.expect("Task panicked");
    println!("Result: {}", result);
}

tokio::spawn creates an async task that runs on Tokio's runtime.

Basic spawn_blocking Usage

#[tokio::main]
async fn main() {
    // spawn_blocking runs synchronous code on a blocking thread pool
    let handle = tokio::task::spawn_blocking(|| {
        println!("Running on blocking thread pool");
        std::thread::sleep(std::time::Duration::from_millis(100));
        42
    });
    
    let result = handle.await.expect("Task panicked");
    println!("Result: {}", result);
}

spawn_blocking runs blocking code on a separate thread pool.

Blocking the Runtime Problem

#[tokio::main]
async fn main() {
    // WRONG: Blocking the async runtime
    tokio::spawn(async {
        // This blocks the runtime thread!
        std::thread::sleep(std::time::Duration::from_secs(5));
        println!("Done blocking");
    });
    
    // Other tasks on this thread can't run during the sleep
    // The runtime thread is blocked
    
    // CORRECT: Use spawn_blocking for blocking operations
    tokio::task::spawn_blocking(|| {
        std::thread::sleep(std::time::Duration::from_secs(5));
        println!("Done on blocking thread");
    }).await.unwrap();
}

Blocking in async code prevents other tasks from running on that thread.

When to Use Each

#[tokio::main]
async fn main() {
    // tokio::spawn: async code that yields
    tokio::spawn(async {
        // Non-blocking async operations
        let data = tokio::fs::read_to_string("file.txt").await.unwrap();
        println!("Read {} bytes", data.len());
    });
    
    // spawn_blocking: sync code that blocks
    tokio::task::spawn_blocking(|| {
        // Blocking operations
        let contents = std::fs::read_to_string("file.txt").unwrap();
        println!("Read {} bytes", contents.len());
    });
}

Use spawn for async, spawn_blocking for blocking sync code.

CPU-Intensive Work with spawn_blocking

#[tokio::main]
async fn main() {
    // CPU-intensive work should not block the runtime
    let handle = tokio::task::spawn_blocking(|| {
        let mut sum = 0u64;
        for i in 0..10_000_000 {
            sum = sum.wrapping_add(i);
        }
        sum
    });
    
    // Runtime stays responsive while calculation runs
    let result = handle.await.unwrap();
    println!("Sum: {}", result);
}

CPU-bound work blocks the thread—use spawn_blocking to isolate it.

Async I/O vs Sync I/O

#[tokio::main]
async fn main() -> std::io::Result<()> {
    // Async I/O: use tokio::spawn with async functions
    tokio::spawn(async {
        use tokio::io::{AsyncReadExt, AsyncWriteExt};
        
        let mut file = tokio::fs::File::open("async_file.txt").await?;
        let mut buffer = Vec::new();
        file.read_to_end(&mut buffer).await?;
        println!("Read {} bytes async", buffer.len());
        Ok::<_, std::io::Error>(())
    });
    
    // Sync I/O: must use spawn_blocking
    tokio::task::spawn_blocking(|| {
        use std::io::Read;
        
        let mut file = std::fs::File::open("sync_file.txt")?;
        let mut buffer = Vec::new();
        file.read_to_end(&mut buffer)?;
        println!("Read {} bytes sync", buffer.len());
        Ok::<_, std::io::Error>(())
    }).await.unwrap()?;
    
    Ok(())
}

Use async I/O with tokio::spawn, sync I/O with spawn_blocking.

Thread Pool Differences

#[tokio::main]
async fn main() {
    // tokio::spawn: runs on async runtime threads (few threads)
    tokio::spawn(async {
        println!("Async thread: {:?}", std::thread::current().id());
    });
    
    // spawn_blocking: runs on blocking thread pool (many threads)
    tokio::task::spawn_blocking(|| {
        println!("Blocking thread: {:?}", std::thread::current().id());
    }).await.unwrap();
    
    // Runtime has limited async threads (typically # cores)
    // Blocking pool can grow to 512 threads by default
}

The async runtime uses few threads; blocking pool can have many.

Configuring Blocking Thread Pool

#[tokio::main]
async fn main() {
    // Blocking pool defaults to max 512 threads
    // Each blocks for up to timeout before thread is released
    
    // Spawn many blocking tasks
    for i in 0..100 {
        tokio::task::spawn_blocking(move || {
            std::thread::sleep(std::time::Duration::from_millis(100));
            println!("Task {} done", i);
        });
    }
    
    // All 100 can run concurrently on blocking pool
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}

The blocking pool is designed for concurrent blocking operations.

JoinHandle Behavior

#[tokio::main]
async fn main() {
    // Both return JoinHandle
    let async_handle = tokio::spawn(async { 1 });
    let blocking_handle = tokio::task::spawn_blocking(|| 2);
    
    // Both can be awaited for result
    let a = async_handle.await.unwrap();
    let b = blocking_handle.await.unwrap();
    
    println!("Results: {}, {}", a, b);
    
    // Detached tasks: both continue running if handle dropped
    tokio::spawn(async {
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        println!("Detached async task completed");
    });
    
    tokio::task::spawn_blocking(|| {
        std::thread::sleep(std::time::Duration::from_millis(100));
        println!("Detached blocking task completed");
    });
}

Both return JoinHandle and can be detached by dropping the handle.

Error Handling Differences

#[tokio::main]
async fn main() {
    // Both can panic
    let async_result = tokio::spawn(async {
        panic!("Async panic!");
    }).await;
    
    let blocking_result = tokio::task::spawn_blocking(|| {
        panic!("Blocking panic!");
    }).await;
    
    // Both return Err(JoinError) on panic
    assert!(async_result.is_err());
    assert!(blocking_result.is_err());
}

Both propagate panics through the JoinHandle.

Return Type Constraints

#[tokio::main]
async fn main() {
    // tokio::spawn: async block, return type must be Send + 'static
    let handle = tokio::spawn(async {
        42u32  // u32: Send + 'static
    });
    
    // spawn_blocking: sync closure, return type must be Send + 'static
    let handle = tokio::task::spawn_blocking(|| {
        42u32  // Same constraints
    });
    
    // Both require Send because tasks can move between threads
}

Both require Send because tasks run on thread pools.

Non-Send Types and spawn_local

#[tokio::main]
async fn main() {
    // tokio::spawn requires Send
    // For non-Send types, use tokio::task::spawn_local
    
    // This won't compile:
    // let rc = std::rc::Rc::new(42);
    // tokio::spawn(async move { *rc });  // Rc is not Send
    
    // Use spawn_local for non-Send (requires current_thread runtime)
    // Note: spawn_local requires #[tokio::main(flavor = "current_thread")]
}
 
// For non-Send in multi-threaded runtime, use Arc instead
#[tokio::main]
async fn main2() {
    use std::sync::Arc;
    
    let arc = Arc::new(42);
    tokio::spawn(async move {
        println!("{}", *arc);
    });
}

tokio::spawn requires Send; for non-Send types, use alternatives.

Performance Implications

#[tokio::main]
async fn main() {
    // tokio::spawn is very cheap
    // Task creation: ~few nanoseconds
    for _ in 0..1_000_000 {
        tokio::spawn(async {
            // Minimal overhead
        });
    }
    
    // spawn_blocking is more expensive
    // Thread creation/assignment: ~microseconds
    // Only use when truly necessary
    tokio::task::spawn_blocking(|| {
        // Worth the overhead for blocking operations
    });
}

tokio::spawn is lightweight; spawn_blocking has more overhead.

Blocking Pool Sizing

#[tokio::main]
async fn main() {
    // Default: blocking pool max 512 threads
    // Each thread kept alive for ~10s after last task
    
    // Configure with runtime builder:
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(4)  // Async runtime threads
        .max_blocking_threads(32)  // Blocking pool size
        .build()
        .unwrap();
    
    runtime.block_on(async {
        tokio::task::spawn_blocking(|| {
            // Uses configured blocking pool
        });
    });
}

Configure blocking pool size based on expected blocking workload.

Common Mistakes

#[tokio::main]
async fn main() {
    // MISTAKE 1: Blocking in async code
    tokio::spawn(async {
        // BAD: Blocks the runtime
        std::thread::sleep(Duration::from_secs(1));
    });
    
    // CORRECT: Use tokio's async sleep
    tokio::spawn(async {
        tokio::time::sleep(Duration::from_secs(1)).await;
    });
    
    // MISTAKE 2: Using spawn_blocking for async operations
    tokio::task::spawn_blocking(|| {
        // BAD: Wastes blocking thread for async work
        // Can't use .await inside sync closure anyway
    });
    
    // MISTAKE 3: Not awaiting spawn_blocking handle
    tokio::task::spawn_blocking(|| {
        // Task runs, but program might exit before completion
    });
    // Should await or use proper synchronization
}

Common pitfalls include blocking in async code and misusing spawn_blocking.

FFI and Blocking Libraries

#[tokio::main]
async fn main() {
    // FFI calls that block must use spawn_blocking
    tokio::task::spawn_blocking(|| {
        // Hypothetical blocking FFI call
        // external_library::blocking_call();
    });
    
    // Libraries without async support
    tokio::task::spawn_blocking(|| {
        // Using sync-only libraries
        let client = reqwest::blocking::Client::new();
        let resp = client.get("https://example.com").send().unwrap();
        println!("Status: {}", resp.status());
    }).await.unwrap();
}

Use spawn_blocking for FFI and libraries without async support.

Mixing Both Patterns

#[tokio::main]
async fn main() {
    // Pattern: async I/O, then CPU work, then async I/O
    let data = tokio::fs::read_to_string("input.txt").await.unwrap();
    
    let result = tokio::task::spawn_blocking(move || {
        // CPU-intensive processing
        data.lines().filter(|l| l.len() > 10).count()
    }).await.unwrap();
    
    // Back to async for I/O
    tokio::fs::write("output.txt", format!("Long lines: {}", result))
        .await
        .unwrap();
}

Combine both for I/O-bound and CPU-bound phases.

Comparison Table

Feature tokio::spawn spawn_blocking
Runs on Async runtime threads Blocking thread pool
Argument Async block Sync closure
Blocks runtime No (yields) No (isolated)
Overhead Very low Moderate
Use case Async code Blocking sync code
Thread count ~CPU cores Up to 512
Can await Yes Only in outer scope

Synthesis

The choice between tokio::spawn and spawn_blocking depends on whether the work yields or blocks:

tokio::spawn schedules async tasks that cooperatively yield control back to the runtime. Use it for async I/O, timers, and any code that uses .await. The runtime can multiplex thousands of tasks on few threads because tasks yield at await points.

spawn_blocking offloads blocking work to a dedicated thread pool where blocking doesn't impact the async runtime. Use it for CPU-intensive computation, synchronous I/O, FFI calls, and any operation that would block a thread without yielding.

Key insight: The async runtime achieves high concurrency through cooperative multitasking—tasks must yield. When code blocks instead of yielding, it prevents other tasks from running on that thread. spawn_blocking isolates blocking work on threads dedicated to that purpose, keeping the async runtime responsive. The overhead of spawn_blocking (thread pool management) is acceptable when the alternative is blocking the runtime, but should not be used for truly async operations that can yield.