Skip to content

Bounded Buffers and Deadlock

The second half of LeetCode’s concurrency set moves from ordering to capacity and contention: a queue that blocks when full, groups that must form before anyone proceeds, and five philosophers who can genuinely deadlock.

This page has the only measured deadlock in the course. If you have not read Thread Ordering and Signalling, start there — the semaphore-versus-lock distinction is assumed here.

When it is not this. If the requirement is a fixed order or an alternation, that is the previous page and a chain of Semaphore(0) gates. And if you are reaching for threads to make CPU work faster, the GIL means you measured 0.68× — use processes.

Producer–consumer: Condition, not a busy-wait

Section titled “Producer–consumer: Condition, not a busy-wait”

LC 1188 asks for a queue with a capacity where enqueue blocks when full and dequeue blocks when empty. The primitive is a Condition, because the thing being waited on is a predicate over shared state:

bounded_queue.py
import threading
from collections import deque
 
 
class BoundedBlockingQueue:
    def __init__(self, capacity):
        self.cap = capacity
        self.q = deque()
        self.cv = threading.Condition()      # a Lock plus a waiting room
 
    def enqueue(self, x):
        with self.cv:
            # wait_for re-checks the predicate on every wake-up, which is what
            # makes spurious wake-ups and lost races harmless.
            self.cv.wait_for(lambda: len(self.q) < self.cap)
            self.q.append(x)
            self.cv.notify_all()
 
    def dequeue(self):
        with self.cv:
            self.cv.wait_for(lambda: len(self.q) > 0)
            x = self.q.popleft()
            self.cv.notify_all()
            return x
 
    def size(self):
        with self.cv:
            return len(self.q)

Verified with capacity 2, one producer pushing 0..9 and one consumer draining:

