Skip to content

Unbounded Pools

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

Unbounded pools

UnboundedPool<TPoolItem> is a variant of the pool that never blocks a lease. The default pool borrows: it holds a hard MaxSize ceiling, and a lease waits when every item is out. The unbounded pool transfers ownership: LeaseAsync returns immediately — handing back an idle item or creating one on the spot — and Release is an optional optimization that donates an item back for reuse.

It's modeled on System.Buffers.ArrayPool<T>: rent freely, return when you can, and a return you skip costs reuse but never correctness. You inject the same IPool<TPoolItem> surface as the bounded pool, so the methods and counters on this page match the IPool API; what changes is the contract behind them.

When to use it

Reach for the unbounded pool when you want pooled reuse without a concurrency ceiling — when blocking or timing out a caller costs more than allocating one more item. Cap memory by how many idle items you retain (MaxIdle) rather than by how many can be live at once.

Stay with the bounded pool when you need MaxSize as a throttle — when the point of the pool is to limit how many items exist or how many operations run concurrently. The unbounded pool has no such limit.

Register a pool

AddUnboundedPool parallels AddPool. It binds UnboundedPoolOptions from the "UnboundedPoolOptions" configuration section, then applies your configure action:

using Pool;

services.AddTransient<SmtpConnection>();
services.AddUnboundedPool<SmtpConnection>(configuration, options =>
{
    options.MinSize = 2;               // pre-create two on startup
    options.MaxIdle = 50;              // retain up to fifty idle items for reuse
    options.UseDefaultFactory = true;  // construct items from the service provider
});

Named pools and typed clients have unbounded counterparts: AddNamedUnboundedPool<TPoolItem>(name, …) registers a keyed pool, and AddUnboundedPool<TPoolItem, TClient>(…) registers a pool keyed by a client type and injects it into that client. Both mirror their bounded equivalents. See Dependency injection for the signatures.

Lease and release

Lease and release through IPool<TPoolItem> exactly as you would a bounded pool:

var connection = await pool.LeaseAsync(cancellationToken);
try
{
    await connection.SendAsync(message);
}
finally
{
    pool.Release(connection);
}

A scoped lease reads the same and is still the recommended style — using donates the item back on scope exit:

using var lease = await pool.LeaseScopeAsync(cancellationToken);
await lease.Item.SendAsync(message, cancellationToken);

What differs is what the methods promise.

LeaseAsync never waits

A lease returns an item right away: an idle one from the pool, or a freshly created one when the pool is empty. There is no capacity gate, so a lease never blocks and never times out. UnboundedPoolOptions has no LeaseTimeout for that reason. The cancellationToken still cancels an already-canceled request up front and cancels preparation, so a PrepareAsync that hangs is still bounded by PreparationTimeout.

Release is optional

Releasing returns ownership of the item to the pool, which keeps it for reuse — but only up to MaxIdle. A return past that cap is dropped, and disposed when the item is IDisposable. Because the pool reuses only what you return, skipping a release costs reuse, not correctness: the item you keep is yours to use or dispose, as any object you own would be.

That ownership transfer is what makes optional release safe even for disposable items. Releasing remains good hygiene — it's how items get reused — so prefer a scoped lease and let using handle it.

What the pool disposes

The pool disposes only the idle items it still holds:

  • a returned item that overflows MaxIdle,
  • an item evicted for exceeding IdleTimeout, discarded the next time a lease meets it,
  • every idle item on Clear, before the pool refills to MinSize,
  • every idle item on Dispose.

Items out on lease belong to their callers. The pool can't reclaim them, so disposing them is the caller's responsibility — the same rule as the bounded pool, covered in Avoiding footguns.

Counters and metrics

The counters on IPool<TPoolItem> carry over, with two that read differently because a lease never queues:

  • QueuedLeases is always 0.
  • ItemsAllocated is ActiveLeases + ItemsAvailable, where ActiveLeases counts items leased and not yet returned — including any a caller dropped without releasing, because those items are still in use.

The pool publishes the same metrics instruments under the "MSL.Pool" meter, tagged with its own pool.name of the form "{typeof(TPoolItem).Name}.UnboundedPool". The pool.leases.queued instrument therefore reports 0 for an unbounded pool.

Next

Clone this wiki locally