Skip to main content
Navigation
HomeTechnical ReferenceJournalGitHubGitHub
Sidebar — toggle document categories via the logo
Categories

Multithread Primitives

Overview

Rust's type system makes concurrency safe at compile time. Send and Sync traits govern which types can cross thread boundaries, and the standard library provides battle-tested primitives from mutexes to channels to atomics. This document covers every major concurrency primitive in std::sync and tokio::sync, with guidance on when to use each one.

Mutex — mutual exclusion

std::sync::Mutex

A blocking mutual exclusion lock. Exactly one thread can hold the lock at a time:

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

let counter = Arc::new(Mutex::new(0));

let handles: Vec<_> = (0..10).map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
// lock released here when `num` goes out of scope
})
}).collect();

for h in handles { h.join().unwrap(); }
assert_eq!(*counter.lock().unwrap(), 10);

Key behaviors:

  • lock() blocks the calling thread until the lock is available.
  • Returns a MutexGuard<T> — deref to &mut T, auto-releases on drop.
  • If a thread panics while holding the lock, it becomes poisoned. lock() returns Err(PoisonError) — you can still access the data via into_inner().
  • Must not hold a std::sync::Mutex across .await — the task may not resume on the same thread, and MutexGuard is not Send.

When to use std::sync::Mutex

ScenarioUse?
Synchronous, short critical sections
Held across .await✗ — use tokio::sync::Mutex
Very high contention✗ — can cause thread parking overhead
Simple shared mutable state✓ — default choice

Parking_lot::Mutex (community alternative)

parking_lot offers faster mutex implementations with fewer dependencies:

use parking_lot::Mutex;

let m = Mutex::new(0);
*m.lock() += 1;
// No unwrap() needed — never poisoned
// No guard poisoning; panic-in-holder releases the lock cleanly

Advantages: smaller, faster (spins briefly before parking), never poisoned, const-constructible. Highly recommended for performance-sensitive code.

RwLock — read-write lock

Allows multiple readers or one writer:

use std::sync::RwLock;

let lock = RwLock::new(5);

// Many readers concurrently
let r1 = lock.read().unwrap();
let r2 = lock.read().unwrap();
assert_eq!(*r1 + *r2, 10);
drop(r1);
drop(r2);

// One writer — blocks until all readers release
let mut w = lock.write().unwrap();
*w = 10;

RwLock vs Mutex tradeoffs

CriterionMutexRwLock
Single accessOne thread onlyOne writer OR many readers
OverheadLower — no reader trackingHigher — atomic reader count
Write starvationN/APossible under heavy read load
Best forShort critical sectionsRead-heavy workloads
Worst forRead-heavy workloads (unnecessary serialization)Write-heavy or balanced workloads (extra overhead)

Rule of thumb: use RwLock when reads outnumber writes at least 10:1 and read sections are non-trivial. Otherwise Mutex is simpler and faster.

MPSC channels

std::sync::mpsc — multi-producer, single-consumer

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

let (tx, rx) = mpsc::channel::<String>();

// Many producers (tx is Clone)
let tx1 = tx.clone();
thread::spawn(move || { tx1.send("from thread 1".into()).unwrap(); });
let tx2 = tx.clone();
thread::spawn(move || { tx2.send("from thread 2".into()).unwrap(); });
drop(tx); // drop the original or rx.iter() will never terminate

// Single consumer
for msg in rx.iter() {
println!("{}", msg);
}
// rx.iter() stops when all senders are dropped

Channel variants

// Unbounded — send never blocks (risk: memory exhaustion)
let (tx, rx) = mpsc::channel();

// Bounded — send blocks when full (backpressure)
let (tx, rx) = mpsc::sync_channel(10); // capacity of 10

// Try — non-blocking variants
tx.try_send(msg); // Result<(), TrySendError>
rx.try_recv(); // Result<T, TryRecvError>

tokio::sync::mpsc — async channels

use tokio::sync::mpsc;

