Fearless concurrency
Threads, channels, Send and Sync, and the rules that keep concurrent Rust honest
"Fearless concurrency" is the marketing phrase. The reality is more modest. Rust's type system catches most data races at compile time. It does not catch deadlocks, logic bugs, or starvation. What you get is a strong baseline: if it compiles, no two threads are mutating the same memory through & references.
Spawning threads
use std::thread;
let handle = thread::spawn(|| {
println!("hello from a thread");
});
handle.join().unwrap();thread::spawn takes a closure and runs it on a new OS thread. The returned JoinHandle<T> lets you wait for the thread and recover its return value.
The closure must be 'static and Send. That is why you usually see move:
let data = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("{:?}", data);
});
handle.join().unwrap();Without move, the closure would try to borrow data from the calling stack. Since the thread might outlive the borrow, the compiler refuses. move transfers ownership instead.
Sharing state: Arc<Mutex<T>>
The previous chapter introduced Arc and Mutex. Together they are the canonical "shared mutable state" primitive:
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0u64));
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut g = c.lock().unwrap();
*g += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("final: {}", counter.lock().unwrap());Same caveat as before: when an atomic or a channel will do, use that. Arc<Mutex<T>> is the heavy hammer, not the default.
Send and Sync
Two marker traits encode "thread safety" in the type system:
| Trait | Meaning |
|---|---|
Send | Safe to transfer ownership to another thread |
Sync | Safe to share &T across threads |
thread::spawn requires its closure to be Send. Capturing an Rc<T> makes the closure not-Send, which is why you see Arc everywhere in multi-threaded code.
You almost never implement these traits yourself. They are auto-derived by the compiler when all fields satisfy them. The unsafe escape hatch exists for raw FFI, but for application code you should never touch it.
Send violations look like:
error[E0277]: `Rc<i32>` cannot be sent between threads safelyThe fix is almost always swapping the type (Rc to Arc, RefCell to Mutex), not adding unsafe impl Send.
Channels: ownership transfer between threads
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel::<String>();
thread::spawn(move || {
tx.send(String::from("hello")).unwrap();
});
let msg = rx.recv().unwrap();
println!("{}", msg);mpsc stands for "multi-producer, single-consumer." tx can be cloned; rx cannot. Senders moved into threads, receiver stays at the orchestration point.
The mental model: a channel is an ownership-transfer queue. When you send(value), the value moves into the channel; when you recv(), it moves out. No shared mutable state needed.
For most "worker pool" or "producer/consumer" shapes, channels are the right primitive. They are cheaper to reason about than shared state.
A brief detour: async/await
Threads model concurrency at the OS level. Async/await models concurrency at the task level: thousands of cheap tasks multiplexed onto a small thread pool. The runtime (Tokio, in practice) handles the scheduling.
#[tokio::main]
async fn main() {
let h = tokio::spawn(async {
do_work().await
});
h.await.unwrap();
}When to pick which:
| Use threads when | Use async when |
|---|---|
| CPU-bound work | I/O-bound work |
| Small number of long-running workers | Thousands of concurrent connections |
| Code is mostly synchronous | Code already uses async libraries |
For the deep dive, see Concepts: Async. The chapter you are reading is about std::thread.
Data parallelism with rayon
For "do the same thing to every element of a collection, in parallel," reach for rayon:
use rayon::prelude::*;
let nums: Vec<i32> = (1..=1_000_000).collect();
let sum: i32 = nums.par_iter().sum();.par_iter() swaps the sequential iterator for a parallel one. Rayon manages a thread pool, splits the work, and joins the results. For embarrassingly parallel work over Vec, slice, HashMap, this is two lines and you get all the cores.
The classic async footgun: blocking lock across await
This bug shows up constantly in agent-written async code:
use std::sync::Mutex;
use std::sync::Arc;
async fn handle(state: Arc<Mutex<State>>) {
let mut g = state.lock().unwrap();
g.update();
fetch_external().await; // lock held across await
g.commit();
}use tokio::sync::Mutex;
use std::sync::Arc;
async fn handle(state: Arc<Mutex<State>>) {
{
let mut g = state.lock().await;
g.update();
}
fetch_external().await;
{
let mut g = state.lock().await;
g.commit();
}
}Two issues with the bad version. First, std::sync::Mutex blocks the OS thread; held across .await, it can deadlock the entire runtime if the executor schedules another task that needs the same lock. Second, even with tokio::sync::Mutex, holding the guard across an external call is bad practice: it serializes all callers on a slow operation.
The rules:
std::sync::Mutexis fine in async code as long as you do not hold the guard across.await. Drop it in a scope.tokio::sync::Mutexis for when you genuinely need to hold a lock across.await. Slower thanstd::sync::Mutex. Use only when needed.- Whenever possible, take the lock, copy what you need, drop the lock, then do async work.