MSL.Pool 7.1.0

dotnet add package MSL.Pool --version 7.1.0
                    
NuGet\Install-Package MSL.Pool -Version 7.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MSL.Pool" Version="7.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MSL.Pool" Version="7.1.0" />
                    
Directory.Packages.props
<PackageReference Include="MSL.Pool" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MSL.Pool --version 7.1.0
                    
#r "nuget: MSL.Pool, 7.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MSL.Pool@7.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MSL.Pool&version=7.1.0
                    
Install as a Cake Addin
#tool nuget:?package=MSL.Pool&version=7.1.0
                    
Install as a Cake Tool

.NET Tests .NET Publish NuGet .NET

Pool MSL Armory

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

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 — MaxSize caps 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 a ValueTask<TPoolItem>. Reuses an idle item, creates one if the pool is below MaxSize, or waits for a release.
  • Release(item) — return a leased item. Synchronous and void. If a lease is waiting, the item goes to it; otherwise it becomes idle.
  • Clear() — dispose every idle item and refill to MinSize. 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.

Scoped leases

LeaseScopeAsync wraps the lease in a disposable Lease<TPoolItem>, so a using returns the item on scope exit — no try/finally, and a double dispose is a safe no-op:

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

This is the recommended way to lease: it turns "forgot to release" — which no analyzer can see — into "forgot to dispose," which using and the IDisposable analyzers already guard against. It also makes the release idempotent, closing the double-release footgun below. The raw LeaseAsync/Release pair stays available for advanced cases. A Lease that is garbage-collected without being disposed is counted on the pool.leases.leaked counter, so leaks are observable even when they slip through.

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 — or use a scoped lease so the return is automatic.

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 floor Clear() refills to. Defaults to 0.
  • MaxSize — hard cap on items in existence, and therefore on concurrent leases. At least 1. Defaults to 100.
  • LeaseTimeout — how long a lease waits for an item before throwing TaskCanceledException. Defaults to Timeout.InfiniteTimeSpan (wait forever).
  • PreparationTimeout — how long the readiness check and preparation may take. Defaults to Timeout.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 to Timeout.InfiniteTimeSpan (never expire).
  • UseDefaultFactory — register the default item factory, which resolves TPoolItem from the service provider. Defaults to false.
  • UseDefaultPreparationStrategy — register the default preparation strategy, which reports every item ready. Defaults to false.

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 = true to use the built-in factory. It resolves TPoolItem from the service provider, so register the item type as well: services.AddTransient<SmtpConnection>().
  • Implement IItemFactory<TPoolItem> and register it with AddPoolItemFactory<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 when UseDefaultFactory / UseDefaultPreparationStrategy are set.
  • AddPoolItemFactory<TPoolItem, TFactory>() — a custom IItemFactory<TPoolItem>.
  • AddPreparationStrategy<TPoolItem, TStrategy>() — a custom IPreparationStrategy<TPoolItem>.
  • AddDefaultPoolMetrics<TPoolItem>() — the default metrics, when you build a pool without AddPool.

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

Upgrading from v7? The meter, instrument names, and tags changed in v8. See UPGRADING for the before/after mapping and migration steps.

AddPool wires up IPoolMetrics, implemented by DefaultPoolMetrics over System.Diagnostics.Metrics — the API OpenTelemetry consumes directly. Every pool publishes under one stable meter, PoolMeter.Name ("MSL.Pool"), and carries its identity as a pool.name tag rather than in the instrument name. The instruments:

  • pool.lease.exceptions, pool.preparation.exceptions — counters of failed leases and failed preparations, tagged error.type with the exception type
  • pool.lease.wait.duration, pool.item.preparation.duration — histograms in seconds (OTEL convention), with bucket boundaries tuned for sub-millisecond-to-seconds pool latencies
  • pool.items.allocated, pool.items.available, pool.leases.active, pool.leases.queued — observable up/down counters of pool state
  • pool.utilization — observable gauge of active leases over allocated items
  • pool.leases.leaked — counter of leases garbage-collected without being returned (see Scoped leases)

Every measurement carries a pool.name tag, so metrics aggregate across pools and you slice by pool as a dimension. Collect them with any System.Diagnostics.Metrics listener — OpenTelemetry, dotnet-counters, a custom exporter — with a single AddMeter:

services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics
        .AddMeter(PoolMeter.Name)
        .AddPrometheusExporter());

That one subscription captures every pool in the process, named or not. Distinguish instances by the pool.name tag — for example, ReadPool and WritePool over DbConnection both report under MSL.Pool, separated by their tag values rather than by separate meters.

The library takes no dependency on the OpenTelemetry SDK; it only exposes the meter via System.Diagnostics.Metrics, leaving the choice of exporter to the host app.

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
Loading failed