Skip to content

Catching Release Bugs with BoundedSemaphore

A semaphore limits concurrency only as long as every acquire() is matched by exactly one release(). An error path that releases early and then releases again in finally silently adds a slot each time it runs, and a limit of 5 drifts upward until it limits nothing. asyncio.BoundedSemaphore exists to catch this: it raises ValueError when released more times than acquired. Measured on Python 3.14 with a limit of 5, 1,000 concurrent calls, and a bug that double-released on the 10% of calls that failed: a plain Semaphore reached a peak of 94 concurrent calls and ended with an internal count of 110. A BoundedSemaphore with the same bug reached the same peak of 94 — the extra releases admitted waiting tasks without tripping its check — and raised 105 ValueErrors only once the burst had drained. In a test that made one call at a time, the BoundedSemaphore raised "BoundedSemaphore released too many times" on the fourth call, the first one to take the buggy path, while the plain semaphore passed all 20 calls and ended at 6 slots. With the release fixed by async with, the peak stayed at 5. This guide explains when BoundedSemaphore catches the bug and how to make sure it is caught.

Prerequisites

1. See how a release bug inflates the limit

The bug usually comes from manual acquire()/release() calls spread across error-handling code:

async def call(sem, client, item):
    await sem.acquire()
    try:
        return await client.send(item)
    except ConnectionError:
        sem.release()                      # "free the slot before retrying elsewhere"
        raise
    finally:
        sem.release()                      # ...and release it again

Every failure adds one permit. Measured with a limit of 5 and 1,000 concurrent calls, 10% failing: peak concurrency 94, and the semaphore's internal value 110 at the end — it would now allow 110 concurrent calls. Nothing raised, nothing was logged, and the protected upstream simply received far more concurrent requests than it was promised, in proportion to how often errors occurred. Under an outage, when errors are frequent, the limit fails exactly when it is needed most.

Verify: for each semaphore, every code path releases it exactly once per acquisition.

Limit 5, 1,000 concurrent calls, 10% taking a double-release path A grid of 3 rows by 5 columns. Limit 5, 1,000 concurrent calls, 10% taking a double-release path primitive release code peak concurrency value at end ValueErrors Semaphore buggy 94 110 0 BoundedSemaphore buggy 94 5 105 (after the burst) BoundedSemaphore async with 5 5 0 Python 3.14.

2. Know when BoundedSemaphore actually raises

BoundedSemaphore.release() raises only if releasing would push its internal value above the initial value. But when tasks are waiting, release() does not raise the value at all — it hands the permit straight to the next waiter:

class BoundedSemaphore(Semaphore):
    def release(self):
        if self._value >= self._bound_value:
            raise ValueError('BoundedSemaphore released too many times')
        super().release()                   # with waiters queued, wakes one instead

Measured under load: with hundreds of tasks queued, every extra release woke an extra waiter, so the value never reached the bound and the check never fired — the peak was 94, the same as with the plain semaphore. Only after the burst, when the queue had emptied and extra releases had nowhere to go, did the value hit the bound, and then 105 releases raised. So under contention BoundedSemaphore reports the bug late, after the damage; with no contention it reports it immediately. The tool is a tripwire for tests and quiet periods, not a guard in production.

Verify: you rely on BoundedSemaphore to find release bugs in tests, not to enforce the limit under load.

3. Test the error paths without concurrency

Because BoundedSemaphore catches over-release reliably when nothing is waiting, the most effective place for it is a test that drives each code path, including the failure paths, one call at a time:

async def test_failure_path_releases_once():
    sem = asyncio.BoundedSemaphore(5)
    client = FakeClient(fail_on={3})                  # the fourth call fails
    for i in range(20):
        try:
            await call(sem, client, i)
        except ConnectionError:
            pass
    assert sem._value == 5                            # all permits back, none extra

Measured with the buggy call: the BoundedSemaphore raised ValueError: BoundedSemaphore released too many times during the fourth call — the first failure — while a plain Semaphore in the same test let all 20 calls pass and ended with 6 permits. Make the semaphore injectable, so tests can pass a BoundedSemaphore even where production code uses a plain one, and drive every except branch at least once. Checking the internal _value is acceptable in a test; in production code, track in-flight work yourself.

Verify: a test exercises every error path of code that releases semaphores manually, with a BoundedSemaphore.

