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::spawn | tokio::spawn | |
|---|---|---|
| Creates | An OS thread | An async task |
| Scheduled by | The OS | Tokio, on OS threads |
| Waiting for completion | handle.join() blocks the caller | handle.await can suspend the caller’s task |
| Parallel execution | Possible across cores | Possible across runtime workers and cores |
| Blocking calls | Block that thread | Block 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.