skip to content

A service uses a counting semaphore to cap how many requests are in flight to a downstream dependency at any moment. What failure modes would you design against in that limiter?

level: seniorimportance: should knowfreq 47%

answer

  1. release in finally/defer — leak is permanent
  2. double release silently raises the cap
  3. bounded acquire + reject, never wait forever
  4. cap is in-flight, not arrivals — bound the queue too
  5. concurrency limiter ≠ rate limiter; cap × replicas is what downstream sees

basics

~20 s

Permit leaks on error paths (always release in a finally/defer), double releases that silently raise the cap, unbounded waiting instead of a timeout plus rejection, an unbounded queue of waiters hiding the overload, starvation under barging, and forgetting the cap is per instance, not global.

solid answer

~60 s

Six things break real semaphore limiters. **Permit leaks** — an exception, early return or cancellation between acquire and release permanently burns a permit; the limiter degrades to zero and the service hangs. Release in a `finally`/`defer`/scope guard, never at the end of the happy path. **Double release** — releasing twice raises the cap silently; the limit you configured is no longer the limit you have. Make release idempotent per acquisition. **Unbounded blocking** — a plain acquire waits forever, so a stalled dependency turns into a stalled service. Use a bounded acquire and reject on timeout. **Unbounded waiter queue** — the semaphore caps *in flight*, not *arrived*. Waiters accumulate, memory grows and queued requests time out anyway; cap the queue too and shed early. **Starvation** — barging implementations can leave an unlucky waiter parked indefinitely; pick fairness if you need a tail-latency bound. **Scope confusion** — the cap is per process. With R replicas the dependency sees R × N. Also: measure permits in use, waiters and rejections, or you cannot tell a healthy limiter from a leaking one.

code

text · 12 lines
text
# leaks a permit whenever call() throws
sem.acquire()
result = call()          # BUG: exception skips the release
sem.release()

# correct: cleanup on every exit path, plus bounded wait
if not sem.try_acquire(timeout = 50ms):
    reject("downstream at capacity")   # visible backpressure
try:
    result = call()
finally:
    sem.release()        # exactly once, on every path

go deeper

for a junior

Focus on the mechanical rule: acquire, then release in a finally so every exit path returns the permit exactly once.

for a middle

Add bounded acquisition with rejection, and explain why an extra release silently widens the cap.

for a senior

Cover the queue in front of the limiter, fairness and tail latency, the metrics that make a leak visible, and the replica-count multiplication.

for a principal

Treat the limiter as a capacity contract with the dependency: decide the global budget, how it is split across replicas and endpoints, what the overload policy is (shed, queue, degrade), and whether concurrency or rate is the real constraint.

