-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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 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.
The pool stores no manual allocation count. The counters are computed from the gate and the queue:
-
ActiveLeasesismaxSize - gate.CurrentCount— capacity minus free permits, which is permits held. -
ItemsAvailableis the idle queue's count. -
ItemsAllocatedisActiveLeases + ItemsAvailable. -
QueuedLeasesis 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.
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
MinSizebefore it builds the gate; when a factory call throws mid-seed, the already-created items are disposed and no semaphore is left behind. -
Clearis strict-max. It disposes the idle items, then reseeds up toMinSizecapped by free permits, so it never manufactures items pastMaxSize. Queued waiters are served by real returns, not byClear.
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.
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.