← All weekly notes

16 min read

Threads, Tokio tasks and lock-free concurrency

A layered mental model for OS threads, async tasks, synchronization, blocking, atomics and choosing the right Rust primitive.

Rust · Tokio · Concurrency · Async

In November 2025 I wanted a cleaner mental model for concurrency in Rust.

I kept seeing threads, Tokio tasks, channels, mutexes and lock-free data structures discussed as if they were competing solutions. They are not. They live at different layers and answer different questions.

The model that finally made the subject click was to separate four decisions:

QuestionConcernTypical choices
Who executes the work?SchedulingOS threads, Tokio tasks
How does waiting happen?WaitingBlocking, asynchronous suspension
How is state coordinated?SynchronizationMutex, channel, semaphore, atomic
What progress is guaranteed?Algorithmic progressBlocking, lock-free, wait-free

A Tokio task can use a mutex. A channel can be implemented with atomics and locks. An OS thread can run a lock-free algorithm. An async program can still block accidentally.

Those combinations are not contradictions. They are the reason the layers must stay separate.

Layer one: OS threads and Tokio tasks

An operating-system thread is a unit the kernel schedules onto a CPU. It owns a stack and can be preempted by the operating system.

If a thread calls a blocking receive and no value is available, that thread cannot execute application work until the operation completes:

use std::sync::mpsc;use std::thread; let (tx, rx) = mpsc::channel(); thread::spawn(move || {	let value = rx.recv().expect("sender closed");	process(value);});

The kernel may schedule another thread on the CPU, but the blocked thread itself is unavailable:

OS scheduler    |    +-- Thread A -- blocked in recv()    +-- Thread B -- runnable    +-- Thread C -- runnable

A Tokio task is different. It is a Rust future owned and scheduled by the Tokio runtime. Many tasks can share a much smaller number of runtime worker threads.

use tokio::sync::mpsc; async fn consume(mut rx: mpsc::Receiver<Job>) {	while let Some(job) = rx.recv().await {		process(job).await;	}}

When recv cannot produce a value, the future returns Pending. Tokio records how to wake the task and lets the worker thread poll another ready task.

Tokio worker thread        |        +-- Task A -- Pending: waiting for a message        +-- Task B -- Ready: execute        +-- Task C -- Ready: execute

Only Task A is suspended. The worker is free to do other work.

There is one important correction to the phrase “the runtime thread never sleeps.” A Tokio worker can sleep when there is no ready work, and it can be blocked by accidental blocking code. The precise statement is:

A pending async operation does not require its runtime worker thread to remain blocked on behalf of that task.

That is the scalability advantage. It is not that waiting disappears; waiting becomes a suspended task rather than a dedicated blocked thread.

What async and await compile into

Calling an async function does not immediately execute the whole function. It creates a future. Conceptually, the compiler turns the function into a state machine containing:

  • The current suspension point.
  • Local values that must survive across suspension.
  • A poll method that attempts to make progress.

The core interface is approximately:

