Skip to content

The Capacity Gate

Mark Lauter edited this page Jun 15, 2026 · 1 revision

The capacity gate

This page is for contributors and readers curious about the pool's internals. It explains the SemaphoreSlim design at the heart of the pool — why capacity is modeled as permits, how the counters derive from it, and what that buys in correctness. How leasing works traces a lease through this design; this page explains the design itself.

Capacity as permits

The pool models capacity as a single SemaphoreSlim(maxSize, maxSize) — one permit per unit of MaxSize — plus one ConcurrentQueue of idle items. A permit is the right to hold one item. A lease takes a permit before it touches the idle queue; a release returns one after it enqueues the item. Permits held therefore equals items leased, and the leased count can never exceed MaxSize.

This collapses what a pool would otherwise split across two structures. There is no separate waiter list: when every permit is held, callers wait in the semaphore's own queue, and a release wakes one of them. One structure means there is no second queue to fall out of sync with the first — the class of lost-wakeup and cancel-versus-handoff races that a hand-rolled rendezvous invites cannot be represented here.

Release ordering

Release enqueues the item before it returns the permit. The order matters: returning the permit is what wakes a waiting lease, so the item must already be in the queue when the waiter runs, or the waiter would wake to an empty queue. Enqueue first, release second — the woken lease always finds an item.

Derived counters

The pool stores no manual allocation count. The counters are computed from the gate and the queue:

  • ActiveLeases is maxSize - gate.CurrentCount — capacity minus free permits, which is permits held.
  • ItemsAvailable is the idle queue's count.
  • ItemsAllocated is ActiveLeases + ItemsAvailable.
  • QueuedLeases is a separate counter, incremented only on the contended wait path, because the semaphore does not expose its waiter count.

Deriving the counts removes a whole category of drift: a stored count can disagree with reality after a Clear or a failure path, but a derived count cannot.

Failure and disposal paths

The permit is the invariant. From the moment a lease takes one, every exit accounts for it exactly once:

  • On a preparation failure, the pool disposes the item and releases the permit before rethrowing, so a failed lease never leaks capacity.
  • On success, ownership transfers to the caller, who returns the permit with a later Release.

Two lifecycle paths round this out:

  • Seeding is fault-safe. The constructor fills the idle queue to MinSize before it builds the gate; when a factory call throws mid-seed, the already-created items are disposed and no semaphore is left behind.
  • Clear is strict-max. It disposes the idle items, then reseeds up to MinSize capped by free permits, so it never manufactures items past MaxSize. Queued waiters are served by real returns, not by Clear.

What the design does not police

The gate guarantees MaxSize only under correct release discipline. The pool tracks no ownership of leased items, so a stray or double Release over-counts the gate — inflating permits and breaching MaxSize — or throws SemaphoreFullException when the gate is already full. This is the deliberate trade described in Avoiding footguns: a tight, allocation-light path in exchange for trusting the caller to release each item once. A scoped lease makes the return idempotent and closes that gap.

Leak detection

A scoped Lease that is garbage-collected without being disposed records itself on the pool.leases.leaked counter from its finalizer. The finalizer touches only an immutable string and a static counter, so it is safe to run on the finalizer thread, and it deliberately does not resurrect the item — the permit stays held, but the leak is now counted rather than silent. See Metrics.

Clone this wiki locally