Coordination & Limits

Latches & Countdowns

One-shot and asymmetric: waiters wait, workers count down, and when the count reaches zero the gate opens permanently. 3 → 2 → 1 → 0 and it never resets — which is both the whole appeal and the thing people get wrong when they reach for it expecting a barrier.

▶ Run the lab

The question this answers

The question

How does a coordinator wait for N pieces of work to finish, without knowing which one finishes last?

The work

A service that must not accept traffic until three independent warm-ups complete: a connection pool fills, a 200 MB lookup table loads, and a feature-flag snapshot is fetched.

What is shared

A single counter, starting at 3, decremented once per completed warm-up, plus the readiness flag the health check reads. The warm-ups share nothing else — they touch different subsystems entirely.

The invariant — what must stay true under every interleaving

The readiness flag is true only after all three warm-ups have completed, and the counter never goes below zero or is decremented twice by the same worker.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

3 → 2 → 1 → 0, and the decrement that has to be indivisible

A latch is a counter and a gate. Waiters call wait() and block while the count is above zero; workers call countDown() when they finish; the transition to zero releases every waiter at once and the gate stays open forever. Later waiters do not wait at all — which is exactly what you want for a readiness flag, and exactly wrong if you expected the next round to re-arm it.

The decrement is the part that must be indivisible. count -= 1 on two OS threads is a read, a subtract and a write, and the interleaving that loses one of them is the canonical lost update: two workers finish, the counter goes from 3 to 2 instead of to 1, and the waiter never wakes. On real threads this needs an atomic decrement or a mutex; on one event loop it is already atomic provided no await separates the read from the write (Event Loops as a Concurrency Model).

The second requirement is idempotence per worker. A worker that counts down twice — a retry path, an error handler that also signals, a finally plus an explicit call — opens the gate early, and the service starts accepting traffic against a half-loaded lookup table. That failure looks like data corruption, not like a concurrency bug, which is why it survives so long.

Two warm-ups finishing simultaneously on two threads, with a non-atomic decrement.ILLUSTRATIVE
Invariant · count equals 3 minus the number of completed warm-ups; readiness is announced only at zero
#Warm-up 1 (pool)Warm-up 2 (lookup table)Warm-up 3 (flags)Coordinator (waiting)State
1··finishes; atomic-free decrement: reads 3, writes 2·count=2 done=1 ready=false
2finishes; reads count → 2···count=2 done=1 ready=false
3·finishes; reads count → 2··count=2 done=1 ready=false
4computes 2 − 1 = 1; writes count = 1···count=1 done=2 ready=false
5·computes 2 − 1 = 1; writes count = 1··count=1 done=3 ready=false
✕ Three warm-ups have completed and the counter says 1. The coordinator will never be released; the service never becomes ready and the deploy hangs at the readiness gate with every subsystem healthy.
6···still waiting; the deploy times out after 10 minutescount=1 done=3 ready=false
A lost decrement is a hang, not a wrong answer — which makes it easier to notice and harder to diagnose, because every component reports itself healthy. Atomics.sub, std::atomic::fetch_sub, or a mutex around the decrement fixes it. Guard against the mirror-image bug too: a worker that counts down twice releases the gate early, and that one corrupts data instead of hanging.

Latch or barrier: they are not interchangeable

These get confused constantly, and the confusion is expensive in one direction specifically: using a latch where the algorithm needs a reusable barrier means phase 2 has no synchronisation at all, because the latch is already open and every subsequent wait() returns immediately.

The distinguishing questions are: does everyone wait for everyone (barrier) or do some wait while others signal (latch)? And does it need to work again next round (barrier) or exactly once (latch)? Get those two right and the choice makes itself.

