Concurrency Roadmap/Coordination Primitives

Latches & Barriers

A latch is a one-shot gate that opens forever once N events have happened; a barrier is a reusable rendezvous point where N parties repeatedly wait for each other — knowing which one you need, and being able to build either from a lock and a condition variable, is the core interview skill.

~3/5Theory: 25m
Language-specific mechanics: Concurrency Language Manual — Channels & Message-Passing Concurrency

Why a whole new set of primitives?

Topics 3 through 7 gave you the raw material: locks for mutual exclusion, condition variables for waiting on a predicate, atomics for lock-free updates. Latches and barriers are what you get when you package that raw material into a reusable shape for one specific, extremely common problem: a group of threads need to agree on a moment in time before any of them proceeds. You could build this yourself with a mutex and a condition variable every time — and in fact, understanding how to do exactly that is the interview-testable core of this subtopic — but a mature concurrency library gives you two named shapes for it, and knowing which one to reach for (and why the other one is wrong) is a very common senior-level question.

The latch: a one-shot gate

A latch (the canonical example is Java's CountDownLatch) is initialized with a count, N. Any thread can call the decrement operation; once the count reaches zero, every thread currently blocked on the wait operation is released, and every future call to wait returns immediately. This is the detail that trips people up: a latch cannot be reset or reused. Once it opens, it stays open forever. If you need the same coordination again, you construct a brand-new latch.

latch = CountDownLatch(N) // N worker threads, each running independently: do_work() latch.countDown() // one or more waiter threads: latch.await() // blocks until count reaches 0, then returns immediately forever after proceed()

The classic use case is startup sequencing: don't start accepting traffic until the database pool, the cache client, and the message bus have all finished initializing.

ready = CountDownLatch(3) spawn { initDb(); ready.countDown() } spawn { initCache(); ready.countDown() } spawn { initQueue(); ready.countDown() } ready.await() startAcceptingTraffic()

Notice the asymmetry: the three initialization threads call countDown(), but they never call await() — and the main thread calls await() but never countDown(). The threads doing the counting and the threads doing the waiting don't have to be the same set, or even the same size. That asymmetry is one of the two facts that distinguish a latch from a barrier.

The barrier: a reusable rendezvous point

A barrier (the canonical example is Java's CyclicBarrier) is initialized with a number of parties, N. Every one of those N parties calls the same wait operation. The barrier only releases anyone once all N have called it — and critically, it then resets itself automatically for the next round. This is why it's called "cyclic": the same barrier object can synchronize the same N threads at the end of round 1, then again at the end of round 2, and so on, indefinitely.

barrier = CyclicBarrier(N, onBarrierTripped) // action is optional // each of the N worker threads: for round in rounds: computeRound(round) barrier.await() // blocks until all N workers reach this line

The use case that matters most for interviews — because it's a direct preview of topic 11 (Parallel Algorithm Patterns) — is synchronizing N worker threads at the end of every iteration of a parallel computation: a parallel matrix multiply, an iterative simulation, a distributed gradient-descent step where every worker must finish its slice of the batch before anyone starts the next one. Many barrier implementations also accept an optional action (a Runnable in Java) that runs exactly once per round, executed by whichever thread happens to be the last to arrive, before anyone is released — the natural place to merge each worker's partial result into the shared state for the next round.

The other structural difference from a latch, beyond reusability: in a barrier, the waiters and the "counters" are the same set of threads. There's no separate coordinator thread standing outside the group — every party both contributes to tripping the barrier and is released by it. That symmetry is what "rendezvous" means here: everyone waits for everyone.

The one distinction interviewers actually want to hear

LatchBarrier
Reusable?No — one-shot, cannot resetYes — resets automatically each cycle
Who's counted vs. who waitsCan be different sets/sizesAlways the same N parties
What it models"Wait for N events""Wait for N threads to rendezvous"
Typical useStartup gating, fixed batch completionPhased/iterative parallel computation
Failure behaviorNo built-in propagation — a stuck task just hangs await() foreverAll-or-none: one broken/timed-out/interrupted party breaks the barrier for everyone waiting

If an interviewer asks "how would you make a CountDownLatch reusable," the correct answer isn't a trick — it's "you wouldn't; that's precisely what a CyclicBarrier (or the more general Phaser, for a variable party count) is for."

Building a barrier from scratch — the harder ask

A common follow-up: implement a barrier using only a mutex and a condition variable (topic 5's primitives). The standard trick is a generation counter, which exists to solve a subtle bug: without it, a thread that arrives late for round 2 could be mistaken for having satisfied round 1's wakeup, or a spurious wakeup could let a thread "leak" into the wrong round.

class Barrier: mutex, condition parties = N count = N generation = 0 def await(): with mutex: gen = generation # snapshot which round I belong to count -= 1 if count == 0: # last one in: run the barrier action, then start a new round runBarrierAction() count = parties generation += 1 condition.notifyAll() else: condition.wait_for(lambda: generation != gen)

Every waiter captures its own generation before waiting, and only wakes up once generation has actually advanced — so a notifyAll() from a later round can never be mistaken for the round a given thread is actually blocked on. This is exactly the "guarded wait with a predicate, not a bare wakeup" discipline from topic 5, applied to a group instead of a single flag.

Pitfalls and interview gotchas

  • Trying to "reset" a CountDownLatch. There is no such API, and that's intentional — reach for a barrier or Phaser instead.
  • Off-by-one on the party count. If a barrier is constructed for N parties but only N-1 threads ever call await() (one crashed, or a countDown()/await() call was accidentally skipped in an error path), every other thread blocks forever. Always release the latch/barrier side in a finally block.
  • Underestimating the barrier's failure blast radius. A CyclicBarrier's all-or-none breakage (one party timing out or getting interrupted poisons the round for every other waiter) is a feature for tightly-coupled phased algorithms, but a liability if your "parties" are actually independent, fault-tolerant tasks — that's a signal you want per-task fault isolation instead, which is exactly what futures/promises (next subtopic) give you.
  • Confusing "wait for completion" with "wait for rendezvous." If you catch yourself asking "does the waiting thread also need to call the same completion signal," you've drifted from latch territory into barrier territory, or vice versa.

Where this leads

Latches and barriers answer "when." They don't hand you anything back — await() returns void. The moment you need the result of the work you waited for, not just the fact that it finished, you need the next subtopic: futures and promises.

Reference implementations in:

Countdown Latch: Wait for N Workers to Finish Startup

The classic "don't accept traffic until DB, cache, and message bus are all ready" pattern — one-shot, and any thread may call the decrement side.

var ready sync.WaitGroup ready.Add(3) go func() { defer ready.Done(); initDb() }() go func() { defer ready.Done(); initCache() }() go func() { defer ready.Done(); initQueue() }() ready.Wait() // blocks until all three Done() calls fire startAcceptingTraffic()

sync.WaitGroup is Go's CountDownLatch: one-shot, any goroutine may Done(), and Wait() unblocks once the count hits zero. Always defer Done() so a panicked init can't hang Wait() forever. It is not reusable as a barrier — Add after Wait has returned is legal, but there is no reset, and a negative count panics.

Reusable Barrier: Synchronizing Parallel Rounds

N worker threads each finish a phase of a parallel computation, then must all arrive before any of them starts the next phase.

// No CyclicBarrier in the standard library. Recreate a WaitGroup // per round (legal once the previous Wait has returned), or close // a fresh channel as a one-shot barrier. const workers = 4 for round := 0; round < totalRounds; round++ { var wg sync.WaitGroup wg.Add(workers) for i := 0; i < workers; i++ { go func() { defer wg.Done() computeRound(round) }() } wg.Wait() mergePartialResults() // runs once everyone has arrived }

Don't look for a CyclicBarrier equivalent in stdlib — there isn't one. A new WaitGroup (or a new channel you close to release everyone) per round is the idiomatic substitute. A channel barrier (close(ch) wakes every receiver) is one-shot too; reuse means allocating a new channel, not resetting the old one.

Further Resources (Optional)