The same bug, one call at a time A grid of 2 rows by 2 columns. The same bug, one call at a time primitive result of 20 sequential calls (call 4 fails) Semaphore(5) all 20 passed; 6 permits at the end BoundedSemaphore(5) ValueError on call 4: released too many times Without contention, the bound check fires on the first extra release.

4. Remove manual release calls

The real fix is to make double release impossible. async with acquires once and releases once, on every exit path — return, exception or cancellation:

async def call(sem, client, item):
    async with sem:
        return await client.send(item)           # errors propagate; the slot is released once

Measured with the fixed version: peak concurrency 5 and no ValueErrors across the same 1,000 calls with 10% failing. When a slot genuinely has to be released before the function returns — handing work to another stage, say — release it once, in one place, and record that you did:

released = False
await sem.acquire()
try:
    result = await client.send(item)
    sem.release(); released = True               # early release on the success path only
    await hand_off(result)
finally:
    if not released:
        sem.release()

Code review can then check one rule: no release() without a matching flag or context manager. The same discipline applies to locks, as in avoiding deadlocks with nested asyncio locks.

Verify: grep -n "\.release()" src/ finds only releases paired with a flag, or none at all.

5. Watch the effective limit in production

Since neither semaphore enforces correctness under load, observe the outcome: the number of operations actually in flight. Count it alongside the semaphore, and alarm when it exceeds the configured limit:

class CountedSemaphore:
    def __init__(self, limit: int):
        self.limit = limit
        self._sem = asyncio.BoundedSemaphore(limit)
        self.in_flight = 0

    async def __aenter__(self):
        await self._sem.acquire()
        self.in_flight += 1
        if self.in_flight > self.limit:
            log.error("semaphore over limit: %d > %d", self.in_flight, self.limit)

    async def __aexit__(self, *exc):
        self.in_flight -= 1
        self._sem.release()

in_flight counts what the semaphore admitted, independently of its permit count, so an over-release shows up as in_flight exceeding limit — 94 against 5 in the buggy run — the moment it happens, under load or not. Exported as a gauge with the limit beside it, it also shows whether the limit is ever reached, which is how to size it, as discussed in limiting concurrent requests with asyncio.Semaphore.

Verify: in-flight counts are exported per semaphore and never exceed the configured limit.

How do I keep a semaphore honest? A decision on How is the semaphore used with 4 outcomes. How do I keep a semaphore honest? How is the semaphore used? async with only nothing to catch peak stayed 5 manual acquire/release test each path, BoundedSemaphore raised on call 4 under contention do not rely on BoundedSemaphore 94 admitted first in production count in-flight vs limit over-limit visible at once The bound is checked only when no task is waiting.

Verification

Semaphore release bugs are under control when:

  • Production code uses async with, or a single flagged release per acquisition.
  • Every error path is tested sequentially with a BoundedSemaphore.
  • Nobody relies on BoundedSemaphore under load, where extra releases wake waiters silently.
  • In-flight counts are exported and compared with the limit.

Diagnostic Hook: compare the upstream's observed concurrency — from its own metrics or your connection pool — with the semaphore's configured limit after an incident with many errors. Concurrency that rose with the error rate is the signature of a release on an error path; the fix is in the except blocks, not in the limit.

Pitfalls & edge cases

  • Releasing in both except and finally. Measured: a limit of 5 admitted 94.
  • Trusting BoundedSemaphore under contention. Measured: the same 94 before its first error.
  • Tests that only run the success path. The bug lives in the error path.
  • Reading _value in production code. It is internal; count in-flight work yourself.

Frequently Asked Questions

What is the difference between Semaphore and BoundedSemaphore in asyncio?

BoundedSemaphore raises ValueError if released more times than acquired; Semaphore silently gains a permit. In a sequential test the bounded version raised on the first double release, while the plain one ended with 6 permits instead of 5.

Does BoundedSemaphore prevent over-admission under load?

No: when tasks are waiting, an extra release wakes one of them instead of raising, so a limit of 5 admitted 94 concurrent calls before the first ValueError in testing.

How do I avoid releasing a semaphore twice?

Use async with sem:, which releases exactly once on every path; with it, peak concurrency stayed at the limit of 5.

How do I detect semaphore leaks in production?

Count operations in flight alongside the semaphore and alarm when the count exceeds the limit.