PropertyLatchBarrierJoin / Promise.allSemaphore
ReusableNo — one-shot, permanent once openYes — resets for the next phaseNo — one collection, one waitYes — permits are returned
SymmetryAsymmetric: waiters and counters are different rolesSymmetric: every participant both waits and signalsAsymmetric: parent waits, children finishAsymmetric: acquirers and releasers
Who may waitAnyone, including parties that never count downOnly the N participantsThe parent that holds the handlesAnyone needing a permit
Count directionDown to zero, then open foreverUp to N, then reset to zeroImplicit in the number of handlesUp and down, no terminal state
Late arrivalReturns immediately — gate is openJoins the next phaseHandle is already settled; resolves immediatelyBlocks if no permits are free
Typical useReadiness gates, start signals, "wait for shutdown"Phased simulation, bulk-synchronous roundsFan-out then fan-in of independent tasksLimiting concurrent access to a resource
Characteristic failureLost or double decrement — hang, or open too earlyMissing generation counter; a party that never arrivesFirst rejection abandons the others (Promise.all & gather)Permit leaked on an error path
Latch versus barrier versus join versus semaphore — four things that all involve counting.

The same countdown in four languages

The mechanisms genuinely differ. C++20 gives you a real std::latch whose count_down is atomic and whose wait blocks a thread. Java has had CountDownLatch for two decades and it is the reference model. JavaScript has no latch at all — you build one from a promise and a counter, which is fine because the decrement is already atomic on one loop. Python's asyncio has no latch either, but asyncio.Event plus a counter, or simply gather, covers the ground.

The important porting note is in the JS and Python entries: because there is no library primitive, the *idempotence* of the decrement is entirely on you. std::latch will abort in a debug build if you count down past zero; a hand-rolled counter will cheerfully go negative and open the gate early.

Wait until three independent warm-ups have finished. — A coordinator blocks until N workers have each signalled completion, once
C++LANGUAGE-SPECIFIC
1#include <latch>
2#include <thread>
3
4std::latch warmups{3}; // one-shot, cannot be reset
5
6auto worker = [&](auto fn) {
7 fn();
8 warmups.count_down(); // atomic; UB if it goes past zero
9};
10
11std::jthread t1(worker, fill_pool);
12std::jthread t2(worker, load_table);
13std::jthread t3(worker, fetch_flags);
14
15warmups.wait(); // blocks THIS thread until zero
16start_accepting_traffic();
17// Later waiters return immediately: the gate is open permanently.

A real primitive: count_down is an atomic decrement plus a notify, and wait blocks the calling OS thread. Counting down more times than the initial count is undefined behaviour, so double-signal is a bug the language will not catch in release builds. arrive_and_wait combines both roles for a participant that also waits.

JavaScriptNODE.JS
1// No latch primitive. Build one — the decrement is atomic on this loop.
2function createLatch(n) {
3 let remaining = n
4 const signalled = new Set() // idempotence is YOUR job
5 let open
6 const gate = new Promise((resolve) => { open = resolve })
7 return {
8 countDown(id) {
9 if (signalled.has(id)) return // a retry path must not open the gate early
10 signalled.add(id)
11 remaining -= 1 // no await between read and write: atomic here
12 if (remaining === 0) open()
13 },
14 wait: () => gate, // late waiters get the settled promise instantly
15 }
16}
17
18const latch = createLatch(3)
19fillPool().then(() => latch.countDown('pool'))
20loadTable().then(() => latch.countDown('table'))
21fetchFlags().then(() => latch.countDown('flags'))
22await latch.wait()

The promise IS the gate: it settles once and every later waiter gets the settled value immediately, which is exactly latch semantics for free. What the language does not give you is protection against a double count-down, so key the signal by worker id.

