2024-09-21
HSPACE Rust Lectures #9 — Concurrency
The longest session by far, and the one where everything learned so far clicks into place. This is where you find out what "fearless concurrency" actually means.
The lecture splits concurrency into three approaches:
- Fork-join — chop the work up, hand pieces to threads, collect results
- Channels — threads pass values to each other, sharing nothing
- Shared mutable state — threads use the same data behind a lock
Fork-join parallelism
The simplest form, for work that splits into independent pieces.
use std::thread;
let handle = thread::spawn(move || {
// runs on another thread
process(chunk)
});
let result = handle.join().unwrap(); // wait for it and take the result
spawn requires a move closure for the reason the closures session gave: the
new thread may outlive the original stack frame, so it must own its captures.
The exact bound is F: FnOnce() -> T + Send + 'static.
Errors across threads
join() returns a Result, and a panicking thread produces Err.
match handle.join() {
Ok(value) => println!("result: {value:?}"),
Err(_) => println!("the thread panicked"),
}
One thread panicking doesn't take down the process. Only that thread unwinds
and exits, and the parent learns about it at join(). The payload comes back as
Box<dyn Any>, which is awkward to work with, so in practice sending errors over
a channel is nicer.
Sharing immutable data
Often every thread needs the same read-only data. That's Arc.
use std::sync::Arc;
let data = Arc::new(build_huge_table());
for _ in 0..8 {
let data = Arc::clone(&data); // bumps the reference count only
thread::spawn(move || {
lookup(&data, key);
});
}
No copying of the data — just an atomic increment, and release when the last
thread finishes. If you only read, you need no Mutex at all, thanks to the
rule that a shared reference is immutable.
Rayon
Managing threads by hand is tedious. Rayon parallelizes iterators.
use rayon::prelude::*;
let sum: i64 = numbers.par_iter().map(|&n| expensive(n)).sum();
Swapping iter() for par_iter() runs the work on a work-stealing thread pool.
The remarkable part is that the compiler guarantees this is safe — capture
something that isn't Send and it won't compile.
Channels
The second approach, in the spirit of Go's "don't communicate by sharing memory; share memory by communicating."
use std::sync::mpsc::channel;
let (sender, receiver) = channel();
thread::spawn(move || {
for line in read_lines() {
sender.send(line).unwrap();
}
// sender dropped here → the channel closes
});
for line in receiver { // iterate until the channel closes
process(line);
}
mpsc is multi-producer, single-consumer: Sender clones, Receiver doesn't.
Sending a value is a move. sender.send(value) transfers ownership, so you
can't touch the original afterwards and two threads never observe the same value.
What struck me is that channels are safe not because of some new mechanism but
because of the ownership rules that were already there.
Closing a channel
When every Sender is dropped the channel closes and the for loop ends
naturally — no sentinel "we're done" message needed. Conversely, accidentally
holding on to a Sender leaves the consumer waiting forever, which is a mistake
people make routinely.
Running a pipeline
The fingertips example (an inverted-index builder) is a good case study. Read
documents → build an index → write temp files → merge, wired together with
channels.
fn run_pipeline(documents: Vec<PathBuf>, output_dir: PathBuf) -> io::Result<()> {
// each stage runs on its own thread, connected by channels
let (texts, h1) = start_file_reader_thread(documents);
let (pints, h2) = start_file_indexing_thread(texts);
let (gallons, h3) = start_in_memory_merge_thread(pints);
let (files, h4) = start_index_writer_thread(gallons, &output_dir);
let result = merge_index_files(files, &output_dir);
// join every thread and check for errors
// ...
}
Each stage consumes the previous stage's output on its own thread, so with enough cores the stages overlap. I liked that channels also give you natural backpressure: with a synchronous channel, a slow consumer automatically stalls the producer.
The repository ships a single-threaded version (run_single_threaded) alongside
it, which makes the comparison easy — same logic, different wiring.
Channel performance
Channels aren't magic:
send/recvuse locks and condition variables, so they aren't free- sending tiny values very frequently is dominated by overhead; batching is usually better
- the default channel is unbounded, so a slow consumer grows memory without
limit.
sync_channel(n)caps the capacity
Send and Sync
This is the heart of Rust concurrency: two marker traits that define thread safety at the type level.
Send— values of this type can be moved to another thread safelySync— a&Tcan be observed by several threads at once safely
Precisely: T: Sync ⟺ &T: Send.
Nearly every type implements both automatically (they're auto traits). The exceptions are what matters:
| Type | Send | Sync | Why |
|---|---|---|---|
Rc<T> | ✗ | ✗ | non-atomic reference count |
RefCell<T> | ✓ | ✗ | non-atomic borrow flag |
Arc<T> | ✓ | ✓ | atomic counter (when T: Send + Sync) |
Mutex<T> | ✓ | ✓ | protected by the lock (when T: Send) |
| raw pointers | ✗ | ✗ | no idea what they point at |
Since thread::spawn requires F: Send and T: Send, a closure capturing an
Rc simply doesn't compile — the example from session one.
Here's the part I found impressive: the Rust compiler knows nothing about
concurrency. It doesn't understand deadlocks or races. It only checks
Send/Sync bounds and the ownership rules — and data races disappear anyway.
Concurrency safety comes out as a corollary of ordinary type checking.
Shared mutable state
The third approach: several threads reading and writing the same data.
Mutex
use std::sync::{Arc, Mutex};
let counter = Arc::new(Mutex::new(0));
for _ in 0..10 {
let counter = Arc::clone(&counter);
thread::spawn(move || {
let mut n = counter.lock().unwrap(); // acquire
*n += 1;
}); // end of scope → released
}
The decisive difference from a C mutex is that the mutex owns what it protects.
In C, the mutex and the data are separate variables, and touching the data
without the lock is invisible to the compiler. In Rust the data lives inside
the Mutex, and reaching it requires lock(), which hands back a MutexGuard
that releases the lock when dropped.
You can't forget to lock, and you can't forget to unlock.
mut and Mutex
There's a fun detail:
let counter = Mutex::new(0); // no mut!
let mut n = counter.lock().unwrap();
*n += 1; // yet the contents change
counter isn't mut and yet the value inside changes, because Mutex is also an
interior mutability type. RefCell provides that on one thread with a runtime
borrow check; Mutex provides it across threads with a lock.
Deadlock
Rust does not prevent deadlocks.
// thread A: lock(a) → lock(b)
// thread B: lock(b) → lock(a) ← deadlock
Locking the same mutex twice deadlocks too:
let guard1 = m.lock().unwrap();
let guard2 = m.lock().unwrap(); // waits on itself
The borrow checker guarantees memory safety, not freedom from deadlock. A deadlock isn't undefined behavior — it's a stopped program, which counts as safe. Classic discipline like establishing a lock ordering is still on you.
Poisoned mutexes
This is why lock() returns a Result. If a thread panics while holding the
lock, the mutex becomes poisoned, because the protected data may be half
updated.
Subsequent lock() calls return Err(PoisonError). That's what the ubiquitous
.unwrap() is doing: declaring "if another thread died mid-update, I'll die
too." Another place where the language takes data integrity seriously.
RwLock
When reads dominate, a mutex is overkill.
use std::sync::RwLock;
let config = RwLock::new(Config::default());
let c = config.read().unwrap(); // many readers at once
// ...
let mut c = config.write().unwrap(); // writers are exclusive
It's the borrow rule — many &T or one &mut T — transplanted into a runtime
lock. Seeing the same rule repeated at compile time (references), at runtime on
one thread (RefCell), and at runtime across threads (RwLock) is where the
consistency of the design really landed for me.
Condition variables
For "wait until a condition holds."
use std::sync::Condvar;
let (lock, cvar) = &*pair;
let mut started = lock.lock().unwrap();
while !*started {
started = cvar.wait(started).unwrap(); // release, wait, reacquire
}
Note that wait consumes the guard and returns a new one. The protocol — release
the lock while waiting, reacquire on wakeup — is encoded in the type
signature. And because of spurious wakeups you must loop with while, not
if.
Atomics
For a plain counter, even a mutex is too much.
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
COUNTER.fetch_add(1, Ordering::SeqCst);
No lock — just the CPU's atomic instructions. The Ordering parameter picks the
memory ordering, with SeqCst the strongest and slowest. Using Relaxed,
Acquire and Release correctly requires knowing the memory model, so the
lecture's advice was "if in doubt, SeqCst."
Global variables
static PACKETS_SERVED: AtomicUsize = AtomicUsize::new(0); // fine
// static mut COUNTER: usize = 0; // access requires unsafe
static mut permits data races and therefore requires unsafe. An atomic type or
a Mutex in a static is the safe alternative; when initialization needs
computation, OnceLock or LazyLock.
Assignment: the Mandelbrot set
Parallelize the same program four ways. The comparison is the whole point.
1. Single-threaded — the baseline.
2. Multi-threaded — split the image into horizontal bands, one per thread.
let bands = pixels.chunks_mut(bounds.0).enumerate().collect::<Vec<_>>();
let mut handles = Vec::new();
for (i, band) in bands.into_iter() {
let mut band = band.to_owned();
let handle = thread::spawn(move || {
render(&mut band, band_bounds, band_upper_left, band_lower_right);
band
});
handles.push(handle);
}
for (i, handle) in handles.into_iter().enumerate() {
let band = handle.join().expect("Thread panicked");
pixels[start..end].copy_from_slice(&band);
}
The key is that chunks_mut splits the vector into non-overlapping mutable
slices. Because they don't overlap, handing each to a different thread is safe —
and the compiler knows it.
3. Rayon — the same thing in one line.
use rayon::prelude::*;
let bands: Vec<(usize, &mut [u8])> = pixels.chunks_mut(bounds.0).enumerate().collect();
bands.into_par_iter().for_each(|(i, band)| {
render(band, band_bounds, band_upper_left, band_lower_right);
});
into_iter() becomes into_par_iter() and that's it — no thread count, no
join. It's also faster than version 2, which had to copy each band with
to_owned() and copy it back.
4. Lock-free — don't pre-assign bands; let threads grab the next one via an atomic counter.
pub struct AtomicChunksMut<'a, T> {
slice: ...,
step: usize,
next: AtomicUsize, // index of the next band
}
let bands = AtomicChunksMut::new(&mut pixels, rows_per_band * bounds.0);
crossbeam::scope(|scope| {
for _ in 0..threads {
scope.spawn(|_| {
for (i, band) in &bands { // atomically take the next band
render(band, band_bounds, band_upper_left, band_lower_right);
}
});
}
});
Mandelbrot bands have wildly different costs: pixels inside the set run to the iteration limit, pixels outside bail out immediately. Distribute bands evenly up front and some threads idle while others grind. Letting threads pull work dynamically through an atomic counter balances the load by itself.
crossbeam::scope matters too. Standard thread::spawn requires 'static and
therefore can't borrow locals, but scoped threads are guaranteed to be joined
before the scope ends, so borrowing local data is allowed — which is why no
to_owned() copy is needed here.
Running all four shows the performance differences directly, and being able to explain those differences from the code (how work is distributed, whether data is copied) was the real payoff of this assignment.
Takeaways
- three approaches: fork-join, channels, shared mutable state
Send/Syncdefine thread safety as types; the existing ownership rules do the rest- sending over a channel is a move, so no sharing happens in the first place
Mutex<T>owns its data, so you can't forget the lock — but deadlock is still possible- the borrow rule reappears at runtime as
RwLock - for uneven workloads, dynamic distribution beats static partitioning
Next: macros — extending the syntax itself with macro_rules!.