Concurrency Roadmap/Classic Concurrency Interview Patterns

Multi-Party Rendezvous (H2O, Barbershop-Style Problems)

Coordinate multiple distinct thread roles with asymmetric counting constraints — like H2O's 2 hydrogens per 1 oxygen — using per-role semaphores and a bulk-release gate: dining philosophers' asymmetric-role cousin.

!!!4/5Theory: 35m1 problems
Language-specific mechanics: Concurrency Language Manual

What makes this different from ordinary alternation

The previous subtopic's problems all had one thing in common: every waiting thread was interchangeable with every other thread waiting on the same gate. Multi-party rendezvous problems break that assumption. Threads now have distinct roles, each role needs a different count of participants before anything can proceed, and the group must cross the finish line together, atomically, as a unit — no partial groups, ever.

Building H2O is the canonical example: exactly two hydrogen threads and one oxygen thread must "bond" to form a molecule, and the constraint is not just "wait for 3 threads total" but "wait for exactly 2 of role H and exactly 1 of role O, and let no other H or O thread interleave a partial bond in between." A generic barrier (Topic 8's CyclicBarrier, which only counts how many threads have arrived, not which roles they belong to) gets you partway there, but on its own it can't enforce the 2:1 role ratio — you need to combine it with per-role counting.

The general technique: per-role semaphores plus a release gate

The standard solution shape has two ingredients:

  1. One semaphore per role, sized to how many of that role are allowed "in flight" before they must wait for their partners. For H2O: a semaphore for hydrogen initialized with 2 permits (two hydrogens are allowed to proceed into the bonding step before any oxygen is required), and a semaphore for oxygen initialized with 0 permits (no oxygen may proceed until hydrogens have signaled that two of them are ready).
  2. A hand-off rule that releases the other role's semaphore by exactly the count needed to complete the next group, and is reset atomically once a group completes.

For H2O specifically:

hydrogen_sem = semaphore(2) # up to 2 H's may proceed without waiting oxygen_sem = semaphore(0) # O must wait for H's to unlock it # hydrogen thread acquire(hydrogen_sem) bond_as_hydrogen() release(oxygen_sem) # signal "one more H is ready" # oxygen thread acquire_n(oxygen_sem, 2) # wait until BOTH hydrogens have signaled bond_as_oxygen() release_n(hydrogen_sem, 2) # reopen the gate for the next molecule's 2 H's

Walk through why this is correct, because the counting argument is exactly what an interviewer wants to hear:

  • At most 2 hydrogens can ever be "past the gate" at once, because hydrogen_sem starts at 2 and isn't replenished until an oxygen thread explicitly releases 2 permits back.
  • The oxygen thread's acquire_n(oxygen_sem, 2) is a bulk acquire — it blocks until both outstanding hydrogen releases have landed, which is precisely the "exactly 2 H's must bond with 1 O" constraint, expressed as a semaphore count rather than an explicit rendezvous barrier.
  • Once oxygen fires, it resets the hydrogen gate for exactly 2 more permits — no more, no less — so the next molecule can't start forming until this one is fully bonded and the gate is deliberately reopened.
  • Crucially: threads don't need to know which other threads they're paired with. The semaphore counts alone guarantee that if you group the sequence of "who bonded" events into consecutive triples, each triple is exactly {H, H, O}. That's the same "don't track identities, just track counts" insight that makes semaphores tractable in the first place.

If your language's semaphore doesn't support a bulk acquire(n) (not all do), the same effect is achievable with an explicit shared counter protected by a lock, incremented by each hydrogen, with the oxygen thread using a condition variable to wait until the count reaches 2 before proceeding and resetting it — mechanically more verbose, but the same idea.

Why this is dining philosophers' cousin, not its twin

Dining Philosophers (covered in this roadmap's Semaphores topic) is also a "multiple threads competing for shared resources" problem, and it's tempting to lump it in here. Resist that: dining philosophers has symmetric roles (every philosopher behaves identically) and its central danger is deadlock from circular resource acquisition (each philosopher holding one fork, waiting for a second). Multi-party rendezvous problems have asymmetric roles by definition — the whole point is that a hydrogen thread and an oxygen thread do fundamentally different things and appear in different required quantities — and the central danger is different too: it's not deadlock avoidance, it's making sure partial, invalid groups can never form or observably bond. Both families use semaphores as the hammer, but the nail is shaped differently: think of dining philosophers as "N-way mutual exclusion with a cycle to break," and rendezvous problems as "N-way counting synchronization with an atomicity-of-the-group guarantee to enforce."

Generalizing beyond H2O

The same shape reappears any time an interviewer asks for "K1 of role 1 plus K2 of role 2 (plus possibly more roles) must group up before proceeding" — sometimes dressed up as a "barbershop" or "assembly line" story problem instead of a molecule. The recipe doesn't change:

  1. Identify the roles and the fixed count of each required per group.
  2. Give each role its own semaphore.
  3. Initialize the semaphore(s) for whichever role(s) are allowed to proceed "unblocked" up to their required count; initialize any role that must always wait for others to 0.
  4. On the role that completes the group (the one waiting on a bulk acquire(n), or the last one to increment a guarded counter to the target), reopen the other semaphore(s) by exactly the count needed for the next group.
  5. Double-check: can two groups' worth of threads ever be "in flight" past the gate simultaneously in a way that lets a partial group's output interleave with another partial group's? If yes, you're missing a reset step or a lock around the bookkeeping.

Pitfalls and interview gotchas

  • Forgetting the bulk-acquire (or equivalent counting) step, and instead having the "completing" role naively acquire() once per required partner — this creates a race where two completing-role threads can each grab one partner's signal and neither ever sees a full group.
  • Resetting the gate before the group has actually finished its critical/bonding step, which lets the next group's members start interleaving mid-bond — re-open the gate as the last action after the group's shared work is done, not before.
  • Protecting the "how many have arrived" bookkeeping with the wrong granularity — if you fall back to a manual counter instead of a semaphore's built-in atomic counting, that counter update absolutely needs its own lock (Topic 2/3 material); a naive count += 1 from multiple roles racing is a classic reintroduced bug in an otherwise-correct-looking rendezvous solution.
  • Assuming a generic barrier alone solves this. A CyclicBarrier/barrier primitive is necessary-but-not-sufficient here — it enforces "N threads total have arrived," not "N₁ of role 1 and N₂ of role 2 have arrived," so it needs to be paired with per-role counting (typically the semaphore scheme above) to fully solve asymmetric-role problems.
Reference implementations in:

Building H2O: Per-Role Semaphores With a Bulk Release

hydrogen starts with 2 permits (two H's may proceed unblocked); oxygen starts with 0 and needs both H releases before it can fire — then reopens the gate for exactly the next molecule.

type H2O struct { h chan struct{} // permits for hydrogen o chan struct{} // signals from each H toward oxygen } func NewH2O() *H2O { h := make(chan struct{}, 2) h <- struct{}{} h <- struct{}{} // two H's may proceed unblocked return &H2O{h: h, o: make(chan struct{}, 2)} } func (w *H2O) hydrogen(releaseHydrogen func()) { <-w.h releaseHydrogen() // outputs "H" w.o <- struct{}{} } func (w *H2O) oxygen(releaseOxygen func()) { <-w.o <-w.o // wait for BOTH hydrogens — no bulk acquire releaseOxygen() // outputs "O" w.h <- struct{}{} w.h <- struct{}{} // reopen the gate for the next molecule }

Buffered channels are counting semaphores. There is no acquire(2) — two receives stand in for Java's bulk wait. sync.Cond plus H/O counters also works, but channels are the expected Go answer. The gotcha is opening the next molecule's H permits only after O has bonded, or you over-produce hydrogen.

Further Resources (Optional)

Practice Problems

Interview relevancy:!!!Critical — must-have!!Important — highly recommended!Good to have~Niche — rarely asked