## What the primitive actually guarantees A counting semaphore guarantees exactly one thing: the number of threads that have acquired but not released never exceeds the initial permit count. Every failure mode below is a way the surrounding code breaks the assumptions that make that guarantee useful. ## 1. Permit leaks The acquire/release pair must bracket the guarded work on **every** exit path — normal return, exception, early return, cancellation, timeout, panic. If a permit is not returned, it is gone for the life of the process. Leaks are cumulative and monotonic: the limiter of 20 becomes 19, then 12, then 0, and the service stops serving with no error anywhere near the cause. The symptom is a slow decay to a hang over hours or days, which is why it survives testing. Mitigation is structural, not disciplinary: acquire immediately before a `try`/scope-guard whose cleanup releases, or wrap the whole thing in a helper that takes a callable, so no caller can write an unbalanced pair. A cancellation-aware runtime adds a subtlety — if a task is cancelled *while blocked in acquire*, the implementation must guarantee it did not consume a permit; if cancellation can race with a hand-off, an interrupted acquire that actually took a permit becomes a leak. ## 2. Double release / release without acquire The mirror image. Semaphores have no ownership, so an extra release simply increments the count and permanently raises the ceiling above your configured value. A retry loop that calls cleanup twice, or a `finally` release plus an error-handler release, silently converts a limit of 20 into 21, then 25. Nothing throws, and the effect only shows as unexplained downstream overload. Guard with a per-acquisition released-once flag, or by making the release path unreachable more than once by construction. ## 3. Unbounded blocking A plain `acquire()` waits forever. When the dependency stalls, permits stop returning, every caller parks, and the outage propagates upward as a hang — the worst failure shape, because hangs consume caller resources and defeat their timeouts too. Always prefer a bounded acquire: try for a small budget, then reject with an explicit "downstream at capacity" signal. The rejection is a working backpressure signal; the hang is not. The acquire timeout should be small relative to the caller's own deadline, because time spent queueing is time not spent on the call. ## 4. The queue in front of the limiter The cap bounds concurrency, not arrival rate. If arrivals exceed the rate the limiter can retire work, the *wait queue* grows without bound: memory climbs, and by the time a request reaches the front its caller has already given up, so the work you eventually do is worthless — the classic queueing-latency collapse. Two mitigations: bound the number of waiters as well (reject immediately once the queue is full — this is what a bulkhead does), and prefer LIFO or deadline-aware admission under overload so at least the freshest requests are still useful. Little's law makes this concrete: if the mean service time is W and you allow N in flight, the sustainable throughput is N/W; anything arriving faster queues forever. ## 5. Starvation and fairness Unfair (barging) semaphores let a thread calling acquire at the instant a permit is released take it ahead of threads already queued. Throughput is better; the tail is worse, and under sustained saturation an individual waiter can be passed over indefinitely. If you have a p99 obligation, choose a fair semaphore and accept the hand-off cost, or bound waiting with the timeout in point 3 so "starved" becomes "rejected", which is at least observable. ## 6. Scope: the cap is local A process-local semaphore caps that process. Deploy R replicas and the dependency sees up to R × N concurrent calls; autoscale the fleet and the dependency's load scales with it, which is exactly backwards — the point of the limiter was to protect something that cannot scale on demand. Either divide a global budget across replicas (and re-divide when the replica count changes), or move the limit to a shared coordination point, accepting that a distributed limiter buys accuracy with latency and a new dependency. Also beware nesting: a global limiter plus per-endpoint limiters can deadlock if a task holds one permit while acquiring another and the acquisition order is inconsistent. ## 7. Not observable = not operable Export permits available, permits in use, current waiters, wait time distribution, acquisition timeouts and rejections. Without "permits in use" you cannot distinguish a genuinely busy limiter from one that has leaked its permits away; without waiter count you cannot see the queue that is about to eat your memory. A leak detector is simple: available + in-use should equal the configured cap at all times. ## 8. What a semaphore is not It limits **concurrency** (things at once), not **rate** (things per second). If the downstream constraint is requests per second, a semaphore is the wrong primitive — you want a token bucket or leaky bucket. The two are related by Little's law (rate ≈ concurrency / latency), which means a concurrency limiter's effective rate silently drops when the dependency slows down. That is often the behaviour you want — it self-throttles under stress — but it is a property to state deliberately, not to discover.

  • How would you detect a permit leak in production?
    Export both permits available and permits in use, and assert that their sum equals the configured cap. A leak shows as that sum drifting downward over time, or as available permits trending to zero while the in-use count and downstream throughput stay low. Rising acquisition timeouts with a flat downstream call rate is the same signature seen from outside.
  • When is a semaphore the wrong tool, and what would you use instead?
    When the constraint is a rate rather than a concurrency level — for example a partner API allowing 100 requests per second regardless of how fast each one returns. A semaphore bounds simultaneous calls, so its effective rate varies with latency; you want a token bucket or leaky bucket for a rate cap. If the limit must hold across many replicas, a process-local semaphore is also the wrong tool and you need a shared limiter.
  • Your limiter is at capacity and the queue of waiters keeps growing. Is FIFO admission the right policy?
    Often not. Under sustained overload FIFO serves the oldest requests first, and those are exactly the ones whose callers have already timed out, so you spend capacity producing responses nobody reads. LIFO or deadline-aware admission keeps the freshest, still-useful requests moving and lets the stale ones be dropped. FIFO remains right when fairness between clients matters more than usefulness of the work.

saying these in an interview costs you the question

  • Releasing at the end of the happy path instead of in a finally/defer
  • Assuming the runtime will detect an unbalanced release
  • Using a plain unbounded acquire and calling the resulting hang "backpressure"
  • Believing a concurrency cap also caps requests per second
  • Forgetting the limit multiplies by the number of replicas

context