Skip to content

← Concurrency Without Regret step 3 of 38

Medium Primitives

BoundedCounter: check-then-act under 64 threads

Build the counter that everyone gets wrong.

BoundedCounter(limit) must expose:

def try_increment(self) -> bool     # increment and return True only if the result stays <= limit
def value(self) -> int              # the current count
def snapshot(self) -> Mapping[str, int]   # read-only view: {"value": ..., "granted": ...}

granted counts how many try_increment() calls returned True. If the counter is correct, value == granted == min(limit, threads * calls) and the sum of the per-thread win counts agrees with both.

Why it fails

The obvious implementation has two independent bugs in three lines:

def try_increment(self) -> bool:
    if self._count < self._limit:   # check
        self._count += 1            # ...then act
        return True
    return False

Check-then-act. The read and the write are separate. Two threads sitting at limit - 1 both see room and both take it, so the final count exceeds the limit. A rate limiter that lets traffic through, a connection pool that opens one connection too many, a licence check that grants a seat it does not have.

Read-modify-write. self._count += 1 is a load, an add and a store. Two threads can both load the same value and both store the same successor, and one increment disappears. So the count can also end up lower than the number of True returns.

Under the first test case — 64 threads, 10,000 calls each, limit 5,000 — the naive version reliably fails both checks at once, in opposite directions.

What to write

Only BoundedCounter. The driver that spawns the threads and assembles the report is provided.

Take the lock around the whole decision, not just the mutation. Locking only self._count += 1 fixes the lost update and leaves the over-grant, which is the more damaging of the two.

Where the type system earns its keep

Two annotations are doing real work here:

  • Final on the lock and the limit. A lock that gets rebound is not a lock; a limit that another thread can change is not a limit. Final turns “assigned once in __init__“ from a convention into something mypy checks.
  • Mapping[str, int] on snapshot. The return type says callers may not mutate. But dict is a Mapping, so the annotation alone still hands out a live, mutable, unsynchronised handle on your internal state to anyone who does not type-check. Build the snapshot inside the lock and wrap it in types.MappingProxyType — the report’s read_only field checks that the returned object genuinely has no __setitem__.

Loading visualization…