lifetimes.dev
On this page

Rust Threads vs Tokio Tasks: Concurrency and Parallelism

How std::thread::spawn and tokio::spawn schedule work, when tasks run in parallel, and what happens when you block a Tokio worker.

std::thread::spawn creates an OS thread. tokio::spawn creates an async task that the Tokio runtime schedules on OS threads. Both let you run work concurrently, but they differ in how that work waits and gets CPU time.

Concurrency and parallelism

Concurrency means multiple operations make progress over the same period. A single thread can start one operation, switch to another while the first waits, and return to the first when it can continue.

Parallelism means work executes at the same time on multiple CPU cores. Two threads can run in parallel when the OS schedules them on different cores. On one core, they can take turns and still make progress concurrently.

A program can use concurrency to wait for several network responses at once, and parallelism to process several images across CPU cores. Tokio supports both, depending on the runtime configuration and how you write the work. Tokio’s spawning tutorial uses concurrent connection handling as an example.

std::thread::spawn creates an OS thread

Each spawned thread has its own stack. The OS scheduler decides when it runs and can interrupt it to give another thread CPU time. With this preemptive scheduling, the OS can pause a long computation and let another thread run.

Here are two threads waiting independently:

use std::{thread, time::Duration};

fn main() -> thread::Result<()> {
    let first = thread::spawn(|| {
        thread::sleep(Duration::from_secs(1));
        println!("first finished");
    });

    let second = thread::spawn(|| {
        thread::sleep(Duration::from_secs(1));
        println!("second finished");
    });

    first.join()?;
    second.join()?;
    Ok(())
}

We spawn both threads before joining either, so their waits can overlap. The program takes roughly one second plus scheduling overhead. The print order is unspecified.

thread::sleep blocks the calling thread. The OS can run other threads while it sleeps. join() also blocks its caller until the target thread finishes. See the standard library’s thread documentation.

Spawning a thread for every operation allocates a stack and OS scheduling resources for each one. With async tasks, many waiting connections can share a smaller set of threads.

tokio::spawn schedules a future

Tokio runs tasks by polling their futures. A poll either completes with Ready or returns Pending. When a task returns Pending, Tokio can use that thread for other work. The awaited operation arranges a wake-up so Tokio knows when to poll the task again.

Scheduling is cooperative. A long computation inside a poll keeps the worker occupied until the code returns control. An .await can return immediately when its future is ready, so writing .await does not guarantee a switch to another task. See Tokio’s task documentation.

To run the following example, add Tokio to a Cargo project’s dependencies:

[dependencies]
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "time"] }

Use this as src/main.rs:

use tokio::time::{sleep, Duration};

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), tokio::task::JoinError> {
    let first = tokio::spawn(async {
        sleep(Duration::from_secs(1)).await;
        println!("first finished");
    });

    let second = tokio::spawn(async {
        sleep(Duration::from_secs(1)).await;
        println!("second finished");
    });

    first.await?;
    second.await?;
    Ok(())
}

This runtime runs both tasks on one thread. Each timer suspends its task while waiting, leaving the thread available to poll the other task. Their waits overlap, so this also takes roughly one second, even though the tasks’ Rust code executes one at a time.

Both calls to tokio::spawn submit work to the scheduler immediately. While we await first for its result, Tokio can keep running second.

When Tokio tasks run in parallel

Change the runtime attribute in that example to:

#[tokio::main(flavor = "multi_thread", worker_threads = 2)]

Tokio now has two worker threads for async tasks. Different tasks can execute in parallel if the OS schedules those workers on different cores. A task may resume on a different worker after yielding, but Tokio does not poll the same task concurrently on two workers.

If you spawn 1,000 tasks on this runtime, Tokio distributes the runnable tasks across those two workers. The OS still schedules the workers, so parallel execution depends on available CPU capacity. See Tokio’s runtime documentation.

The timer example still mostly waits. Its roughly one-second duration shows that the waits overlap; it tells us nothing about whether the tasks execute code in parallel.

std::thread::spawntokio::spawn
CreatesAn OS threadAn async task
Scheduled byThe OSTokio, on OS threads
Waiting for completionhandle.join() blocks the callerhandle.await can suspend the caller’s task
Parallel executionPossible across coresPossible across runtime workers and cores
Blocking callsBlock that threadBlock a worker shared by tasks

Blocking work inside a task

Replacing tokio::time::sleep(...).await with std::thread::sleep(...) in the single-thread example blocks the runtime thread. The two sleeps then take roughly two seconds in total because the second task cannot run during the first task’s blocking sleep.

A long loop doing hashing or signature verification also occupies the worker, even inside an async block. Tokio cannot preempt that loop to run another task on the same worker. Adding more workers leaves some capacity for other tasks, but enough blocking tasks can occupy all of them.

For synchronous operations in an async application, tokio::task::spawn_blocking runs a closure on a separate blocking pool. For substantial CPU work, limit concurrency or use a dedicated CPU pool such as Rayon; Tokio’s blocking pool has a large default thread limit. See the spawn_blocking documentation.

Use async tasks to wait for network responses. If processing those responses requires heavy computation, send that work to a bounded pool so the async workers can keep handling connections.