let (tx, mut rx) = mpsc::channel::<i32>(32); // bounded, capacity 32

// Async send — waits for capacity when full
tokio::spawn(async move {
tx.send(42).await.unwrap();
});

// Async receive — waits for data when empty
while let Some(msg) = rx.recv().await {
println!("{}", msg);
}

std vs tokio channels

Featurestd::sync::mpsctokio::sync::mpsc
Receiver typeReceiver<T>mpsc::Receiver<T>
Blocking recvrx.recv() blocks thread✗ no blocking API
Async recv✗ not availablerx.recv().await
ClosureSender drop closes channelSame
Use in asyncUse only in spawn_blockingPrimary choice in tokio tasks

tokio::sync primitives

tokio::sync::Mutex

An async mutex — safe to hold across .await:

use tokio::sync::Mutex;

let m = Mutex::new(0);

async fn increment(m: &Mutex<i32>) {
let mut guard = m.lock().await;
*guard += 1;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
// guard auto-drops here — safe because tokio Mutex is Send
}

Unlike std::sync::Mutex, the guard can be sent between threads (it implements Send). However, the lock implementation uses an async semaphore internally, making it slower than std::sync::Mutex for short critical sections.

Prefer std::sync::Mutex when the critical section does not cross an .await boundary.

tokio::sync::RwLock

Async read-write lock:

use tokio::sync::RwLock;

let lock = RwLock::new(5);

// Concurrent reads
let r1 = lock.read().await;
let r2 = lock.read().await;

// Write waits until all reads drop
let mut w = lock.write().await;
*w = 10;

Caveat: tokio's RwLock is not fair — under heavy read load, writers can starve. Use tokio::sync::RwLock::max_reads() to mitigate this.

Semaphore

Limits concurrent access to a resource:

use tokio::sync::Semaphore;
use std::sync::Arc;

let sem = Arc::new(Semaphore::new(5)); // 5 concurrent permits

for i in 0..100 {
let permit = sem.clone().acquire_owned().await.unwrap();
tokio::spawn(async move {
// At most 5 of these run concurrently
do_work(i).await;
drop(permit); // return the permit
});
}

Notify

A single-waker event primitive — lighter than a semaphore:

use tokio::sync::Notify;
use std::sync::Arc;

let notify = Arc::new(Notify::new());

// Waiter
let n = notify.clone();
let handle = tokio::spawn(async move {
n.notified().await;
println!("woken up!");
});

// Notifier
notify.notify_one(); // wakes exactly one waiter
// notify.notify_waiters(); // wakes all waiters

handle.await.unwrap();

Barrier

Synchronize multiple tasks at a rendezvous point:

use tokio::sync::Barrier;
use std::sync::Arc;

let barrier = Arc::new(Barrier::new(3));

for i in 0..3 {
let b = barrier.clone();
tokio::spawn(async move {
println!("{i} doing phase 1");
b.wait().await; // all 3 must arrive before any proceed
println!("{i} doing phase 2");
});
}

std::sync::Barrier is the blocking equivalent for OS threads.

Atomic primitives

Atomics provide lock-free, wait-free operations on primitive types:

use std::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering};

let flag = AtomicBool::new(false);
let counter = AtomicUsize::new(0);

// Store
flag.store(true, Ordering::Release);

// Load
let v = flag.load(Ordering::Acquire);

// Atomic operations
counter.fetch_add(1, Ordering::Relaxed);
counter.fetch_sub(1, Ordering::Relaxed);
counter.compare_exchange(5, 10, Ordering::AcqRel, Ordering::Acquire);

Memory ordering

OrderingGuaranteeCostUse case
RelaxedOnly atomicity, no ordering guaranteeCheapestCounters where order doesn't matter
Acquire / ReleaseAcquire-load sees all stores before a corresponding Release-storeModerateMutex implementation, flags
AcqRelBoth Acquire and ReleaseModerateCompare-exchange loops
SeqCstTotal global orderMost expensiveWhen you need globally consistent ordering

Default to Relaxed for counters. Use Acquire/Release pairs for flag-based signalling. Reserve SeqCst for when you're unsure — it's never incorrect, just slower.

Atomic patterns

// Spinlock
use std::sync::atomic::{AtomicBool, Ordering};
use std::hint;

let locked = AtomicBool::new(false);

fn acquire() {
while locked.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed).is_err() {
hint::spin_loop(); // yield to CPU on contention
}
}

fn release() {
locked.store(false, Ordering::Release);
}

// Lock-free counter
static HIT_COUNT: AtomicU64 = AtomicU64::new(0);
HIT_COUNT.fetch_add(1, Ordering::Relaxed);

// One-time initialization
use std::sync::Once;

static INIT: Once = Once::new();
INIT.call_once(|| {
// runs exactly once across all threads
initialize_subsystem();
});

Arc — shared ownership

use std::sync::Arc;

let data = Arc::new(vec![1, 2, 3]);

// Cheap clone — increments atomic reference count
let clone1 = Arc::clone(&data);
let clone2 = Arc::clone(&data);

// When the last Arc is dropped, the inner vec is freed

Arc<T> provides Send and Sync when T: Send + Sync. For single-threaded shared ownership, use Rc<T> instead — it's cheaper (non-atomic reference count).

Condvar — condition variables

Waits until a predicate becomes true:

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

let pair = Arc::new((Mutex::new(false), Condvar::new()));
let pair2 = Arc::clone(&pair);

// Waiter thread
thread::spawn(move || {
let (lock, cvar) = &*pair2;
let mut started = lock.lock().unwrap();
while !*started {
started = cvar.wait(started).unwrap();
}
println!("started!");
});

// Signaller
let (lock, cvar) = &*pair;
let mut started = lock.lock().unwrap();
*started = true;
cvar.notify_one();

Always wrap wait in a while loop — spurious wakeups can occur.

OnceLock / OnceCell (stable since Rust 1.80)

Lazy, one-time-initialized values:

use std::sync::OnceLock;

static CONFIG: OnceLock<String> = OnceLock::new();

fn get_config() -> &'static String {
CONFIG.get_or_init(|| {
std::fs::read_to_string("config.toml").unwrap()
})
}

OnceLock<T> is Send + Sync when T: Send + Sync. Use std::cell::OnceCell for single-threaded contexts.

crossbeam channels (community crate)

crossbeam provides faster, more featureful channels:

use crossbeam::channel;

// Multi-producer, multi-consumer (unlike std which is SP/MP→SC)
let (tx, rx) = channel::unbounded();

// Select over multiple channels
crossbeam::select! {
recv(rx1) -> msg => println!("rx1: {:?}", msg),
recv(rx2) -> msg => println!("rx2: {:?}", msg),
default(Duration::from_millis(100)) => println!("timeout"),
}

Key advantages over std::sync::mpsc:

  • Multiple consumers (MPMC instead of MPSC).
  • select! macro for waiting on multiple channels simultaneously.
  • Generally faster under contention.

Decision matrix

You need to...Use
Protect short, non-async critical sectionsstd::sync::Mutex
Protect across .await pointstokio::sync::Mutex
Read-heavy shared statestd::sync::RwLock / tokio::sync::RwLock
Send data between threads (one consumer)std::sync::mpsc
Send data between async taskstokio::sync::mpsc
Limit concurrent access (N permits)tokio::sync::Semaphore
Wake a task on an eventtokio::sync::Notify
Rendezvous point for threads/tasksstd::sync::Barrier / tokio::sync::Barrier
Lock-free counters or flagsAtomicUsize, AtomicBool
Shared read-only or read-write ownershipArc<T>
Wait for a predicatestd::sync::Condvar
One-time lazy initializationOnceLock<T>
MPMC channels or channel select!crossbeam::channel
Ultra-low-latency mutex (no poisoning)parking_lot::Mutex

See also