Concurrency in Practice
Rust's ownership rules don't stop at thread boundaries, they extend to them. The same borrow checker that prevents a use-after-free in single-threaded code also prevents data races across threads, enforced through two marker traits: Send and Sync. This is what Rust calls "fearless concurrency": if it compiles, it doesn't have a data race.
Smart Pointers Demystified already covered Arc<Mutex<T>> for shared mutable state. This tutorial picks up from there: spawning and joining threads correctly, what Send/Sync actually mean, passing ownership through channels instead of sharing it, and the deadlocks and pitfalls that show up once you have more than one thread.
Spawning Threads
thread::spawn takes a closure and runs it on a new OS thread. The closure must be move, it cannot borrow from the calling scope, because the new thread might outlive the function that spawned it.
use std::thread;
let name = String::from("worker-1");
let handle = thread::spawn(move || {
println!("hello from {name}");
});
handle.join().unwrap();
spawn returns a JoinHandle<T>, where T is the closure's return value. Calling .join() blocks the current thread until the spawned one finishes, and gives you back that value (or the panic payload, if it panicked).
let handle = thread::spawn(|| {
(1..=100).sum::<u32>()
});
let total = handle.join().unwrap();
println!("sum: {total}");
A panic in a spawned thread does not crash the program. It poisons that thread's result. .join() returns Err with the panic payload instead of unwinding into the caller. If you drop a JoinHandle without joining it, the thread keeps running detached. This is rarely what you want, the program can exit while it's still mid-write.
Send and Sync: What the Compiler Is Actually Checking
These two traits are the entire enforcement mechanism behind thread safety in Rust. Both are auto-implemented for almost every type, you don't write impl Send for MyStruct {} by hand, the compiler derives it from the fields.
Send: a type isSendif it's safe to move to another thread. Almost everything is.Rc<T>is the notable exception, its reference count isn't atomic, so two threads incrementing it concurrently is a data race.Sync: a type isSyncif&Tis safe to share between threads. Equivalently,TisSyncif&TisSend.Cell<T>andRefCell<T>are notSync, their interior mutability has no synchronization.
use std::rc::Rc;
use std::thread;
let shared = Rc::new(5);
thread::spawn(move || {
println!("{shared}");
});
// ERROR: `Rc<i32>` cannot be sent between threads safely
Swapping Rc for Arc fixes it, Arc's counter is atomic, so it implements both Send and Sync. This is the whole reason Arc exists as a separate type from Rc rather than Rc just always using atomics: single-threaded code pays no synchronization cost.
You almost never write Send/Sync bounds yourself. You'll meet them as compiler errors when a type you're sending across a thread::spawn boundary doesn't qualify, the fix is usually to swap Rc for Arc, or RefCell for Mutex/RwLock.
Channels: Moving Data Instead of Sharing It
Arc<Mutex<T>> shares one value between threads. Channels take the opposite approach: each message is owned by whichever thread currently holds it. There's nothing to lock because there's nothing shared.
use std::sync::mpsc;
use std::thread;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
for i in 0..5 {
tx.send(i).unwrap();
}
// tx is dropped here, closing the channel
});
for received in rx {
println!("got: {received}");
}
mpsc stands for multi-producer, single-consumer. Clone the sender to get multiple producers feeding one receiver:
let (tx, rx) = mpsc::channel();
for id in 0..3 {
let tx = tx.clone();
thread::spawn(move || {
tx.send(format!("worker {id} done")).unwrap();
});
}
drop(tx); // drop the original so the channel closes once all clones are dropped
for msg in rx {
println!("{msg}");
}
The for received in rx loop ends automatically once every Sender (the original and all clones) has been dropped. If you forget to drop or let one go out of scope, the loop blocks forever waiting for a message that will never come.
Reach for a channel before a
Mutexwhen threads produce discrete units of work rather than mutating one shared structure. It sidesteps lock contention entirely and tends to map more directly onto the problem: "worker threads send results back" is a channel, "five threads update one counter" is aMutex.
Scoped Threads: Borrowing Without Arc
thread::spawn requires 'static data because the spawned thread could outlive the current function. thread::scope flips that: it guarantees every spawned thread finishes before the scope ends, so closures can borrow local data without cloning into an Arc.
use std::thread;
let data = vec![1, 2, 3, 4, 5];
thread::scope(|s| {
s.spawn(|| {
println!("first half: {:?}", &data[..2]);
});
s.spawn(|| {
println!("second half: {:?}", &data[2..]);
});
}); // both threads are joined here, automatically
println!("still own it: {:?}", data);
This is the right default when you're fanning work out and waiting for it to finish within the same function, no Arc::clone calls, no move forcing you to give up ownership, and the borrow checker still verifies nothing outlives data.
Deadlocks and Lock Poisoning
Two pitfalls show up repeatedly once Mutex is in play:
Deadlock from re-locking. Calling .lock() on a Mutex you already hold the guard for blocks forever, the same thread is waiting on itself.
let m = Mutex::new(5);
let guard = m.lock().unwrap();
let guard2 = m.lock().unwrap(); // deadlock: this thread already holds the lock
This usually happens indirectly: a function takes &Mutex<T> and locks it, and gets called from somewhere that's already holding the guard. Keep lock scopes small and explicit so it's obvious when a guard is still alive.
Lock poisoning. If a thread panics while holding a MutexGuard, the Mutex is marked poisoned. Every future .lock() returns Err instead of silently handing out a guard, on the assumption that data midway through a mutation when the panic hit might be inconsistent.
let m = Arc::new(Mutex::new(0));
let m2 = Arc::clone(&m);
let _ = thread::spawn(move || {
let _guard = m2.lock().unwrap();
panic!("oops");
}).join();
match m.lock() {
Ok(_) => println!("fine"),
Err(poisoned) => {
// recover the data anyway, if you trust it's still valid
let guard = poisoned.into_inner();
println!("recovered: {guard}");
}
}
Most code just .unwrap()s the lock result and lets the panic propagate, that's the right call when a poisoned mutex means your invariants are already broken. Use .into_inner() to recover only when you've reasoned about why the data is still trustworthy.
RwLock: Many Readers, One Writer
Mutex gives exclusive access regardless of whether you're reading or writing. RwLock<T> distinguishes the two: any number of readers can hold the lock simultaneously, but a writer needs exclusive access.
use std::sync::{Arc, RwLock};
use std::thread;
let config = Arc::new(RwLock::new(AppConfig::default()));
// many threads can read concurrently
let reader = Arc::clone(&config);
thread::spawn(move || {
let cfg = reader.read().unwrap();
println!("port: {}", cfg.port);
});
// a writer waits for all readers to finish, then gets exclusive access
let writer = Arc::clone(&config);
thread::spawn(move || {
let mut cfg = writer.write().unwrap();
cfg.port = 9090;
});
Reach for RwLock when reads vastly outnumber writes, like configuration that's loaded once and read constantly. If writes are frequent, the bookkeeping RwLock does to track readers usually isn't worth it over a plain Mutex.
Key Takeaways
thread::spawnclosures must bemoveand'static. Usethread::scopeinstead when you just need to borrow local data and wait for threads to finish.Sendmeans safe to move to another thread;Syncmeans safe to share by reference. Both are auto-derived, you'll meet them as compiler errors, not traits you implement.RcandRefCellaren't thread-safe; swap them forArcandMutex/RwLockwhen data crosses a thread boundary.- Prefer channels over a shared
Mutexwhen threads produce discrete results rather than mutating one structure together, ownership moves through the channel instead of being contended for. - A panicked thread poisons its
Mutex..unwrap()on the lock result is usually correct; recovering with.into_inner()is the exception, not the default. RwLockis worth it for read-heavy data; for everything else,Mutexis simpler and just as fast.