TypeScript
1// The honest version: if the workers can fail, a latch hides it.
2// A latch only counts DOWN — it has no channel for "worker 2 threw".
3type WarmUp = { name: string; run: () => Promise<void> }
4
5async function warmUp(tasks: WarmUp[], timeoutMs: number): Promise<void> {
6 const results = await Promise.race([
7 Promise.allSettled(tasks.map((t) => t.run())),
8 new Promise<never>((_, rej) =>
9 setTimeout(() => rej(new Error('warm-up timed out')), timeoutMs)),
10 ])
11 const failed = results
12 .map((r, i) => ({ r, name: tasks[i].name }))
13 .filter((x) => x.r.status === 'rejected')
14 if (failed.length) {
15 throw new AggregateError(failed.map((f) => (f.r as PromiseRejectedResult).reason),
16 'warm-up failed: ' + failed.map((f) => f.name).join(', '))
17 }
18}

Reaching for a latch when the workers can fail is usually the wrong call: the counter has no way to express failure, so a crashed worker becomes a hang. allSettled plus a timeout gives you the same rendezvous AND names which subsystem failed.

PythonCPYTHON
1import asyncio
2
3# asyncio has no latch. Event + counter is the direct translation.
4class Latch:
5 def __init__(self, n: int) -> None:
6 self._remaining = n
7 self._event = asyncio.Event()
8 self._signalled: set[str] = set()
9
10 def count_down(self, key: str) -> None:
11 if key in self._signalled: # idempotent per worker
12 return
13 self._signalled.add(key)
14 self._remaining -= 1 # atomic on the loop: no await inside
15 if self._remaining == 0:
16 self._event.set() # stays set forever — latch semantics
17
18 async def wait(self) -> None:
19 await self._event.wait()
20
21# Usually you do not need it: gather IS the rendezvous, and it propagates errors.
22await asyncio.gather(fill_pool(), load_table(), fetch_flags())

asyncio.Event is one-shot in exactly the way a latch is: once set it stays set and later waiters return immediately. But for "wait for these N coroutines", gather is simpler and, unlike a latch, surfaces a failure instead of turning it into a hang.

What actually differs
  • Only C++ and Java ship a real latch. JS and Python build one from a promise or an Event, which is easy precisely because the decrement is already atomic on a single event loop.
  • Threaded latches need an atomic decrement; a plain count -= 1 on two threads loses updates and turns readiness into a permanent hang.
  • No latch anywhere protects against a double count-down. C++ calls it undefined behaviour; hand-rolled counters silently go negative and open the gate early. Key the signal by worker identity.
  • A latch has no failure channel. If a worker can fail, Promise.allSettled / asyncio.gather gives you the same wait plus the identity of what broke — see Promise.all & gather.
  • std::latch::wait blocks an OS thread; await latch.wait() and await event.wait() suspend a task and free the executor. Do not port a blocking wait onto an event loop.

Key points

  • A latch is one-shot and asymmetric: waiters wait, workers count down, and once it hits zero the gate is open permanently.
  • Late waiters return immediately — that is the feature for readiness gates and the bug when someone expected a barrier.
  • The decrement must be indivisible. A lost decrement is a permanent hang with every subsystem reporting healthy.
  • A double count-down is the mirror failure: the gate opens early and the service serves traffic against half-loaded state.
  • Key the signal by worker identity so retry paths and finally blocks cannot decrement twice.
  • A latch has no failure channel. If workers can fail, allSettled or gather gives the same rendezvous and names the failure.
  • Choose a barrier instead when everyone waits for everyone and it must work again next round.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • Construct with a count N — the number of signals required, fixed for the life of the latch.
  • Waiters check the count; if it is above zero they register and suspend, otherwise they proceed immediately.
  • Each worker performs one indivisible decrement on completion — atomic instruction, mutex, or a single synchronous region on an event loop.
  • The decrement that reaches zero releases every registered waiter and permanently marks the gate open.
  • Every subsequent wait returns immediately; there is no reset, and constructing a new latch is the only way to wait again.
  • A correct implementation refuses or ignores decrements past zero; a hand-rolled one will not, so idempotence must be enforced by the caller.