CheckResult
Items consumed[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Order preservedyes
Maximum size ever observed2 — never exceeded the capacity
Threads stuck at the endnone
sketch A capacity-2 queue: the producer blocks rather than overflowing p5.js
One producer pushing 0..9, one consumer draining, capacity 2. Watch the producer go BLOCKED whenever both slots are full -- that is the whole point of a bounded buffer, and it is why the depth trace below never crosses the dashed capacity line. Measured on the page: order preserved, maximum size ever observed exactly 2, nothing stuck at the end.

Three details carry it:

  • wait_for(predicate) rather than wait(). wait_for loops until the predicate holds, so a wake-up that turns out to be premature just waits again. A bare wait() assumes the condition is true on waking, which is the classic bug.
  • notify_all, not notify. With producers and consumers on the same condition, notify can wake a producer when only a consumer can make progress — and then nothing moves. Two separate Conditions sharing one lock let you use notify; one shared condition needs notify_all.
  • The lock is held while checking the predicate. with self.cv acquires it; wait_for releases it while blocked and reacquires before returning. That is what makes “check then act” atomic, and it is why you must not check len(self.q) outside the with.

LC 1117 (Building H2O) needs threads to leave in groups of exactly three — two hydrogen, one oxygen — with no partial molecules. A semaphore caps how many at once; a Barrier makes them leave together:

h2o.py
import threading
 
 
class H2O:
    def __init__(self):
        self.h = threading.Semaphore(2)          # at most 2 H inside a molecule
        self.o = threading.Semaphore(1)          # at most 1 O
        self.bar = threading.Barrier(3)          # nobody leaves until all 3 arrive
 
    def hydrogen(self, out):
        self.h.acquire()
        out.append("H")
        self.bar.wait()                          # blocks until 2 H and 1 O are here
        self.h.release()
 
    def oxygen(self, out):
        self.o.acquire()
        out.append("O")
        self.bar.wait()
        self.o.release()

Verified for 1 and 3 molecules: HHO and HHOHHOHHO, with every group of three being exactly HHO and nothing stuck.

The two primitives are doing different jobs and both are needed:

  • The semaphores enforce the ratio. Without Semaphore(2) on hydrogen, three H threads could get in and the barrier would trip with the wrong mixture.
  • The barrier enforces the grouping. Without it, an H could release its permit and let the next molecule’s H in before this molecule was complete, interleaving them.

A Barrier also resets automatically after tripping, which is what lets the same object serve molecule after molecule with no bookkeeping.

Five philosophers, five forks, each needs the two beside them. The naive solution — take the left fork, then the right — deadlocks, and here it is actually happening:

StrategyMeals eaten (of 5)Threads still stuckVerdict
Naive (left then right)05DEADLOCK
Resource ordering (lowest index first)50ok
Waiter (cap diners at n − 1)50ok

Measured with a 1.5-second timeout and a small sleep between the two acquires to make the window reliable. Zero meals — not “slow”, not “unfair”. Every philosopher holds their left fork and waits forever for a right fork their neighbour is holding.

sketch Dining philosophers: click to break the cycle p5.js
Five philosophers, five forks. Left-then-right closes a cycle of five and nothing ever completes -- measured 0 meals, 5 threads stuck. Click the canvas to switch to resource ordering, where every philosopher takes the LOWER-numbered fork first. Only philosopher 4 changes behaviour, and that one reversal is enough: the cycle cannot close, and all five eat. Deadlock needs a cycle, so removing the cycle is sufficient.
philosophers.py
import threading
 
 
def naive(forks, i, n):
    left, right = i, (i + 1) % n
    forks[left].acquire()                  # everyone gets their left fork...
    forks[right].acquire()                 # ...and nobody ever gets a right one
    return left, right
 
 
def resource_ordering(forks, i, n):
    """Break the CYCLE: always take the lower-numbered fork first."""
    left, right = i, (i + 1) % n
    a, b = min(left, right), max(left, right)
    forks[a].acquire()
    forks[b].acquire()
    return a, b
 
 
def waiter(forks, i, n, permits):
    """Break HOLD-AND-WAIT: at most n-1 philosophers may try to eat at once."""
    permits.acquire()                      # a Semaphore(n - 1)
    left, right = i, (i + 1) % n
    forks[left].acquire()
    forks[right].acquire()
    return left, right                     # caller releases forks, then permits

Why resource ordering works is the part to be able to say: philosopher 4’s “left” is fork 4 and “right” is fork 0, so under the ordering rule philosopher 4 takes fork 0 first. That one philosopher reaching in the opposite direction breaks the cycle — and a cycle is required for deadlock, so removing it is sufficient.

Why the waiter works is different: with at most four philosophers competing for five forks, at least one can always complete a pair. It removes hold-and-wait rather than circular wait.

The four conditions, and which fix removes which

Section titled “The four conditions, and which fix removes which”

Deadlock needs all four Coffman conditions simultaneously. Remove any one and it cannot happen — which turns “avoid deadlock” from a vague instruction into a checklist:

ConditionIn the philosophersHow to remove it
Mutual exclusiona fork is held by one philosopher at a timeNot removable — it is the point of a fork
Hold and waitholds left while waiting for rightWaiter: cap concurrent diners at n − 1
No preemptiona fork is never taken backacquire(timeout=…), then drop both and retry
Circular wait0 waits on 1 waits on … waits on 0Resource ordering: always take the lower index first

This is the answer to “how would you avoid deadlock in general?” — name the four conditions, then say which one your fix removes. It generalises far beyond philosophers: always acquire locks in a globally consistent order is resource ordering, and it is the single most useful deadlock rule in real systems.

Producer pushing 0..9, consumer draining, capacity 2:

MomentQueueProducerConsumer
start[]runningblocked — non-empty is false
after enqueue(0)[0]runningwakes, predicate now true
after enqueue(1)[0, 1]blocked — room-available is falsedraining
consumer takes 0[1]wakes
end[]donegot [0..9] in order

Both sides block, on the same condition object. That is why notify_all is required: a notify after enqueue might wake the producer that is waiting for space, which cannot proceed, and the consumer stays asleep. With one shared Condition, waking everyone and letting each re-check its own predicate is the correct and simple choice.

Verified: max observed size was exactly 2, and order was preserved — a consumer never saw a value out of sequence, because deque is FIFO and the lock makes each pop atomic.

Five philosophers, all executing acquire-left then acquire-right:

StepState
all five acquire their left forkP0 holds F0, P1 holds F1, P2 holds F2, P3 holds F3, P4 holds F4
all five request their right forkP0 wants F1 (P1 has it), P1 wants F2 (P2 has it), … P4 wants F0 (P0 has it)
resulta cycle of five threads, each holding one resource and waiting for the next

Measured: 0 meals, 5 threads alive after the timeout. Nothing recovers on its own, and no amount of retrying inside that structure helps, because every thread is blocked inside its acquire call.

Now the ordering fix, on the same five:

Philosopherleft / rightUnder ordering, takes first
P00 / 1F0
P11 / 2F1
P22 / 3F2
P33 / 4F3
P44 / 0F0 — not F4

P4 is the only one whose behaviour changes, and that is enough. P4 and P0 now contend for F0 first; whichever loses does not hold anything, so the chain never closes. 5 meals, nothing stuck.

wrong_wait.py
# WRONG — do not copy this
import threading
from collections import deque
 
cv, q = threading.Condition(), deque()
 
 
def broken_dequeue():
    with cv:
        if len(q) == 0:
            cv.wait()          # assumes the queue is non-empty on waking
        return q.popleft()     # may raise IndexError

Two ways this fails. A spurious wake-up — permitted by the API and real on some platforms — returns from wait() with nothing changed. And with notify_all, several consumers wake, the first takes the only item, and the rest proceed into an empty queue.

Using wait_for with a predicate re-checks after every wake and loops if it is still false. The rule: always wait in a loop on a predicate, never on a bare notification.

OperationCostNote
enqueue / dequeueO(1)O(1)deque at both ends, plus lock acquire/release
Blocked enqueueone context switchnot a spin — the thread sleeps
notify_allO(w)O(w) in waiterswakes all, each re-checks; fine for small w
Barrier(n).wait()O(1)O(1) amortisedthe n-th arrival releases all
Philosopher meal2 lock acquiresplus the waiter’s semaphore, if used
Naive philosophersnever completesmeasured 0 meals in 1.5 s

Two honest notes:

  • notify_all is O(w)O(w) wake-ups where notify would be O(1)O(1), and with many waiters that is the “thundering herd”. The fix is two separate Condition objects sharing one Lock — one for “not full”, one for “not empty” — so each notify wakes a thread that can actually proceed. For LC 1188’s scale, notify_all on a single condition is simpler and correct, and saying you know the upgrade path is the better answer.
  • None of this is about throughput. The GIL serialises bytecode, so a Python bounded queue is a correctness exercise. queue.Queue is the same design in C.
ProblemThe primitiveThe trap
1188 Bounded Blocking QueueCondition + wait_forBare wait(); notify instead of notify_all
1117 Building H2O2 semaphores + Barrier(3)Semaphores alone let molecules interleave
1226 Dining PhilosophersResource ordering, or a waiter semaphoreLeft-then-right deadlocks — measured 0 meals
1195 Fizz Buzz Multithreaded4 semaphores, or Condition on a counterFour-way ordering — previous page
“At most k concurrent”Semaphore(k)
“Wait for all workers”Barrier(n), or join each threadA barrier resets; join does not
Readers–writersCondition with reader count + writer flagWriter starvation under continuous readers
Unbounded producer–consumerqueue.Queue() with no maxsizeMemory growth if consumers fall behind
Real production codequeue.Queue, concurrent.futuresHand-rolling is the exercise, not the answer
  • Waiting without a predicate loop. Spurious wake-ups are permitted, and notify_all wakes several consumers for one item. Always use wait_for(predicate), or a while loop around wait().
  • notify on a shared condition with both producers and consumers. It can wake a thread that cannot make progress, and everything stalls. Use notify_all, or two conditions on one lock.
  • Checking the predicate outside the lock. Testing the queue’s length before entering the with block is a check-then-act race — the item can vanish between the two.
  • Left-then-right fork acquisition. Measured 0 meals, 5 threads stuck. It is a textbook deadlock and it reproduces reliably with a small sleep between the acquires.
  • Believing a deadlock is a hang you can retry out of. Every thread is blocked inside its acquire; nothing times out and nothing recovers. It needs a structural fix.
  • Timeout-and-retry without backoff. Removes no-preemption and introduces livelock — all threads running, none progressing. Randomise the backoff, or prefer resource ordering.
  • Acquiring locks in different orders in different functions. This is the general form of the philosophers’ bug and the most common real-world deadlock. Pick a global order and document it.
  • Semaphores without a barrier for grouping. In H2O, permits alone let the next molecule’s hydrogen in before the current one completes. The barrier is what makes the group atomic.
  • Forgetting a Barrier resets. It trips and rearms, which is what makes it reusable — but it also means a stray extra thread joins the next group.
  • Confusing deadlock with starvation. Deadlock is a cycle where nothing moves; starvation is one thread never getting a turn while others progress. Different diagnoses, different fixes.
  • Hand-rolling a queue in production. queue.Queue(maxsize=n) is this, in C, tested.
  • Testing concurrency once. Use repetitions and a timeout, so a deadlock fails the test instead of hanging the suite.

Drill 2 — deadlock, then break the cycle

Section titled “Drill 2 — deadlock, then break the cycle”
They askWhat they’re checkingThe answer
“Implement a bounded blocking queue.”The right primitiveA Condition with wait_for on a predicate, notify_all after mutating, everything inside the with. And say that queue.Queue(maxsize=n) is this in C
“Why wait_for rather than wait?”The classic bugSpurious wake-ups are permitted, and notify_all wakes several consumers for one item — so a bare wait() proceeds on a false predicate and pops an empty deque. Always wait in a loop on a predicate
“Why notify_all rather than notify?”Whether you see the stallProducers and consumers share one condition, so notify can wake a thread that cannot proceed while the one that could stays asleep. The upgrade is two Conditions on one Lock, which makes notify safe and avoids the thundering herd
“Where must the predicate be checked?”Check-then-actInside the lock. wait_for releases the lock while blocked and reacquires before returning, which is what makes check-and-act atomic
“Five philosophers, left fork then right. What happens?”Whether you can state it as deadlockIt deadlocks — measured 0 meals and 5 threads stuck. All five hold their left fork and wait on a right fork a neighbour holds: a cycle of five
“Fix it.”Two valid answersResource ordering — always take the lower-numbered fork first, so philosopher 4 reaches for fork 0 and the cycle breaks. Or a waiter semaphore capping diners at n − 1, so someone can always complete a pair. Both measured 5 meals, nothing stuck
“Which deadlock condition does each fix remove?”The general frameworkOrdering removes circular wait; the waiter removes hold and wait; timeouts remove no preemption; mutual exclusion is not removable. All four must hold at once, so killing one is sufficient
“What about timeout and retry?”The trap in the obvious fixIt removes no-preemption but risks livelock — everyone timing out and retrying in lockstep, all running and none progressing. Needs randomised backoff; resource ordering has no such caveat
“Deadlock or starvation?”Distinguishing failuresDeadlock is a cycle where nothing moves. Starvation is one thread never getting a turn while others progress — like writers under continuous readers. Different diagnosis, different fix
“Build H2O in groups of three.”Barrier versus semaphoreTwo semaphores for the ratio, a Barrier(3) for the grouping. Semaphores alone let the next molecule’s hydrogen in before this one completes
“How do you avoid deadlock in real code?”The one transferable ruleAcquire locks in a globally consistent order, and document it. That is resource ordering, and it is the most useful deadlock rule there is
“How do you test for deadlock?”Practicaljoin(timeout) and assert the threads finished — a deadlock then fails the test instead of hanging the suite. Plus many repetitions, because interleavings vary
pch.quizTag pch.quizDefaultTitle
  1. Why does the bounded queue use a predicate-checking wait rather than a bare `cv.wait()`?

    pch.quizShowAnswer

    B — Spurious wake-ups are permitted and notify_all wakes several consumers for one item, so a bare wait() can proceed with a false predicate and pop from an empty deque — wait_for loops until the predicate holds, so a premature wake-up simply waits again. Note that wait() does release the lock while blocked — that part is fine. The bug is the assumption that the condition is true on waking, which neither spurious wake-ups nor notify_all respect.

  2. The queue uses one Condition for both producers and consumers. Why is `notify_all` required?

    pch.quizShowAnswer

    B — notify could wake a producer waiting for space when only a consumer can make progress — and then nothing moves — With a single condition object you cannot choose who to wake, so waking one thread risks waking the wrong one and stalling. notify_all wakes everyone and lets each re-check its own predicate. The upgrade path is two Condition objects sharing one Lock, which makes notify safe and avoids the thundering herd — worth naming as the next step.

  3. Five philosophers each take their left fork then their right. What was measured over 1.5 seconds?

    pch.quizShowAnswer

    B — Zero meals, five threads still stuck — a genuine deadlock — Measured 0 meals and 5 alive. Every philosopher holds their left fork and waits on a right fork their neighbour holds, forming a cycle of five. It is not slowness or unfairness — nothing will ever complete, and no thread can time out because all five are blocked inside their acquire.

  4. How does resource ordering break the philosophers' deadlock?

    pch.quizShowAnswer

    B — Taking the lower-numbered fork first means philosopher 4 reaches for fork 0 before fork 4 — that one philosopher going the other way breaks the cycle — P4's left is fork 4 and right is fork 0, so the ordering rule inverts only P4's behaviour. P4 and P0 then contend for fork 0 first, and whoever loses holds nothing — so the chain never closes. Deadlock requires a cycle, so removing the cycle is sufficient. Note it does not serialise anything: 5 meals were eaten with nothing stuck.

  5. The waiter fix caps concurrent diners at n − 1. Which Coffman condition does it remove?

    pch.quizShowAnswer

    B — Hold and wait — with four philosophers competing for five forks, at least one can always complete a pair — It is a different fix from resource ordering, which is what makes the pair worth knowing. Ordering attacks circular wait; the waiter attacks hold-and-wait by guaranteeing spare capacity. Both measured 5 meals and nothing stuck, and being able to say which condition each one removes is the general framework the question is really after.

  6. You fix deadlock by acquiring with a timeout and retrying on failure. What is the risk?

    pch.quizShowAnswer

    B — Livelock — all philosophers timing out and retrying in lockstep, everything running and nothing progressing. Needs randomised backoff — Timeouts genuinely remove the no-preemption condition, so this is a real fix rather than a wrong one — but it trades a stopped system for a busy one that makes no progress. Randomised backoff makes lockstep unlikely rather than impossible, which is why resource ordering, with no such caveat, is the default recommendation.

  7. In Building H2O, why are semaphores alone insufficient?

    pch.quizShowAnswer

    B — Semaphores enforce the RATIO but not the GROUPING — a hydrogen could release its permit and let the next molecule's hydrogen in before the current molecule completed — The two primitives answer different questions: how many of each kind may be inside a molecule, and when may they leave. Verified — with a Barrier(3), every consecutive group of three is exactly HHO across 1 and 3 molecules. The barrier also rearms after tripping, which is what lets one object serve molecule after molecule with no bookkeeping.

  8. What is the difference between deadlock and starvation?

    pch.quizShowAnswer

    B — Deadlock is a cycle in which nothing moves; starvation is one thread never getting a turn while others progress — such as writers under continuous readers — The distinction drives the diagnosis. In a deadlock every involved thread is blocked and the system is stopped; under starvation the system is making progress and one participant is being passed over. The fixes differ too: break a cycle versus introduce fairness — which is the same judgement as FIFO hold queues and SCAN over SSTF elsewhere in this course.

  9. What is the single most useful deadlock-avoidance rule in real code?

    pch.quizShowAnswer

    B — Acquire locks in a globally consistent order, and document that order — This is resource ordering generalised, and the philosophers are its canonical illustration. Most real deadlocks are two functions taking the same two locks in opposite orders — a global order makes that impossible by construction rather than unlikely. Timeouts are a fallback that risks livelock, and a single lock is often not an option.

  10. How should a concurrency test be written so a deadlock does not hang the suite?

    pch.quizShowAnswer

    B — join(timeout) and assert the threads finished, plus many repetitions — a deadlock then fails the test rather than hanging — That is exactly how the numbers on this page were produced: a 1.5-second timeout turned the philosophers' deadlock into a reportable result (0 meals, 5 alive) instead of a stalled process. Repetitions matter because interleavings vary, and no number of passes proves a race absent — the reasoning still has to come from the invariant.

  • Capacity and grouping, not ordering. Ordering is the previous page.
  • Bounded queue = Condition. wait_for(predicate) never a bare wait(); notify_all after every mutation; predicate checked inside the lock. Verified: order preserved, max size exactly 2, nothing stuck.
  • A bare wait() is a bug — spurious wake-ups are legal and notify_all wakes several consumers for one item. Always loop on a predicate.
  • notify on a shared condition can stall everything by waking a thread that cannot proceed. Upgrade: two Conditions on one Lock.
  • Barrier(n) for grouping, semaphores for ratio. H2O needs both — permits alone let the next molecule interleave. A barrier rearms after tripping.
  • Left-then-right forks deadlock: measured 0 meals, 5 threads stuck. Not slow — stopped.
  • Resource ordering breaks it by making philosopher 4 take fork 0 first, destroying the cycle. Waiter semaphore (n − 1 diners) also works. Both: 5 meals, nothing stuck.
  • Four Coffman conditions, all required: mutual exclusion (not removable) · hold-and-wait (waiter) · no-preemption (timeouts) · circular wait (ordering). Kill one and deadlock cannot happen.
  • Timeout-and-retry risks livelock — running, not progressing. Randomise backoff.
  • Deadlock is not starvation. A cycle with nothing moving, versus one thread never served while others progress.
  • The real-world rule: acquire locks in a globally consistent order and document it.
  • In production use queue.Queue(maxsize=n) — this design, in C, tested.
  • Test with join(timeout) so a deadlock fails instead of hanging.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading