The question this answers
How does a coordinator wait for N pieces of work to finish, without knowing which one finishes last?
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.
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 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.
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.
| # | 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 |
| 2 | finishes; reads count → 2 | · | · | · | count=2 done=1 ready=false |
| 3 | · | finishes; reads count → 2 | · | · | count=2 done=1 ready=false |
| 4 | computes 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 minutes | count=1 done=3 ready=false |
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.
| Property | Latch | Barrier | Join / Promise.all | Semaphore |
|---|---|---|---|---|
| Reusable | No — one-shot, permanent once open | Yes — resets for the next phase | No — one collection, one wait | Yes — permits are returned |
| Symmetry | Asymmetric: waiters and counters are different roles | Symmetric: every participant both waits and signals | Asymmetric: parent waits, children finish | Asymmetric: acquirers and releasers |
| Who may wait | Anyone, including parties that never count down | Only the N participants | The parent that holds the handles | Anyone needing a permit |
| Count direction | Down to zero, then open forever | Up to N, then reset to zero | Implicit in the number of handles | Up and down, no terminal state |
| Late arrival | Returns immediately — gate is open | Joins the next phase | Handle is already settled; resolves immediately | Blocks if no permits are free |
| Typical use | Readiness gates, start signals, "wait for shutdown" | Phased simulation, bulk-synchronous rounds | Fan-out then fan-in of independent tasks | Limiting concurrent access to a resource |
| Characteristic failure | Lost or double decrement — hang, or open too early | Missing generation counter; a party that never arrives | First rejection abandons the others (Promise.all & gather) | Permit leaked on an error path |
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.
1#include <latch>2#include <thread>3 4std::latch warmups{3}; // one-shot, cannot be reset5 6auto worker = [&](auto fn) {7 fn();8 warmups.count_down(); // atomic; UB if it goes past zero9};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 zero16start_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.
1// No latch primitive. Build one — the decrement is atomic on this loop.2function createLatch(n) {3 let remaining = n4 const signalled = new Set() // idempotence is YOUR job5 let open6 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 early10 signalled.add(id)11 remaining -= 1 // no await between read and write: atomic here12 if (remaining === 0) open()13 },14 wait: () => gate, // late waiters get the settled promise instantly15 }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.
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 = results12 .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.
1import asyncio2 3# asyncio has no latch. Event + counter is the direct translation.4class Latch:5 def __init__(self, n: int) -> None:6 self._remaining = n7 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 worker12 return13 self._signalled.add(key)14 self._remaining -= 1 # atomic on the loop: no await inside15 if self._remaining == 0:16 self._event.set() # stays set forever — latch semantics17 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.
- 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 -= 1on 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.gathergives you the same wait plus the identity of what broke — see Promise.all & gather. std::latch::waitblocks an OS thread;await latch.wait()andawait 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
finallyblocks cannot decrement twice. - A latch has no failure channel. If workers can fail,
allSettledorgathergives 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.
- • 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.
- • 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
finallycalls 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.
- • 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.
- • 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.
- • Lost decrement from a non-atomic
count -= 1on multiple threads: permanent hang, every subsystem healthy. - • Double decrement from a retry path or a
finallyplus 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.
- • 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 the workers return values you need — use
Promise.all/gatherand 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.
- • 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).
- • 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
gatherinstead.
- •
Promise.all/asyncio.gather/std::when_allwhen 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 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 allcounter++ with and without atomicity
r ← counter r ← r + 1 counter ← r
fetch_add(counter, 1) # no schedule can cut inside this
wait() in an if, or wait() in a while
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()| # | Producer | Consumer 1 | Consumer 2 | Runtime | State |
|---|---|---|---|---|---|
| 1 | · | lock(); if (items == 0) wait() | · | · | items=0 waiters=1 |
| 2 | · | · | lock(); if (items == 0) wait() | · | items=0 waiters=2 |
| 3 | lock(); 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 |
What people believe, and what is true
A latch is just a barrier with a nicer name.
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.
The counter is small, so count -= 1 is fine.
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.
If a worker fails, the latch will tell me.
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.