Concurrency
Rust’s concurrency story has two principal threads: thread-based concurrency (std::thread) for the OS-level surface, and async/await for the lightweight, runtime-driven surface. The substance of Rust’s contribution is the type system’s enforcement of thread safety — the Send and Sync traits ensure that data races are caught at compile time, not at runtime. The conventional async runtime is tokio; alternatives include async-std and smol. The combination — OS threads with safe sharing primitives (Arc, Mutex, RwLock), async/await with runtime-provided executors, channels for message passing, the type system enforcing data-race freedom — admits substantial concurrency safely.
This page covers thread-based concurrency, async/await, channels, synchronisation primitives, and the conventional patterns.
OS threads
The std::thread module admits OS-thread-based concurrency:
use std::thread;
let handle = thread::spawn(|| {
println!("hello from a thread");
42
});
let result = handle.join().unwrap(); // wait for completion
println!("result: {}", result);
The thread::spawn returns a JoinHandle<T>; join() waits for the thread and returns its result.
Sharing data with threads
A spawned thread typically needs to access data from the spawning thread. The conventional approach:
use std::thread;
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("{:?}", v); // moves v into the thread
});
handle.join().unwrap();
// v is no longer accessible here
The move keyword admits capturing by value; the thread receives ownership.
Shared state
For shared mutable state across threads, Arc (atomic reference count) plus Mutex:
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap()); // 10
The pattern:
Arc<T>— atomically-counted reference; admits shared ownership across threads.Mutex<T>— admits exclusive access to the protected value.Arc::clone— produces another reference to the same data.mutex.lock()— acquires the lock; returns aMutexGuardthat derefs to the protected value.
Treated in Smart pointers.
Send and Sync
The type system distinguishes thread-safe data:
| Trait | Meaning |
|---|---|
Send | Safe to move to another thread |
Sync | Safe to share by reference across threads |
Most types are Send and Sync automatically; the principal exceptions:
Rc<T>— notSend(non-atomic refcount).RefCell<T>— notSync(interior mutability without locks).- Raw pointers (
*const T,*mut T) — neither.
The compiler enforces these at the thread::spawn call:
use std::rc::Rc;
use std::thread;
let r = Rc::new(5);
thread::spawn(move || { // ERROR: Rc is not Send
println!("{}", r);
});
// Use Arc instead:
use std::sync::Arc;
let r = Arc::new(5);
thread::spawn(move || { // OK
println!("{}", r);
});
The mechanism produces compile-time data-race freedom.
Channels
The std::sync::mpsc (multi-producer, single-consumer) admits message-passing:
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
tx.send(42).unwrap();
});
let received = rx.recv().unwrap(); // 42
println!("got {}", received);
The conventional uses are producer-consumer patterns and worker pools:
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel();
for i in 0..10 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(i).unwrap();
});
}
drop(tx); // close the original sender
while let Ok(value) = rx.recv() { // iterate until all senders dropped
println!("got {}", value);
}
The tx.clone() admits multiple senders; the channel closes when all senders are dropped.
For more elaborate patterns, the third-party crossbeam crate provides multi-producer-multi-consumer channels and substantial additional concurrency primitives.
Atomics
The std::sync::atomic module admits lock-free shared state:
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use std::thread;
let counter = Arc::new(AtomicI32::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
counter.fetch_add(1, Ordering::SeqCst);
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", counter.load(Ordering::SeqCst)); // 10
The principal atomics are AtomicBool, AtomicI32/AtomicU32, AtomicI64/AtomicU64, AtomicUsize, AtomicPtr. The Ordering admits memory-ordering control:
SeqCst— sequentially consistent (the default conventional choice).Acquire/Release— pair-wise synchronisation.AcqRel— both for read-modify-write.Relaxed— no synchronisation guarantees.
The conventional discipline is SeqCst unless profiling shows it as a bottleneck and a more relaxed ordering is provably correct.
RwLock
For read-heavy workloads, RwLock admits multiple readers or one writer:
use std::sync::{Arc, RwLock};
use std::thread;
let data = Arc::new(RwLock::new(vec![1, 2, 3]));
// Readers (multiple at once):
let r1 = Arc::clone(&data);
thread::spawn(move || {
let v = r1.read().unwrap();
println!("{:?}", *v);
});
// Writers (exclusive):
let w = Arc::clone(&data);
thread::spawn(move || {
let mut v = w.write().unwrap();
v.push(4);
});
The conventional choice: Mutex for general-purpose locking; RwLock for read-heavy workloads where contention matters.
OnceLock and OnceCell
For one-time initialisation:
use std::sync::OnceLock;
static GLOBAL: OnceLock<String> = OnceLock::new();
fn global() -> &'static String {
GLOBAL.get_or_init(|| {
std::env::var("CONFIG").unwrap_or_else(|_| "default".to_string())
})
}
The OnceLock admits thread-safe lazy initialisation; treated in Smart pointers.
Barrier
For synchronising multiple threads at a point:
use std::sync::{Arc, Barrier};
use std::thread;
let barrier = Arc::new(Barrier::new(10));
let mut handles = vec![];
for _ in 0..10 {
let barrier = Arc::clone(&barrier);
handles.push(thread::spawn(move || {
// ... do some work ...
barrier.wait(); // wait for all 10
// ... continue ...
}));
}
The mechanism is rare in idiomatic code; conventional alternatives are channels and join handles.
async/await
Async functions return futures — values that produce a result asynchronously:
async fn fetch_data(url: &str) -> Result<String, Box<dyn std::error::Error>> {
let response = reqwest::get(url).await?;
let text = response.text().await?;
Ok(text)
}
The principal features:
async fn— declares an async function; the return type is wrapped inimpl Future<Output = T>..await— suspends the current task until the future resolves; admits chaining async operations.async {}— async block; produces an anonymous future.
Running async code
Async code requires a runtime (executor); the standard library does not include one. The conventional choice is tokio:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let body = reqwest::get("https://example.com").await?
.text().await?;
println!("{}", body);
Ok(())
}
The #[tokio::main] attribute macro rewrites main to set up the runtime; the body of main becomes an async block run by the runtime.
For non-main runtimes:
fn main() {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
// ... async work ...
});
}
Spawning async tasks
The tokio::spawn spawns a concurrent task:
let handle = tokio::spawn(async {
do_work().await
});
let result = handle.await.unwrap();
Tasks are similar to threads but cooperatively scheduled; many tasks share a small thread pool.
join! and try_join!
For waiting on multiple futures:
use tokio::join;
let (a, b, c) = join!(
fetch_a(),
fetch_b(),
fetch_c(),
);
// or with ? on errors:
use tokio::try_join;
let (a, b, c) = try_join!(
fetch_a(),
fetch_b(),
fetch_c(),
)?;
The macros admit concurrent execution with structured waiting.
select!
Wait for the first of several futures:
use tokio::select;
select! {
result = fetch_url(url) => {
println!("got: {:?}", result);
}
_ = tokio::time::sleep(Duration::from_secs(5)) => {
println!("timeout");
}
}
The conventional uses are timeouts, race conditions, and event loops.
Async channels
Tokio’s mpsc channels:
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::channel(100);
tokio::spawn(async move {
for i in 0..10 {
tx.send(i).await.unwrap();
}
});
while let Some(value) = rx.recv().await {
println!("got {}", value);
}
Tokio also provides oneshot (single-message), broadcast (one producer, many consumers), and watch (last-value broadcast) channels.
Async traits
Until Rust 1.75, async functions in traits required workarounds (async-trait macro). Since Rust 1.75:
trait Fetcher {
async fn fetch(&self, url: &str) -> Result<String, FetchError>;
}
The form is admitted natively; the principal restriction is that async-trait methods cannot be called through dyn Trait (trait objects) without the async-trait crate as of 2026.
Common patterns
Worker pool
use std::sync::mpsc;
use std::thread;
fn worker_pool<F>(num_workers: usize, work: F) -> mpsc::Sender<Job>
where F: Fn(Job) + Send + Sync + Clone + 'static
{
let (tx, rx) = mpsc::channel::<Job>();
let rx = Arc::new(Mutex::new(rx));
for _ in 0..num_workers {
let rx = Arc::clone(&rx);
let work = work.clone();
thread::spawn(move || {
while let Ok(job) = rx.lock().unwrap().recv() {
work(job);
}
});
}
tx
}
Read-mostly cache
use std::sync::RwLock;
use std::collections::HashMap;
struct Cache {
data: RwLock<HashMap<String, String>>,
}
impl Cache {
fn get(&self, key: &str) -> Option<String> {
self.data.read().unwrap().get(key).cloned()
}
fn set(&self, key: String, value: String) {
self.data.write().unwrap().insert(key, value);
}
}
Async server (tokio)
use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
loop {
let (mut socket, _) = listener.accept().await?;
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let n = socket.read(&mut buf).await.unwrap();
socket.write_all(&buf[..n]).await.unwrap();
});
}
}
Concurrent fetches
use futures::future::join_all;
async fn fetch_all(urls: Vec<&str>) -> Vec<Result<String, FetchError>> {
let futures = urls.iter().map(|u| fetch(u));
join_all(futures).await
}
Timeout
use tokio::time::{timeout, Duration};
let result = timeout(Duration::from_secs(5), fetch_url(url)).await;
match result {
Ok(Ok(body)) => println!("{}", body),
Ok(Err(e)) => println!("fetch error: {}", e),
Err(_) => println!("timeout"),
}
Cancellation
Async tasks are cancelled by dropping the future:
let handle = tokio::spawn(async {
long_running_task().await
});
// ... later ...
handle.abort(); // cancels the task
The convention is that async functions check cancellation at .await points (the runtime drops the future at that point).
A note on rayon
For data-parallel computation, the third-party rayon crate admits parallel iterators:
use rayon::prelude::*;
let v: Vec<i32> = (1..=1_000_000).collect();
let sum: i64 = v.par_iter().map(|&x| x as i64).sum();
let processed: Vec<i32> = v.par_iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * x)
.collect();
The par_iter() produces a parallel iterator; the API mirrors Iterator. The conventional discipline is to use rayon for CPU-bound parallel work; tokio for I/O-bound async work.
A note on Send boundaries
Most types are Send automatically; the principal exceptions to know:
Rc<T>— notSend; useArc<T>for cross-thread sharing.RefCell<T>— notSync; useMutex<T>orRwLock<T>.Cell<T>— notSync; use atomics.
The compiler reports these as errors at the thread::spawn or tokio::spawn call:
use std::rc::Rc;
let r = Rc::new(42);
thread::spawn(move || { // ERROR: Rc<i32> is not Send
println!("{}", r);
});
The diagnostics admit substantial guidance to the conventional defences.
A note on async vs threads
The conventional choice:
| Workload | Choice |
|---|---|
| CPU-bound, parallel | Threads (or rayon) |
| I/O-bound, many concurrent connections | Async (tokio) |
| Mixed | Both — async for I/O, threads for CPU |
Async excels at high-concurrency I/O (thousands of connections); threads excel at CPU-bound parallelism. For most servers and clients, async is the conventional choice; for compute-heavy work, threads or rayon.
A note on the conventional discipline
The contemporary Rust concurrency advice:
- Use
Arc<Mutex<T>>for shared mutable state across threads. - Use
Arc<RwLock<T>>for read-heavy shared state. - Use channels (
mpsc) for producer-consumer patterns. - Use atomics for simple shared counters and flags.
- Use tokio for async I/O.
- Use rayon for data-parallel computation.
- Trust the type system —
SendandSynccatch data races at compile time. - Avoid sharing where possible — message passing via channels is conventionally clearer.
- Use
parking_lot::Mutex(third-party) for somewhat faster mutex performance.
The combination — OS threads with safe sharing primitives, async/await with tokio, channels for message passing, atomics for low-overhead state, rayon for parallel computation, and the type system’s data-race freedom — is the substance of Rust’s concurrency story. The mechanism admits substantial concurrent code with compile-time safety guarantees.