trait Future {	type Output; 	fn poll(		self: Pin<&mut Self>,		cx: &mut Context<'_>,	) -> Poll<Self::Output>;}

Polling returns one of two states:

Ready(value)  -> the future completedPending       -> it cannot continue now

When returning Pending, a well-behaved future arranges for the task's Waker to be called when progress may be possible. The wake operation places the task back into a runnable queue. The executor can then poll it again.

poll task   |   +-- Ready  ------> return result   |   +-- Pending -----> register waker                          |                    I/O or timer completes                          |                         wake                          |                    runnable queue

An await expression is therefore a possible yield point, not a guaranteed context switch. If the awaited future is already ready, execution may continue immediately. A CPU loop with no operation that returns Pending can occupy a worker for a long time.

What Tokio adds

Rust's standard library defines futures, but futures need something to drive them. A Tokio runtime combines three important services:

  1. A scheduler that polls ready tasks.
  2. An I/O driver that notices socket and operating-system events.
  3. A timer driver that wakes sleeping tasks.

The default multi-thread scheduler uses several worker threads and work stealing. Each worker prefers nearby runnable work. An idle worker can take work from another worker's queue.

                  global runnable queue                           |        +------------------+------------------+        |                  |                  |     worker 0           worker 1           worker 2     local queue        local queue        local queue        |                  |                  |      poll tasks         poll tasks         poll tasks        ^                                      |        +------------ steal work <-------------+

Tokio also has a current-thread runtime. It runs async tasks on one thread and is useful when parallel execution is unnecessary or when embedding async operations behind a synchronous interface.

The scheduler is cooperative at the task level. A future is expected to return from poll promptly. Tokio primitives participate in a cooperative budget, but arbitrary user code cannot be forcibly interrupted at every instruction.

async fn bad_worker() {	loop {		expensive_step(); // no await and no yield	}}

This task can starve other tasks assigned to the same worker. Adding async to the function signature does not make the loop cooperative.

Why spawned tasks need Send and static

On the multi-thread runtime, Tokio may resume a suspended task on a different worker. The future passed to tokio::spawn must therefore be Send.

The subtle part is that every value retained across an await becomes part of the future's state machine. If one of those values cannot safely move between threads, the complete future is not Send.

use std::rc::Rc; async fn cannot_be_spawned() {	let value = Rc::new("local value");	do_io().await;	println!("{value}");}

Rc is designed for single-threaded reference counting. Because value remains live across the await, this future cannot be passed to tokio::spawn on the multi-thread scheduler.

Often the fix is to use Arc for genuinely shared thread-safe ownership, or to ensure the non-Send value is dropped before the await:

async fn can_be_spawned() {	{		let value = Rc::new("temporary value");		println!("{value}");	} // value is gone before suspension 	do_io().await;}

The static bound on a spawned future is also easy to misread. It does not mean the task must live forever. It means the task must not borrow data that could disappear while Tokio still owns the task. Moving owned values into the async block normally satisfies that requirement.

For deliberately thread-local futures, Tokio provides LocalSet and spawn_local. That is a scheduling decision: the task gives up migration and stays on the thread driving the local set.

Scheduling is not synchronization

Scheduling decides which work executes. Synchronization decides how concurrent work coordinates state and events.

Rust and Tokio provide several different synchronization tools:

PrimitiveWhat it coordinates
MutexExclusive access to an invariant
RwLockMultiple readers or one writer
ChannelOwnership or messages moving between tasks
SemaphoreA bounded number of concurrent operations
Notify or CondvarA state-change notification
AtomicA small operation on shared memory

Tokio does not replace these primitives. It provides asynchronous variants of the ones that may need to wait.

The important question is not “Is this a Tokio program?” It is:

What must be protected, and is it acceptable to block a worker while waiting?

Blocking mutex versus async mutex

A standard mutex blocks the calling thread when it cannot acquire the lock:

use std::collections::HashMap;use std::sync::{Arc, Mutex}; type Cache = Arc<Mutex<HashMap<String, String>>>; fn insert(cache: &Cache, key: String, value: String) {	let mut map = cache.lock().expect("cache mutex poisoned");	map.insert(key, value);}

This can be a good choice inside async code when:

  • The critical section is short.
  • The guard is never held across an await.
  • Contention is low.
  • The protected work is ordinary in-memory work.

The lock is synchronous, but that does not automatically make it wrong. A short uncontended standard mutex is often simpler and cheaper than an async mutex.

A Tokio mutex waits asynchronously:

use tokio::sync::Mutex; async fn update(state: &Mutex<State>) {	let mut state = state.lock().await;	state.version += 1;}

If the lock is unavailable, the task yields instead of blocking the runtime worker. Its guard is also designed to survive across await points.

That ability should not become the default design:

async fn risky(state: &Mutex<State>) -> Result<()> {	let mut state = state.lock().await;	let response = remote_call().await?; // lock held during network I/O	state.apply(response);	Ok(())}

This is legal, but it serializes every caller for the entire network round trip. A better design often copies or moves the required input out, releases the guard, performs I/O, and reacquires only to commit the result:

async fn better(state: &Mutex<State>) -> Result<()> {	let request = {		let state = state.lock().await;		state.build_request()	}; 	let response = remote_call(request).await?; 	{		let mut state = state.lock().await;		state.apply(response);	} 	Ok(())}

The choice is not “standard Rust versus Tokio.” It is about the lifetime of the critical section and the type of waiting that contention creates.

Channels: synchronization plus ownership

A channel lets producers communicate with a consumer without all callers directly mutating the same object.

use tokio::sync::{mpsc, oneshot}; enum Command {	Get {		key: String,		reply: oneshot::Sender<Option<String>>,	},	Put {		key: String,		value: String,	},} async fn state_owner(mut rx: mpsc::Receiver<Command>) {	let mut state = std::collections::HashMap::new(); 	while let Some(command) = rx.recv().await {		match command {			Command::Get { key, reply } => {				let value = state.get(&key).cloned();				let _ = reply.send(value);			}			Command::Put { key, value } => {				state.insert(key, value);			}		}	}}

Only the owner task touches the map, so the map itself needs no mutex. The channel still performs synchronization internally.

This actor-like design is especially useful when the owned resource naturally performs async operations, such as:

  • A database connection.
  • A socket writer.
  • A batching pipeline.
  • A state machine that needs ordered commands.

A bounded channel also creates backpressure:

let (tx, rx) = tokio::sync::mpsc::channel::<Command>(256);

After 256 buffered commands, send().await waits until capacity is available. That limit turns overload into explicit waiting instead of unlimited memory growth.

An unbounded channel does not apply this pressure. It may be appropriate when the producer is inherently bounded, but it should not be a convenient way to postpone capacity planning.

Async does not imply lock-free

This is the misconception I wanted to remove completely.

When I write:

let value = receiver.recv().await;

the await says:

Suspend this task if the receive cannot complete now.

It says nothing about the queue's internal algorithm. A channel implementation may use:

  • Atomic operations.
  • Compare-and-swap loops.
  • Mutexes around some state.
  • Semaphores for capacity.
  • Linked blocks or ring buffers.
  • A combination of all of them.

“Async” describes the caller-visible waiting behavior. “Lock-free” describes a progress property of the implementation.

What lock-free actually means

Consider a shared counter protected by a mutex:

let mut value = counter.lock().expect("counter mutex poisoned");*value += 1;

If the thread holding the lock is paused, every thread waiting for that lock can be prevented from making progress.

An atomic counter avoids that lock:

use std::sync::atomic::{AtomicU64, Ordering}; static REQUESTS: AtomicU64 = AtomicU64::new(0); fn record_request() {	REQUESTS.fetch_add(1, Ordering::Relaxed);}

For a statistics counter whose value does not publish other memory, Relaxed ordering is often sufficient. The update is atomic, but no additional ordering relationship is requested.

More complex updates commonly use compare-and-swap:

use std::sync::atomic::{AtomicU64, Ordering}; fn increment_below(counter: &AtomicU64, limit: u64) -> bool {	let mut current = counter.load(Ordering::Relaxed); 	loop {		if current >= limit {			return false;		} 		match counter.compare_exchange_weak(			current,			current + 1,			Ordering::Relaxed,			Ordering::Relaxed,		) {			Ok(_) => return true,			Err(observed) => current = observed,		}	}}

Two CPUs may read the same old value. One wins the update; the other observes failure and retries.

CPU 1: load 10 -> CAS 10 to 11 -> successCPU 2: load 10 -> CAS 10 to 11 -> failure -> reload -> retry

There is no mutex, but there is still contention, cache-line traffic and retry work.

The progress terms are more precise than “uses no lock”:

PropertyGuarantee
BlockingA paused owner may prevent everyone else from progressing
Lock-freeThe system as a whole continues to make progress
Wait-freeEvery participating operation completes within a bounded number of its own steps

Lock-free does not mean every thread progresses fairly. One unlucky thread may repeatedly lose the CAS race while other threads succeed. That is still lock-free at the system level, but not wait-free for the individual operation.

It also does not mean faster. Under high contention, a mutex may avoid repeated failed work and perform better. The correct choice depends on the invariant, contention pattern, memory layout and measurement.

Atomics need a memory-ordering argument

Atomicity answers whether an operation can be torn or interleaved. Memory ordering answers what other reads and writes become visible around it.

Rust exposes several orderings:

OrderingMental model
RelaxedAtomic access without cross-variable ordering
AcquireLater operations observe data published before a matching release
ReleaseEarlier operations are published before the release
AcqRelBoth acquire and release behavior for read-modify-write
SeqCstStrongest model, including a single global order for sequentially consistent operations

A counter can often use Relaxed. Publishing a newly initialized data structure through an atomic pointer or flag usually needs a Release store paired with an Acquire load. Complex lock-free structures need a proof of their happens-before relationships, not copied orderings from an unrelated example.

Atomics are excellent for:

  • Independent counters.
  • Flags and state transitions.
  • Reference counts.
  • Carefully designed pointer publication.
  • Building higher-level synchronization primitives.

They are usually a poor first tool for a multi-field business invariant:

balance >= reservedavailable = balance - reservedversion matches the data snapshot

Making each field atomic does not make the relationship among them atomic.

I/O-bound work: where Tokio helps

Imagine ten thousand requests, each waiting 50 milliseconds for a database or another service.

With one blocking thread per request, the program needs a large number of stacks and kernel-scheduled threads to represent the waiting.

With Tokio, each request can be a suspended task:

request task    |    +-- parse request    +-- send database operation    +-- await response ------> Pending                                  |runtime worker polls other tasks -+                                  |database readiness event --------> wake task    |    +-- resume and encode response

Async does not reduce the database's 50-millisecond latency. It improves utilization during those 50 milliseconds.

A production service still needs bounded concurrency. Spawning cheap tasks without controlling the expensive resource merely moves the queue.

use std::sync::Arc;use tokio::sync::Semaphore;use tokio::time::{timeout, Duration}; async fn fetch_with_limit(	limit: Arc<Semaphore>,	key: String,) -> Result<Value, Error> {	let permit = timeout(		Duration::from_millis(100),		limit.acquire_owned(),	)	.await	.map_err(|_| Error::Overloaded)?	.map_err(|_| Error::ShuttingDown)?; 	let result = timeout(		Duration::from_secs(2),		fetch_remote(key),	)	.await	.map_err(|_| Error::Timeout)?	.map_err(Error::Remote)?; 	drop(permit);	Ok(result)}

The semaphore limits in-flight calls. The first timeout bounds queueing for a permit; the second bounds the remote operation. This makes overload and latency part of the design rather than accidental runtime behavior.

CPU-bound work: where async does not help

Now consider image compression, signature verification or a long calculation:

fn compress(image: &[u8]) -> Vec<u8> {	// 200 milliseconds of CPU work	do_compression(image)}

There is no external event to await. The CPU must perform the work. Running this directly inside a Tokio task occupies its worker until the function returns.

For bounded blocking or CPU work, Tokio provides spawn_blocking:

use tokio::task; async fn compress_async(image: Vec<u8>) -> Result<Vec<u8>, Error> {	let output = task::spawn_blocking(move || compress(&image))		.await		.map_err(Error::Join)?; 	Ok(output)}

This protects async worker threads by moving the closure to Tokio's blocking pool. It does not make the computation asynchronous internally or reduce its CPU cost.

Two caveats matter:

  1. A blocking task that has started cannot generally be aborted by cancelling its JoinHandle.
  2. The blocking pool can grow, so CPU-heavy work should be bounded with a semaphore or moved to a dedicated pool.

For sustained data-parallel computation, Rayon or a purpose-built worker pool is usually a better fit. For a long-lived blocking loop, a dedicated thread can be clearer than spawn_blocking.

Cancellation is part of synchronization

Async tasks can be cancelled while suspended. That changes how I think about state updates.

If a future is dropped at an await point:

  • Stack-like local values are dropped.
  • Mutex and semaphore guards owned by the future are released.
  • Partially completed external operations may still have happened.
  • A spawned child task continues unless it is explicitly cancelled or tied to structured ownership.

This means a sequence such as “write remotely, await acknowledgement, update local state” needs the same idempotency and recovery thinking as any distributed operation. Async cancellation is not a database transaction rollback.

Timeouts, task abortion and select branches should therefore be reviewed for cancellation safety, especially around channels, buffered I/O and multi-step protocols.

Failure modes I now look for

Blocking inside an async task

File-system calls, synchronous database drivers, thread sleep and large CPU loops can occupy a Tokio worker.

Use async APIs where available. Isolate unavoidable blocking work with spawn_blocking or dedicated threads.

Holding a lock across an await

This may serialize unrelated requests and create lock-ordering problems that span network latency.

Keep critical sections small. Prefer ownership through a task when one async resource must be serialized.

Using an async mutex for every shared value

An async mutex is not automatically more idiomatic. A standard mutex is often right for a short in-memory update that cannot cross an await.

Assuming a channel removes contention

A hot multi-producer queue can become the shared bottleneck. Batching, sharding, per-worker queues or direct ownership may scale better.

Unbounded spawning

Tasks are lightweight, not free. Each task retains its future state, buffers, references and queued work. Bound the scarce resource rather than relying on cheap task creation.

Replacing an invariant with several atomics

Atomic fields do not automatically create an atomic transaction. If several values must agree, start with a mutex or single owner unless a lock-free design has a clear correctness proof.

A practical decision guide

SituationFirst choice
Short in-memory critical section, no awaitstd::sync::Mutex
Lock acquisition or guard must span awaittokio::sync::Mutex
One async resource needs ordered ownershipDedicated task plus bounded mpsc
Limit database or HTTP concurrencySemaphore
Independent statistics counterAtomic with justified ordering
Blocking library callspawn_blocking
Sustained CPU-parallel workRayon or dedicated CPU pool
Long-lived blocking workerDedicated OS thread

The decision can be reduced to four questions:

1. Is the workload waiting on I/O or consuming CPU?2. Must several workers mutate the same invariant?3. Can a lock guard remain entirely between await points?4. What bounds the number of tasks, messages and external operations?

The mental model I kept

                         Concurrency                              |             +----------------+----------------+             |                                 |       OS threads                         Tokio tasks   kernel scheduled                 runtime scheduled futures             |                                 |   blocking waits occupy              Pending yields the task      that thread                    and registers a wakeup             +----------------+----------------+                              |                    State coordination                              |        Mutex   RwLock   Channel   Semaphore   Atomic                              |                    Implementation property                              |              blocking   lock-free   wait-free

My short version is:

  • Threads versus Tokio tasks asks who is scheduled.
  • Blocking versus await asks what is occupied while waiting.
  • Mutexes, channels and atomics ask how state is coordinated.
  • Lock-free versus lock-based asks what progress the algorithm guarantees.

Async does not make one operation faster. It allows a worker to make progress on other tasks while an operation is waiting.

Tokio does not imply lock-free. It supplies scheduling, I/O and async-aware coordination primitives. Their internal algorithms are a separate question.

And lock-free does not mean contention-free. It replaces ownership of a lock with atomic coordination, retries and a different progress guarantee.

References