Interleavings that matter
  • W3 decrements 3 → 2; W1 and W2 both read 2 on separate cores and both write 1; three workers finished and the count says 1 — the coordinator waits forever and the deploy times out.
  • W1's task throws; its finally calls countDown and its error handler also calls countDown; the count goes 3 → 2 → 1 and W2 takes it to 0 while W3 is still loading the table — the gate opens with 200 MB of data missing.
  • The coordinator calls wait() after all three have already finished; the count is already zero and it returns immediately, exactly as intended.
  • W2 crashes without ever counting down; the count sticks at 1; there is no error anywhere and the readiness probe simply never passes — which is why a latch wait needs a timeout with a message naming the outstanding workers.
  • Correct schedule: each worker decrements exactly once under an atomic operation; the third decrement releases the coordinator; the service announces readiness with all three subsystems loaded.
What it guarantees — and does not
  • Guaranteed: no waiter proceeds before the count reaches zero.
  • Guaranteed: all waiters are released at the transition, and the gate stays open for every later waiter.
  • Guaranteed (with an atomic decrement): exactly N signals are required, and concurrent signals cannot be lost.
  • NOT guaranteed: that the count is decremented once per worker. Idempotence is the caller's responsibility everywhere.
  • NOT guaranteed: any failure semantics. A latch cannot express "worker 2 threw" — it can only fail to reach zero.
  • NOT guaranteed: reusability. A latch never resets; expecting a second round is the most common misuse.
  • NOT guaranteed: liveness. If a worker dies before signalling, the default outcome is an indefinite wait with no error.
Where contention appears
  • The counter is the contention point across threads; at N=3 it is nothing, and at N=100,000 the atomic decrement becomes a cache-line battle (False Sharing: Different Variables, Same Cache Line).
  • Release wakes every waiter at once — a wake burst that is trivial for one coordinator and a Thundering Herd when thousands wait on a start signal.
  • A latch wait on an OS thread holds a stack and a scheduler slot for the duration; on an event loop it is a pending promise and costs almost nothing.
  • The real waiting cost is not the primitive but the slowest worker: the coordinator is blocked for exactly as long as the longest warm-up, however fast the other two were.
How it fails
  • Lost decrement from a non-atomic count -= 1 on multiple threads: permanent hang, every subsystem healthy.
  • Double decrement from a retry path or a finally plus an explicit signal: the gate opens early against incomplete state.
  • Worker death before signalling: an indefinite wait with no error and no indication of which worker is missing.
  • Misuse as a barrier: the second round has no synchronisation at all because the gate is already open.
  • Blocking wait on an event loop, which stalls the whole process instead of the one task (Blocking the Event Loop).
  • Counting down past zero: undefined behaviour in C++, a negative counter and an early gate in a hand-rolled implementation.
When it helps
  • Readiness and start gates: hold traffic, or hold a benchmark, until every subsystem reports in.
  • Fan-in where the coordinator does not care which task finished last and the tasks return nothing.
  • Shutdown coordination: a latch that opens when the last in-flight request drains, so the process exits cleanly (Draining a Pipeline).
  • Test harnesses: a start latch that releases N threads simultaneously to make an interleaving likely.
  • When the waiters and the signallers are genuinely different roles, which is where a barrier does not fit at all.
When it hurts
  • When the workers return values you need — use Promise.all / gather and get the results as well as the wait.
  • When workers can fail, because the latch converts a failure into a hang with no diagnostic.
  • When you need it again next round; a latch cannot reset and reaching for one is a design error, not an inconvenience.
  • When N is not known up front — a latch's count is fixed at construction, and dynamic work needs a different primitive.
How you would know
  • Time from latch creation to release, and the per-worker signal timestamps; the last timestamp names the critical path.
  • Outstanding-signal count exported as a gauge, so a hang shows you which workers have not reported instead of just "not ready".
  • A hard timeout on the wait, whose error message lists the workers that have not signalled. This single change turns the worst failure mode into a legible one.
  • A counter of ignored duplicate signals, which is how you learn a retry path is double-signalling before it opens the gate early.
  • For the atomicity bug specifically: assert that the final count is exactly zero after all workers have joined, under a stress run (Stress Testing: A Test That Passed Once Proves Nothing).
