Skip to content
← Back to explainers

Rust explainer

Choosing your concurrency primitive

About 8 min to read. Allow extra time to work through examples.

Choose concurrency tools by the state they protect, the work they coordinate and how they shut down. Compare atomics, locks, channels and scoped threads with runnable examples.

This article uses Rust. You can make it your practice mode to browse related content across the site.

A concurrency primitive is part of a protocol: who owns the state, which operations must happen together, when a worker may proceed, and what happens if a participant stops. Start with those requirements before comparing costs.

Match the tool to the requirement

Requirement Consider Check before using it
Independent work on borrowed data thread::scope All scoped threads finish before the scope returns
Disjoint mutation of a buffer chunks_mut or split_at_mut, with scoped threads Each worker gets a non-overlapping slice
A shared counter or flag An appropriate atomic type Target support, overflow and memory ordering
A producer feeding a consumer A channel Capacity, blocking, disconnection and shutdown
An invariant spanning mutable fields Mutex<T> Keep related checks and updates in one critical section
Concurrent readers with exclusive writers RwLock<T> Measure the workload; do not assume a fairness policy
Initialization shared across threads OnceLock<T> Initialization failure and reentrancy behavior
Repeated rendezvous between a fixed number of participants Barrier Every phase needs the required number of arrivals
Waiting for protected state to change Condvar with a mutex Recheck the predicate after waking
State belonging to one operating-system thread thread_local! It is not automatically local to an async task or logical job

This is not a ranking from safest to fastest. These tools solve different coordination problems and can be combined. Arc supplies shared ownership when needed; it does not make the payload's operations synchronized.

Avoid sharing when the work is already separate

Partitioning a buffer gives each worker exclusive access to its own part:

use std::thread;

fn main() {
    let mut values = [1, 2, 3, 4];
    thread::scope(|scope| {
        for chunk in values.chunks_mut(2) {
            scope.spawn(move || {
                for value in chunk {
                    *value *= 2;
                }
            });
        }
    });
    assert_eq!(values, [2, 4, 6, 8]);
}

No mutex is needed because the slices do not overlap and the scope waits for workers before the result is read. Parallel execution is not necessarily faster for such a small input; thread creation and scheduling also cost work.

Atomics protect operations, not whole algorithms

Check your understanding Eight threads increment a counter. Should it use a mutex or an atomic? Show answer

If the required operation is an independent integer increment, an atomic fetch_add is a useful fit. A separate load followed by a store is not an atomic read-modify-write operation and can lose updates.

Decide what the counter means. If it only counts completed operations and the result is read after joining all workers, relaxed operations may suffice. If it publishes access to other data, the protocol needs an appropriate synchronization relationship. Integer fetch_add wraps on overflow, so the intended overflow behavior also matters.

A mutex remains reasonable when the counter participates in a larger invariant. Updating several related fields under one guard is often simpler than a multi-atomic protocol. Multi-field atomic designs are possible, but their correctness needs a separate argument. Neither tool guarantees a particular instruction count or universal speed advantage.

Here the counter is the only shared state, its final value fits in usize, and all workers finish before the final load:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

fn main() {
    let count = AtomicUsize::new(0);
    thread::scope(|scope| {
        for _ in 0..8 {
            scope.spawn(|| {
                for _ in 0..1000 {
                    count.fetch_add(1, Ordering::Relaxed);
                }
            });
        }
    });
    assert_eq!(count.load(Ordering::Relaxed), 8000);
}

Atomic widths depend on target support; do not assume every target provides AtomicU64. Clippy's opt-in mutex_atomic and mutex_integer lints can suggest reviewing a mutex-wrapped scalar. A suggestion does not establish that replacing the mutex preserves the program's synchronization behavior.

Locks protect invariants

One Mutex<HashMap<K, V>> serializes access while its guard is held. That can be a simple, effective design when critical sections are short and contention is low; work outside the lock can still run in parallel.

If measurements show contention, consider partitioning independent state, sharding the map, or giving the map an owner thread that processes requests. Sharding helps only when operations spread across shards. A hot key can keep one shard busy, and operations needing several shards introduce lock-order questions. There is no guaranteed speedup equal to the shard count.

