Skip to content

← Fearless Concurrency: Threads, Channels, Shared State step 21 of 24

Medium End-to-End

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…