Complexity it introduces
  • The count is a configuration coupling: whoever constructs the latch must agree with however many workers actually get spawned, and a mismatch is a hang.
  • Idempotence must be enforced by the caller, which usually means tracking worker identity — more state than the primitive itself.
  • A timeout and a diagnostic message are not optional in practice, so the real implementation is always larger than the counter suggests.
  • Error propagation must be built separately, and that is usually the point at which the latch should have been a gather instead.
Simpler alternatives
  • Promise.all / asyncio.gather / std::when_all when the tasks return values or can fail — the same rendezvous, plus results and error propagation (Promise.all & gather).
  • A barrier when the coordination is symmetric and repeats each phase (Barriers).
  • A counting semaphore when you need permits returned rather than a one-way countdown (Semaphores: Counting Permits as a Resource Limit).
  • A condition variable with an explicit predicate when the condition is richer than "N things happened" — and remember the predicate must be checked in a loop (Condition Variables: Waiting Until a Predicate Is True).
  • Structured concurrency: a task group that returns when all children complete and cancels the rest on the first failure, which is a latch with failure handling built in (Structured Concurrency).

Barrier vs latch

Barrier vs latch
Two phases of work across N workers, where phase 2 reads what phase 1 wrote. The question is who has to wait for whom, and whether the primitive can be used twice.
Coordination
Worker 1
phase 1 (68)
at barrier
phase 2 (41)
at barrier
Worker 2
phase 1 (50)
at barrier
phase 2 (39)
at barrier
Worker 3
phase 1 (95)
phase 2 (47)
at barrier
Worker 4
phase 1 (66)
at barrier
phase 2 (44)
at barrier
Worker 5
phase 1 (77)
at barrier
phase 2 (57)
↑ barrier 1↑ barrier 2
runningreadywaitingblockedidlems
wall clock
152 ms
worker time spent blocked
176 ms
phase 2 sees phase 1
guaranteed
reusable
yes, every round
barrier  every worker blocks at await(); the last arrival releases all N, and the barrier resets
         wall = max(phase1) + max(phase2) = 95 + 57 = 152 ms
latch    workers count down and carry on; a separate waiter is released when the count hits 0
         wall = max(phase1) + finalise    = 95 + 20 = 115 ms   — and the latch cannot count up again
none     wall = max over workers of (phase1 + phase2) = 142 ms, with no happens-before edge at all
152 ms, of which 176 ms is workers blocked at a barrier. A barrier converts N independent workers into one worker running at the speed of the slowest, once per round — the cost is the variance, not the mean, so a single straggler taxes every round for everybody. It buys you the thing phase 2 needs: a happens-before edge, so every write from phase 1 is visible to every reader in phase 2. If your phases are unbalanced, the fix is not a faster barrier; it is fewer barriers, or work-stealing inside a phase so the stragglers stop existing.
SIMULATEDdurations are deterministic per worker count

counter++ with and without atomicity

