Part 4 · Concurrency & async
Threads, channels, and shared state
What the concurrency primitives actually look like, how they compare to goroutines and `synchronized`, and why scoped threads changed the ergonomics.
What this lesson is trying to break (3)
- A thread closure must always take ownership of everything.
- Channels are the idiomatic answer to everything, as in Go.
- `Arc<Mutex<T>>` is a code smell.
The primitives, quickly, with attention to the parts that differ from what you already know.
Spawning
use std::thread;
let handle = thread::spawn(move || {
expensive_work();
42
});
let result = handle.join().unwrap(); // blocks; Err if the thread panicked
Three things to notice.
move is nearly always required. spawn demands F: Send + 'static, and
'static means the closure cannot borrow from the calling frame
(2.4) — because the thread may
outlive it.
join returns a Result. Err means the thread panicked. This is one of
the few places you can observe a panic without the process ending.
Threads are OS threads. Not green threads, not goroutines. Roughly 8 MB of virtual stack each on Linux by default, and a real cost to create. If you want tens of thousands of concurrent tasks, that is async (4.3), not threads.
Scoped threads
The 'static requirement used to force a lot of ceremony. Since Rust 1.63:
let data = vec![1, 2, 3];
thread::scope(|s| {
s.spawn(|| println!("{:?}", &data)); // borrows! no Arc, no clone
s.spawn(|| println!("{}", data.len()));
});
// all scoped threads are guaranteed joined here
scope guarantees every thread it spawned has finished before it returns. That
makes borrowing sound, so the 'static bound is not needed.
This is a genuine ergonomic improvement and reasonably recent, so it is worth
knowing about: if you read older Rust and see Arc::new purely to hand data to
short-lived worker threads, that code predates scoped threads or has not been
updated.
Channels
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
for i in 0..3 {
let tx = tx.clone(); // multi-producer
thread::spawn(move || tx.send(i * 10).unwrap());
}
drop(tx); // drop the original, or rx never ends
for received in rx { // iterates until all senders are gone
println!("{received}");
}
Two things bite people here.
mpsc is multi-producer, single-consumer. Clone tx for more senders;
there is only ever one rx.
And that drop(tx) matters. The receiver’s iterator ends when all senders
have been dropped. Keep the original tx alive in scope and the loop hangs
forever, which is a very common first bug.
Varies: the language’s default answer for sharing between concurrent tasks
| Go | Java | Rust | |
|---|---|---|---|
| Unit of concurrency | Goroutine (green, ~2 KB) | Thread, or virtual thread | OS thread, or async task |
| Cultural default | “Share memory by communicating” — channels | synchronized, concurrent collections | Whichever fits |
| Shared mutable state | Allowed, unchecked; -race at test time | synchronized, by convention | Arc<Mutex<T>>, checked at compile time |
| Channels are | A language feature | A library class | A library type (std, crossbeam, tokio) |
| Forgetting to synchronise | A race, found at run time if lucky | A race, found in production | Does not compile |
Rust has no cultural preference for channels the way Go does. Shared state with a mutex is entirely idiomatic here, because the compiler makes it safe.
That last point is worth internalising if you are coming from Go, where reaching
for a mutex can feel like admitting defeat. In Rust, Arc<Mutex<T>> is not a
code smell. It is the standard tool, used throughout the ecosystem.
The questions worth asking in review are not “why a mutex?” but:
- How long is the lock held? (See 1.4 — the scope is the critical section.)
- Does the critical section do I/O, or call anything that might block?
- Would an atomic express this better? A counter does not need a mutex.
- Is there a lock-ordering rule, and is it written down?
Shared state
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut n = counter.lock().unwrap();
*n += 1;
})); // guard dropped here → unlocked
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // always 10
Read the two layers separately, because they do different jobs:
Arc— shared ownership. Several threads each hold a handle; the value is freed when the last one goes.Mutex— shared mutability. Serialises access so only one thread has&mutat a time.
Neither substitutes for the other, which is why the combination is so common. And
note .lock().unwrap(): the Result is for lock poisoning — if a thread
panics while holding the lock, later lockers get an Err, because the data may
be in a broken half-updated state. Most code unwraps; being aware of what it
means is enough.
fn transfer(from: &Mutex<u64>, to: &Mutex<u64>, amount: u64) {
let mut a = from.lock().unwrap();
let mut b = to.lock().unwrap();
*a -= amount;
*b += amount;
}Yes — classic lock-order inversion.
Thread 1 calls transfer(&x, &y, 10); thread 2 calls transfer(&y, &x, 5).
Thread 1 holds x and waits for y. Thread 2 holds y and waits for x.
Neither ever proceeds.
This compiles cleanly. No borrow error, no Send error, nothing.
It is the concrete demonstration of 4.1’s honest caveat: Rust prevents data races, not deadlocks. The type system has no concept of lock ordering.
The standard fix is a total order on locks — for example, always lock the one with the lower memory address first:
let (first, second) = if std::ptr::from_ref(from) < std::ptr::from_ref(to) {
(from, to)
} else {
(to, from)
};
let _a = first.lock().unwrap();
let _b = second.lock().unwrap();Being able to produce this example on demand is useful. It is the cleanest counter to someone who thinks Rust solved concurrency.
Rayon
Worth knowing about, because it is the strongest practical demonstration of what Part 4 buys:
use rayon::prelude::*;
let total: u64 = items.par_iter() // was .iter()
.map(|x| expensive(x))
.sum();
Change one method name and the loop runs on a work-stealing thread pool. And
because par_iter requires Send + Sync on everything involved, you cannot
parallelise a loop that would have raced. If the closure captured a RefCell,
it would not compile.
That is the Part 4 argument in one line: safety that comes from the ordinary type system is safety you can apply retroactively, cheaply, to code you did not write carefully in the first place.
You are reviewing a service. A handler does this:
let mut cache = state.cache.lock().unwrap();
let value = fetch_from_database(&key).await;
cache.insert(key, value);Name the problem. Then name the second problem, which is about which Mutex
this is.
What you should now be able to say
spawnneedsSend + 'static;thread::scopelifts the'staticby guaranteeing joins.stdchannels are mpsc; the receiver ends only when all senders drop.- Rust threads are OS threads — for many thousands of tasks, use async.
Arc<Mutex<T>>is idiomatic, not a smell.Arcis ownership,Mutexis mutability.lock()returns aResultbecause of poisoning.- Deadlocks still compile. Lock ordering is yours.
rayonshows the payoff: parallelism you can add safely after the fact.
Next: 4.3 — Futures are inert. If you write JavaScript, this is the biggest trap in the language.