We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Fearless Concurrency: Threads, Channels, Shared State step 21 of 24
Barrier: keeping workers in lock step
Run Conway’s Game of Life for steps generations, with workers threads each
owning a horizontal band of rows.
pub fn life_generations(grid: Vec<Vec<u8>>, steps: usize, workers: usize) -> Vec<Vec<u8>>
Cells are 0 or 1. The grid is bounded — cells off the edge count as
dead, no wrapping. Standard rules: a live cell with 2 or 3 live neighbours
survives, a dead cell with exactly 3 is born, everything else is dead next
generation.
steps == 0 or an empty grid returns the grid unchanged. Treat workers == 0
as 1. The tests run workers = 1, 3, 4 on the same glider and require
identical output.
Synchronisation is not only mutual exclusion
Everything in this track so far has been about exclusion: one thread at a
time touching a thing. Life needs something different. Every worker must
finish generation g before any worker starts generation g + 1 — not
because they would corrupt each other’s memory, but because a worker one
generation ahead would read its neighbour’s cells at the wrong age and compute
a physically impossible board.
That is a rendezvous for all N, and std::sync::Barrier is the primitive:
let barrier = Barrier::new(n);
// in each of n threads:
barrier.wait(); // blocks until all n have arrived, then all resume
It resets automatically, so the same barrier serves every generation.
Exactly one of the wait() calls per round returns a BarrierWaitResult with
is_leader() == true — useful when one thread must do a single piece of
per-round bookkeeping while the others idle.
The trap, and it is the hidden test
Barrier::new(n) releases only when exactly n threads have called
wait(). Fewer than n and everyone waits forever.
So the number you pass is not a configuration knob, it is a count of the
threads that will actually arrive. In this problem workers may exceed the
number of rows, and a band with no rows in it is a thread with nothing to do —
so you skip it. Skip a thread and size the barrier from workers anyway, and
every remaining thread hangs at the first wait().
The starter does exactly that. It works for every case where
workers <= rows, and hangs on the one where it does not. Size the barrier
from the number you spawn, and derive both from the same expression.
Two buffers, and how to avoid a swap
Single-buffered Life with a barrier is subtly wrong: a worker updating row r
in place destroys the old value its neighbour still needs. You need a front
buffer to read and a back buffer to write.
The classic structure is: compute into back, barrier, leader swaps the
buffers, barrier again — two barriers per generation, because the swap must
happen after everyone has finished writing and before anyone starts reading.
There is a neater trick that needs neither the leader nor the second barrier: let the generation number decide. Keep both buffers side by side and read the parity of the loop counter:
let (front, back) = if generation % 2 == 0 { (&bufs[0], &bufs[1]) }
else { (&bufs[1], &bufs[0]) };
Every worker is on the same generation — the barrier guarantees it — so every
worker computes the same answer with no shared state and nothing to swap. One
barrier per generation is then enough: after it, all writes to back are done
and all reads of front are finished. The final board is in bufs[steps % 2].
This is worth noticing as a general move: replacing shared mutable state with a value every participant can derive independently. It is usually simpler and always cheaper than coordinating.
Why the cells are atomics
Workers write to disjoint bands, so chunks_mut would work — but the threads
here are long-lived, spanning many generations and swapping roles between
buffers, and re-deriving &mut slices every generation would mean joining
every generation, which is what the barrier was supposed to replace.
Vec<AtomicU8> sidesteps it: AtomicU8 is Sync, so &Vec<AtomicU8> can be
shared by every thread for the whole run, and each cell can be written without
any &mut at all. Ordering::Relaxed is enough for the loads and stores,
because the barrier provides the happens-before edge between generations —
it is built on a mutex and a condvar, both of which synchronise. Getting
ordering for free from a synchronisation primitive you already needed is the
common case, not the exception; Track 15 has the details.
One small obstacle you will meet: vec![AtomicU8::new(0); n] does not
compile, because AtomicU8 is not Clone. Use
(0..n).map(|_| AtomicU8::new(0)).collect().
One more honest note
If a participant panics, it never arrives, and every other thread waits at
the barrier forever. std::sync::Barrier has no poisoning story at all —
unlike Mutex, there is no mechanism to notice and no Err to return. A
panicking worker turns into a hang.
Loading visualization…
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.