counter++ with and without atomicity
The same program on both sides: N threads, one shared counter, one increment each. On the left counter++ is read, add, write. On the right it is a single indivisible instruction. Every schedule of both is enumerated.
20 schedules enumerated on the left, 2 on the right
counter++ — read, add, write
r ← counter
r ← r + 1
counter ← r
schedules
20
lose an update
18
end at 2
2
worst case
1
final counter = 118 · 18 of 20 schedules
final counter = 22 · 2 of 20 schedules
atomic fetch_add — one indivisible step
fetch_add(counter, 1)   # no schedule can cut inside this
schedules
2
lose an update
0
end at 2
2
worst case
2
final counter = 22 · 2 of 2 schedules — the order still varies, the outcome does not
The threads still interleave. Atomicity does not remove the schedules; it removes the points at which a schedule can cut.
The non-atomic version, run 200 times under a random scheduler
runs that produced the right answer59 · 29.5% — a green test suite
runs that lost an update141 · 70.5%
With 2 threads there are 20 schedules of read/add/write and 18 of them — 90.0% — end with a counter smaller than 2. The worst is 1: every thread read 0, every thread computed 1, and the last write erased the rest. And yet 59 of the 200 sampled runs above produced exactly 2. That is why the non-atomic version passes tests. A test does not explore the schedule space, it samples it, and the sampling is biased by whatever the machine happened to be doing. 29.5% green is not 29.5% correct — the invariant is "after k completed increments, counter === k", and it is false in 18 legal schedules whether or not today's run found one. The right-hand column does not test better, it removes the schedules: an atomic read-modify-write has no interior for the scheduler to cut into. That buys correctness for one variable only — atomics compose badly, and two atomic operations in a row are not one atomic operation.
SIMPLIFIEDSchedule counts are exact for this model of the program. counter++ is modelled as three indivisible steps; a real compiler may split it further, and a real CPU may fuse it into one atomic instruction — which is exactly the right-hand column.

wait() in an if, or wait() in a while

wait() in an if, or wait() in a while
A consumer waits for the buffer to be non-empty. The difference between the two spellings is one keyword, and it is the difference between correct and corrupt.
lock()
if (items == 0):         # checked once, before sleeping
    cond.wait(lock)      # releases the lock, sleeps, reacquires
take(item)               # <- assumes the predicate is still true
unlock()
Invariant · items >= 0 — a consumer only takes an item that exists.
#ProducerConsumer 1Consumer 2RuntimeState
1·lock(); if (items == 0) wait()··items=0 waiters=1
2··lock(); if (items == 0) wait()·items=0 waiters=2
3lock(); items = 1; notifyAll(); unlock()···items=1 waiters=0
4·proceed: take(item)··items=0 waiters=0
5··proceed: take(item)·items=-1 waiters=0
✕ two consumers took one item — items = -1
notifyAll() woke both consumers, but only one item exists. C1 relocked first and took it; C2 then ran straight past a condition that had already become false again, because `if` checked the predicate once — before it slept. A wakeup is not a promise that the predicate is true; it is only a hint that it may be worth looking. The rule has no exceptions worth remembering: always wait in a loop over the predicate, and hold the lock while checking it. The condition variable carries no state and remembers no notifications — a notify() sent while nobody is waiting is simply lost, which is why the shared predicate, not the signal, is the source of truth.
SIMPLIFIEDA schedule the runtime is allowed to produce, not one it must. That is the point: this failure is legal and rare.

What people believe, and what is true

Claim

A latch is just a barrier with a nicer name.

Reality

A barrier is symmetric and reusable; a latch is asymmetric and one-shot. Using a latch for phase 2 means phase 2 has no synchronisation at all.

Claim

The counter is small, so count -= 1 is fine.

Reality

Size is irrelevant. It is a read-modify-write, and two threads doing it simultaneously lose one decrement — which is a permanent hang, not a slightly wrong number.

Claim

If a worker fails, the latch will tell me.

Reality

It cannot. A latch has one channel and it counts down. A failed worker simply never signals, and the wait hangs with no diagnostic unless you added a timeout.

Go deeper

Overview

Start at N, count down once per completed worker, and when it hits zero everybody waiting goes — permanently.

Practical

Make the decrement atomic, key it by worker identity so it cannot happen twice, give the wait a timeout, and put the names of the outstanding workers in the timeout message.

Advanced

If the workers can fail or return values, a latch is the wrong primitive — allSettled or gather gives the same rendezvous with a failure channel. Reach for a latch when the roles are genuinely asymmetric and the signal carries no data.

Internals

Threaded implementations are an atomic counter plus a futex or condition variable; the decrement that reaches zero performs a broadcast wake. On an event loop the promise itself provides both the gate and the permanent-open property, which is why the hand-rolled version is five lines.

Apply it