Keep guards away from blocking work when the invariant permits it. For an I/O-based update, one approach is to copy the needed state, release the lock, do the I/O, then reacquire the lock and validate that the result is still applicable before committing it. Simply moving work outside the lock can introduce a race if it separates a check from the update it protects.

Joining a worker while holding a lock it needs creates a deadlock. More generally, review dependencies between locks, joins, sends and receives. A short critical section can still deadlock if its dependencies form a cycle.

RwLock permits shared read guards or one exclusive write guard. It can help when concurrent reads do enough useful work to justify its coordination cost, but it is not automatically better for a read-heavy workload. The standard library does not guarantee a particular reader/writer priority policy, so do not base progress on a presumed fairness guarantee. Measure throughput and latency for both readers and writers.

The trait bounds differ too: Mutex<T>: Sync requires T: Send, while RwLock<T>: Sync requires T: Send + Sync, because concurrent readers can hold shared references. Handle poisoning deliberately; unwrapping a poisoned lock result propagates failure rather than recovering the protected state.

Channels need a shutdown plan

Channels make ownership transfer explicit. A bounded channel can limit queued messages and apply backpressure by blocking a sender when full. That does not bound all application memory: producers may retain other state, and messages themselves can vary in size. Backpressure also creates dependencies that can deadlock if a receiver waits for a sender that cannot finish its send.

This example consumes messages while the producer is active. The last sender is dropped when that producer exits, allowing the receiver loop to finish:

use std::sync::mpsc::sync_channel;
use std::thread;

fn main() {
    let (sender, receiver) = sync_channel(2);
    let total: i32 = thread::scope(|scope| {
        scope.spawn(move || {
            for value in 1..=4 {
                sender.send(value).unwrap();
            }
        });
        receiver.iter().sum()
    });
    assert_eq!(total, 10);
}

Retaining an unused sender clone would keep this receiver waiting after the producer finishes. Dropping the receiver makes subsequent sends fail. An owner-thread design centralizes state changes, but requests are still serialized there and callers need an error and shutdown protocol. The channel implementation may use locks internally; message passing does not mean that the entire program is lock-free.

Wait for state, not just a notification

A condition variable is used with a predicate protected by a mutex. Waiting releases that mutex and reacquires it before returning. Notifications are not saved as application state, and waking does not prove the predicate is true. wait_while checks it again, including after spurious wakeups:

use std::sync::{Condvar, Mutex};
use std::thread;

fn main() {
    let ready = Mutex::new(false);
    let changed = Condvar::new();
    thread::scope(|scope| {
        scope.spawn(|| {
            *ready.lock().unwrap() = true;
            changed.notify_one();
        });
        let guard = ready.lock().unwrap();
        let guard = changed.wait_while(guard, |ready| !*ready).unwrap();
        assert!(*guard);
    });
}

For reusable phases, a Barrier instead waits for a configured number of arrivals. It does not track participant identity or automatically compensate for a worker that panics or exits early. Plan how every phase reaches the barrier and what happens on failure; a missing arrival can leave others blocked. A barrier also does not grant permission to mutate shared data without an appropriate ownership or synchronization mechanism.

Choose a worker count separately from the result contract

Check your understanding Can available_parallelism() affect a function's output? Show answer

It can legitimately affect output such as a resource report or a chosen pool configuration. For a computation promised to return the same result for any worker count, however, use it only to choose the execution strategy.

available_parallelism() returns a fallible estimate, not a fixed machine constant. It is recomputed on each call, can change as resource limits change, and has platform limitations involving affinity, quotas and virtual machines. Query it outside hot paths and provide an appropriate fallback. Its query mechanism and cost vary by platform; it is not specified as exactly one syscall.

Test whatever worker-count independence the API promises. Floating-point reductions may change when partitioning changes the addition order, so a reproducibility requirement needs an explicit reduction strategy or tolerance. Do not assume every concurrency exercise or every real program has the same result contract.

Choose a design whose invariants and termination conditions you can explain. Then measure the actual workload, including contention, scheduling, allocation and communication. Partitioning can simplify reasoning, but copying data or merging results can also add costs; no fixed ranking replaces that analysis.

step 23 of 24

Fearless Concurrency: Threads, Channels, Shared State

Up next Reduce deadlock risks in shared-state code