MSL.Pool
7.0.0
See the version list below for details.
dotnet add package MSL.Pool --version 7.0.0
NuGet\Install-Package MSL.Pool -Version 7.0.0
<PackageReference Include="MSL.Pool" Version="7.0.0" />
<PackageVersion Include="MSL.Pool" Version="7.0.0" />
<PackageReference Include="MSL.Pool" />
paket add MSL.Pool --version 7.0.0
#r "nuget: MSL.Pool, 7.0.0"
#:package MSL.Pool@7.0.0
#addin nuget:?package=MSL.Pool&version=7.0.0
#tool nuget:?package=MSL.Pool&version=7.0.0

Pool
Another weapon from the MSL Armory
IPool<TPoolItem> is a thread-safe object pool for expensive-to-create, reusable instances — connections, clients, sockets. It hands items out under a lease, takes them back on release, and caps how many exist at once.
Table of contents
- Pool
When to reach for Pool
Reach for Pool when an object is expensive to create and safe to reuse, and you want a hard ceiling on how many exist at once:
- Connection pools — SMTP, database, or any long-lived network connection
- Clients that hold a socket or session and cost real time to construct
- Bounded concurrency —
MaxSizecaps concurrent leases, so the pool doubles as a throttle - Items that idle out — a connection the server drops after inactivity, re-checked on each lease
Skip Pool when the object is cheap to construct — allocate it directly. For stateless reset-and-reuse objects with no async readiness step, Microsoft.Extensions.ObjectPool is lighter. For HttpClient, use IHttpClientFactory.
Installation
dotnet add package MSL.Pool
Pool targets .NET 10.
Hello, World
Register a pool, then lease and release items through IPool<TPoolItem>:
using Pool;
services.AddTransient<SmtpConnection>(); // the default factory resolves items from DI
services.AddPool<SmtpConnection>(configuration, options =>
{
options.MinSize = 2; // pre-create two on startup
options.MaxSize = 10; // never more than ten at once
options.UseDefaultFactory = true; // construct items from the service provider
});
Inject the pool and lease under a try/finally:
public sealed class Mailer(IPool<SmtpConnection> pool)
{
public async Task SendAsync(Message message)
{
var connection = await pool.LeaseAsync();
try
{
await connection.SendAsync(message);
}
finally
{
pool.Release(connection); // always release, exactly once
}
}
}
That's the whole contract: LeaseAsync to borrow, Release to return. The finally returns the item even when the work throws.
How leasing works
A pool holds a queue of idle items and a capacity gate — a SemaphoreSlim with one permit per unit of MaxSize. A lease takes a permit; a release returns one. Permits held equals items leased, so the leased count never exceeds MaxSize.
Every lease runs the same path:
LeaseAsync
│
├─ permit free? ──no──► wait for a release (up to LeaseTimeout) ──elapsed──► TaskCanceledException
│ │ yes
▼ ▼
take a permit
│
├─ idle item queued? ──yes──► reuse it (discard first if past IdleTimeout)
│ ──no───► create one with the item factory
▼
prepare the item (readiness check, if a strategy is registered)
│
├─ ready ─────► hand it to the caller
└─ not ready ─► prepare; on failure dispose the item, release the permit, throw
When every permit is held, further leases wait in the semaphore's own queue until an item is released or LeaseTimeout elapses. That wait queue is the backlog — there's no separate waiter list to fall out of sync.
Leasing and releasing
IPool<TPoolItem> is the surface you inject — three methods and a few counters:
LeaseAsync()/LeaseAsync(CancellationToken)— borrow an item as aValueTask<TPoolItem>. Reuses an idle item, creates one if the pool is belowMaxSize, or waits for a release.Release(item)— return a leased item. Synchronous andvoid. If a lease is waiting, the item goes to it; otherwise it becomes idle.Clear()— dispose every idle item and refill toMinSize. Leased items are untouched and re-enter on release.
Counters for diagnostics and metrics: ItemsAllocated, ItemsAvailable, ActiveLeases, QueuedLeases, and Name.
Release is synchronous by design — returning an item is a queue enqueue and a permit release, neither of which awaits. Wrap every lease in try/finally:
var item = await pool.LeaseAsync(cancellationToken);
try
{
// use the item
}
finally
{
pool.Release(item);
}
LeaseAsync(cancellationToken) cancels the wait. A canceled caller token throws OperationCanceledException; a lease that exceeds LeaseTimeout throws TaskCanceledException.
Footguns
The lease/release contract is a balanced pair, and the pool trusts you to honor it. Like ArrayPool<T>.Return, it doesn't police misuse — these are the sharp edges.
Release exactly once
The pool tracks no ownership. Releasing the same item twice — or releasing an item the pool never handed you — enqueues a duplicate, after which two callers can lease the same instance and corrupt each other's work. A double release also over-counts the capacity gate, letting the pool create past MaxSize, or throws SemaphoreFullException when the gate is already full. Release each leased item once, from a finally.
Never forget to release
A lease that never returns holds its permit for the life of the pool. Leak MaxSize permits and the pool saturates: every later lease waits out the full LeaseTimeout and then throws. The try/finally is not optional.
Dispose reclaims idle items only
Disposing the pool disposes the items sitting idle in it. Items leased out at that moment belong to the caller — the pool can't reclaim them, so dispose them yourself. Releasing an item to a disposed pool throws ObjectDisposedException, as does leasing from one.
Preparation failure discards the item
When a registered preparation strategy throws, the pool disposes that item rather than recirculate a broken one, releases the permit, and rethrows. Catch the exception at the call site and retry the lease for a fresh item.
Configuration
PoolOptions sets sizing, timeouts, and which defaults to register. Bind it from the "PoolOptions" configuration section, pass an Action<PoolOptions>, or both — the action runs after binding:
services.AddPool<SmtpConnection>(configuration, options =>
{
options.MinSize = 2;
options.MaxSize = 10;
options.LeaseTimeout = TimeSpan.FromSeconds(30);
});
Options and their defaults:
MinSize— items created up front, and the floorClear()refills to. Defaults to0.MaxSize— hard cap on items in existence, and therefore on concurrent leases. At least1. Defaults to100.LeaseTimeout— how long a lease waits for an item before throwingTaskCanceledException. Defaults toTimeout.InfiniteTimeSpan(wait forever).PreparationTimeout— how long the readiness check and preparation may take. Defaults toTimeout.InfiniteTimeSpan.IdleTimeout— how long an item may sit idle before the next lease discards and disposes it instead of reusing it. Eviction is lazy: it happens when a lease meets the stale item, not on a background timer. Defaults toTimeout.InfiniteTimeSpan(never expire).UseDefaultFactory— register the default item factory, which resolvesTPoolItemfrom the service provider. Defaults tofalse.UseDefaultPreparationStrategy— register the default preparation strategy, which reports every item ready. Defaults tofalse.
Item factory
The pool creates new items through IItemFactory<TPoolItem>:
public interface IItemFactory<TPoolItem>
where TPoolItem : class
{
TPoolItem CreateItem();
}
Supply one two ways:
- Set
UseDefaultFactory = trueto use the built-in factory. It resolvesTPoolItemfrom the service provider, so register the item type as well:services.AddTransient<SmtpConnection>(). - Implement
IItemFactory<TPoolItem>and register it withAddPoolItemFactory<TPoolItem, TFactory>()for construction the container can't express — credentials, endpoints, handcrafted state.
Preparation strategy
A pooled item can go stale between leases — an SMTP server drops an idle connection, a token expires. IPreparationStrategy<TPoolItem> checks an item on lease and restores it when needed:
public interface IPreparationStrategy<TPoolItem>
where TPoolItem : class
{
ValueTask<bool> IsReadyAsync(TPoolItem item, CancellationToken cancellationToken);
Task PrepareAsync(TPoolItem item, CancellationToken cancellationToken);
}
On each lease the pool calls IsReadyAsync; if it returns false, the pool calls PrepareAsync before handing the item over, bounded by PreparationTimeout. Register a strategy with AddPreparationStrategy<TPoolItem, TStrategy>(), or set UseDefaultPreparationStrategy = true for the built-in one that reports every item ready.
An SMTP readiness check and preparation over MailKit.IMailTransport:
public async ValueTask<bool> IsReadyAsync(IMailTransport item, CancellationToken cancellationToken) =>
item.IsConnected
&& item.IsAuthenticated
&& await NoOpAsync(item, cancellationToken);
public async Task PrepareAsync(IMailTransport item, CancellationToken cancellationToken)
{
await item.ConnectAsync(host.Name, host.Port, host.UseSsl, cancellationToken);
await item.AuthenticateAsync(credentials.UserName, credentials.Password, cancellationToken);
}
If PrepareAsync throws, the pool disposes the item and rethrows — retry the lease for a fresh one.
Dependency injection
Every registration extension lives in the Pool namespace and registers singletons:
AddPool<TPoolItem>(configuration, configureOptions)— the pool, its options, and metrics. Also registers the default factory and preparation strategy whenUseDefaultFactory/UseDefaultPreparationStrategyare set.AddPoolItemFactory<TPoolItem, TFactory>()— a customIItemFactory<TPoolItem>.AddPreparationStrategy<TPoolItem, TStrategy>()— a customIPreparationStrategy<TPoolItem>.AddDefaultPoolMetrics<TPoolItem>()— the default metrics, when you build a pool withoutAddPool.
A pool with a custom factory and strategy:
services
.AddPoolItemFactory<SmtpConnection, SmtpConnectionFactory>()
.AddPreparationStrategy<SmtpConnection, SmtpConnectionPreparationStrategy>()
.AddPool<SmtpConnection>(configuration, options =>
{
options.MinSize = 2;
options.MaxSize = 10;
});
Named pools
Run several independently configured pools for one item type — a small read pool and a large write pool of the same connection. Register the pool factory once, then a named pool per configuration:
services.AddPoolFactory<DbConnection>();
services.AddNamedPool<DbConnection>("ReadPool", configuration, options =>
{
options.MinSize = 5;
options.MaxSize = 20;
});
services.AddNamedPool<DbConnection>("WritePool", configuration, options =>
{
options.MinSize = 2;
options.MaxSize = 10;
});
A named pool's key is "{name}.{typeof(TPoolItem).Name}.Pool". Build it with ServiceKey.Create<TPoolItem>(name) rather than hand-formatting, then resolve through IPoolFactory<TPoolItem>:
public sealed class Repository(IPoolFactory<DbConnection> pools)
{
private readonly IPool<DbConnection> readPool =
pools.CreatePool(ServiceKey.Create<DbConnection>("ReadPool"));
private readonly IPool<DbConnection> writePool =
pools.CreatePool(ServiceKey.Create<DbConnection>("WritePool"));
}
Each named pool also reads an optional per-pool section, "{key}_PoolOptions", layered over the shared "PoolOptions" section.
To bind a pool to a dedicated client type, register the client and its pool together. AddPool<TPoolItem, TClient> keys the pool by the client type and injects the IPool<TPoolItem> into the client's constructor:
services.AddPool<DbConnection, ReadClient>(configuration,
options =>
{
options.MinSize = 5;
options.MaxSize = 20;
},
client =>
{
// configure the resolved client
});
Metrics
AddPool wires up IPoolMetrics, implemented by DefaultPoolMetrics over System.Diagnostics.Metrics. Each pool publishes under a meter named Pool<TPoolItem>.PoolName — "{typeof(TPoolItem).Name}.Pool". The instruments:
lease_exception,preparation_exception— counters of failed leases and failed preparationslease_wait_time,item_preparation_time— histograms in millisecondsitems_allocated,items_available,active_leases,queued_leases— observable gauges of pool stateutilization_rate— active leases over allocated items
Collect them with any System.Diagnostics.Metrics listener — OpenTelemetry, dotnet-counters, a custom exporter — by adding the meter:
services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter(Pool<MyPoolItem>.PoolName)
.AddPrometheusExporter());
A named pool prefixes its meter with the pool name — "{name}.{typeof(TPoolItem).Name}.Pool" — so each instance reports separately:
.AddMeter($"ReadPool.{Pool<DbConnection>.PoolName}")
.AddMeter($"WritePool.{Pool<DbConnection>.PoolName}")
To replace the default, implement IPoolMetrics and register it before AddPool resolves its own:
services.AddSingleton<IPoolMetrics, MyPoolMetrics>();
FAQ
Why is Release synchronous when LeaseAsync is async?
Returning an item enqueues it and releases a permit — no I/O, nothing to await. Leasing may wait for a free permit or run an async readiness check, so it's the async half.
What happens if I forget to release?
The permit stays held for the life of the pool. Leak MaxSize of them and every later lease waits out LeaseTimeout and throws. Always release from a finally.
Can the pool exceed MaxSize?
Not under correct use — the capacity gate guarantees it. A double release breaks that guarantee by over-counting the gate; see Footguns.
How do idle items get cleaned up?
Lazily. An item past IdleTimeout is disposed the next time a lease meets it in the queue, not on a background timer. Clear() disposes all idle items on demand.
Is the pool thread-safe?
Yes. Concurrent leases and releases are safe — the idle queue is a ConcurrentQueue and capacity is a SemaphoreSlim.
Which exception signals a lease timeout?
TaskCanceledException for an elapsed LeaseTimeout; OperationCanceledException for a canceled caller token.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.8)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Diagnostics (>= 10.0.8)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Options.DataAnnotations (>= 10.0.8)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 7.1.0 | 1,552 | 6/15/2026 |
| 7.0.0 | 5,673 | 6/9/2026 |
| 6.1.0 | 5,696 | 6/9/2026 |
| 6.0.0 | 38,881 | 9/22/2025 |
| 5.0.1 | 38,883 | 3/16/2025 |
| 5.0.0 | 38,860 | 3/16/2025 |
| 4.2.1 | 38,923 | 3/6/2025 |
| 4.2.0 | 38,888 | 3/4/2025 |
| 4.1.0 | 38,914 | 3/4/2025 |
| 4.0.2 | 38,816 | 10/15/2024 |
| 4.0.1 | 38,828 | 7/18/2024 |
| 4.0.0 | 38,780 | 7/18/2024 |
| 3.0.2 | 38,800 | 7/17/2024 |
| 3.0.1 | 38,799 | 7/17/2024 |
| 3.0.0 | 38,798 | 7/17/2024 |
| 2.0.0 | 38,806 | 5/18/2024 |
| 1.1.1 | 38,839 | 5/18/2024 |
| 1.1.0 | 38,834 | 5/16/2024 |
| 1.0.5 | 38,812 | 5/16/2024 |
| 1.0.4 | 38,823 | 5/7/2024 |