Table of Contents

AsyncLock Class

A pooled async mutual exclusion lock for coordinating access to shared resources.

Namespace

CryptoHives.Foundation.Threading.Async.Pooled

Syntax

public sealed class AsyncLock

Overview

AsyncLock provides async mutual exclusion, similar to SemaphoreSlim(1,1) but optimized for the common async locking pattern. It returns a small value-type releaser that implements IDisposable/IAsyncDisposable so the lock can be released with a using pattern. The implementation uses pooled IValueTaskSource instances to minimize allocations in high-throughput scenarios and a local reusable waiter to avoid allocations for the first queued waiter.

Benefits

  • Zero-allocation fast path: When the lock is uncontended the operation completes synchronously without heap allocations.
  • Pooled Task Sources: Reuses IValueTaskSource<AsyncLockReleaser> instances from an object pool when waiters are queued.
  • ValueTask-Based: Returns ValueTask<AsyncLockReleaser> for minimal allocation when the lock is available.
  • RAII Pattern: Uses disposable lock handles for automatic release.
  • Cancellation Support (optimized): Supports CancellationToken for queued waiters; on .NET 6+ registration uses UnsafeRegister with a static delegate to reduce execution-context capture and per-registration overhead.
  • High Performance: Optimized for both uncontended and contended scenarios while keeping allocations low.

Constructor

public AsyncLock(
    IGetPooledManualResetValueTaskSource<Releaser>? pool = null)
Parameter Description
pool Optional custom pool for ValueTaskSource instances.

Note: Unlike other primitives in this library, AsyncLock always runs continuations asynchronously (hardcoded to true). This prevents potential deadlocks in common lock usage patterns.

Methods

LockAsync

public ValueTask<AsyncLockReleaser> LockAsync(CancellationToken cancellationToken = default)

Asynchronously acquires the lock. Returns a disposable that releases the lock when disposed.

Parameters:

  • cancellationToken - Optional cancellation token; only observed if the lock cannot be acquired immediately.

Returns: A ValueTask<AsyncLockReleaser> that completes when the lock is acquired. Dispose the result to release the lock.

Notes on allocations and cancellation:

  • The fast path (uncontended) completes synchronously and performs no heap allocations.
  • The implementation maintains a local waiter instance that serves the first queued waiter without allocating. Subsequent waiters use instances obtained from the configured object pool; if the pool is exhausted a new instance is allocated.
  • Passing a CancellationToken will register a callback when the waiter is queued. On .NET 6+ the code uses UnsafeRegister together with a static delegate and a small struct context to minimize capture and reduce allocation/ExecutionContext overhead. Even so, cancellation registrations and creating Task objects for pre-cancelled tokens may allocate; prefer avoiding cancellation tokens unless necessary for the scenario.

Throws:

  • OperationCanceledException - If the operation is canceled via the cancellation token.

Thread Safety

Thread-safe. All public methods are thread-safe and can be called concurrently from multiple threads.

Performance Characteristics

  • Uncontended Lock: O(1), synchronous completion (no allocation)
  • Contended Lock: O(1) to enqueue waiter; waiter instances are reused from the object pool (allocation only if pool is exhausted)
  • Lock Release: O(1) to signal next waiter
  • Memory: Minimal allocations due to pooled task sources and local waiter reuse

Benchmark Results

The benchmarks compare various AsyncLock implementations:

  • PooledAsyncLock: The pooled implementation from this library
  • RefImplAsyncLock: The reference implementation from Stephen Toub's blog, which does not support cancellation tokens
  • NitoAsyncLock: The implementation from the Nito.AsyncEx library
  • NeoSmartAsyncLock: The implementation from the NeoSmart.AsyncLock library
  • AsyncNonKeyedLocker: An implementation from the AsyncKeyedLock.AsyncNonKeyedLocker library which uses SemaphoreSlim internally
  • SemaphoreSlim: The .NET built-in synchronization primitive

Single Lock Benchmark

This benchmark measures the performance of acquiring and releasing a single lock in an uncontended scenario. In order to understand the impact of moving from a lock or Interlocked implementation to an async lock, the InterlockedIncrement, lock and .NET 9 Lock with EnterScope() are also measured with a integer increment as workload. The benchmark shows both throughput (operations per second) and allocations per operation. The new .NET 9 Lock primitive shows slighlty better performance than the well known lock on an object, but AsyncLock remains competitive due to the fast path implementation with Interlocked variable based state.

Description Mean Ratio Allocated
Lock · Baseline · Increment 0.0019 ns 0.000 -
Lock · Interlocked · Interlocked 0.1777 ns 0.014 -
Lock · Lock · Lock.EnterScope 3.1339 ns 0.254 -
Lock · Lock · System.Lock 3.2885 ns 0.267 -
Lock · Monitor · Monitor 3.8157 ns 0.310 -
LockAsync · AsyncLock · Pooled 12.3146 ns 1.000 -
LockAsync · SemaphoreSlim · SemaphoreSlim 17.4659 ns 1.418 -
LockAsync · AsyncLock · RefImpl 18.7052 ns 1.519 -
LockAsync · AsyncLock · NonKeyed 21.2222 ns 1.723 -
LockAsync · AsyncLock · Nito.AsyncEx 36.8141 ns 2.990 320 B
LockAsync · AsyncLock · NeoSmart 56.8822 ns 4.619 208 B

Multiple Concurrent Lock Benchmark

This benchmark measures performance under contention with multiple concurrent lock requests (iterations). The benchmark shows both throughput (operations per second) and allocations per operation. Zero iterations duplicates the uncontended scenario. It is noticable that all implementations except the pooled one require memory allocations on contention, as long as the ValueTask is not converted to Task. The only implementation that slightly outperforms the pooled AsyncLock with a default cancellation token is the SemaphoreSlim, but at the cost of memory allocations on every lock acquisition.

Description Iterations cancellationType Mean Ratio Allocated
Multiple · AsyncLock · Pooled (ValueTask) 0 None 13.16 ns 1.00 -
Multiple · AsyncLock · Pooled (Task) 0 None 13.63 ns 1.04 -
Multiple · SemaphoreSlim · SemaphoreSlim 0 None 19.24 ns 1.46 -
Multiple · AsyncLock · RefImpl 0 None 19.70 ns 1.50 -
Multiple · AsyncLock · NonKeyed 0 None 22.70 ns 1.72 -
Multiple · AsyncLock · Nito 0 None 38.33 ns 2.91 320 B
Multiple · AsyncLock · NeoSmart 0 None 58.19 ns 4.42 208 B
Multiple · AsyncLock · Pooled (ValueTask) 0 NotCancelled 12.80 ns 1.00 -
Multiple · AsyncLock · Pooled (Task) 0 NotCancelled 13.94 ns 1.09 -
Multiple · SemaphoreSlim · SemaphoreSlim 0 NotCancelled 19.73 ns 1.54 -
Multiple · AsyncLock · NonKeyed 0 NotCancelled 22.40 ns 1.75 -
Multiple · AsyncLock · Nito 0 NotCancelled 37.55 ns 2.93 320 B
Multiple · AsyncLock · NeoSmart 0 NotCancelled 57.40 ns 4.48 208 B
Multiple · AsyncLock · Pooled (ValueTask) 1 None 38.73 ns 1.00 -
Multiple · SemaphoreSlim · SemaphoreSlim 1 None 41.75 ns 1.08 88 B
Multiple · AsyncLock · RefImpl 1 None 75.87 ns 1.96 216 B
Multiple · AsyncLock · Nito 1 None 95.66 ns 2.47 728 B
Multiple · AsyncLock · NeoSmart 1 None 133.56 ns 3.45 416 B
Multiple · AsyncLock · Pooled (Task) 1 None 489.32 ns 12.63 272 B
Multiple · AsyncLock · NonKeyed 1 None 501.05 ns 12.94 352 B
Multiple · AsyncLock · Pooled (ValueTask) 1 NotCancelled 55.37 ns 1.00 -
Multiple · AsyncLock · NeoSmart 1 NotCancelled 115.52 ns 2.09 416 B
Multiple · AsyncLock · Nito 1 NotCancelled 382.16 ns 6.90 968 B
Multiple · AsyncLock · Pooled (Task) 1 NotCancelled 534.78 ns 9.66 272 B
Multiple · SemaphoreSlim · SemaphoreSlim 1 NotCancelled 585.39 ns 10.57 504 B
Multiple · AsyncLock · NonKeyed 1 NotCancelled 651.48 ns 11.77 640 B
Multiple · SemaphoreSlim · SemaphoreSlim 10 None 260.69 ns 0.78 880 B
Multiple · AsyncLock · Pooled (ValueTask) 10 None 333.56 ns 1.00 -
Multiple · AsyncLock · Nito 10 None 548.46 ns 1.64 4400 B
Multiple · AsyncLock · RefImpl 10 None 631.03 ns 1.89 2160 B
Multiple · AsyncLock · NeoSmart 10 None 640.84 ns 1.92 2288 B
Multiple · AsyncLock · Pooled (Task) 10 None 3,192.44 ns 9.57 1352 B
Multiple · AsyncLock · NonKeyed 10 None 3,469.19 ns 10.40 2296 B
Multiple · AsyncLock · Pooled (ValueTask) 10 NotCancelled 516.65 ns 1.00 -
Multiple · AsyncLock · NeoSmart 10 NotCancelled 642.69 ns 1.24 2288 B
Multiple · AsyncLock · Nito 10 NotCancelled 3,124.91 ns 6.05 6800 B
Multiple · AsyncLock · Pooled (Task) 10 NotCancelled 3,362.27 ns 6.51 1352 B
Multiple · SemaphoreSlim · SemaphoreSlim 10 NotCancelled 4,295.86 ns 8.32 3888 B
Multiple · AsyncLock · NonKeyed 10 NotCancelled 5,261.54 ns 10.18 5176 B
Multiple · SemaphoreSlim · SemaphoreSlim 100 None 2,502.49 ns 0.79 8800 B
Multiple · AsyncLock · Pooled (ValueTask) 100 None 3,170.11 ns 1.00 -
Multiple · AsyncLock · Nito 100 None 5,090.40 ns 1.61 41120 B
Multiple · AsyncLock · NeoSmart 100 None 5,878.93 ns 1.85 21008 B
Multiple · AsyncLock · RefImpl 100 None 5,981.69 ns 1.89 21600 B
Multiple · AsyncLock · Pooled (Task) 100 None 32,406.42 ns 10.22 12216 B
Multiple · AsyncLock · NonKeyed 100 None 35,616.83 ns 11.24 21800 B
Multiple · AsyncLock · Pooled (ValueTask) 100 NotCancelled 4,933.30 ns 1.00 -
Multiple · AsyncLock · NeoSmart 100 NotCancelled 5,942.03 ns 1.20 21008 B
Multiple · AsyncLock · Pooled (Task) 100 NotCancelled 32,638.29 ns 6.62 12216 B
Multiple · AsyncLock · Nito 100 NotCancelled 32,766.09 ns 6.64 65120 B
Multiple · SemaphoreSlim · SemaphoreSlim 100 NotCancelled 43,292.92 ns 8.78 37792 B
Multiple · AsyncLock · NonKeyed 100 NotCancelled 49,933.40 ns 10.12 50600 B

Benchmark Analysis

Key Findings:

  1. Uncontended Performance: AsyncLock performs comparably to or better than SemaphoreSlim in uncontended scenarios due to the optimized fast path that avoids allocations entirely.

  2. Memory Efficiency: The pooled IValueTaskSource approach significantly reduces allocations compared to TaskCompletionSource-based implementations. This is especially beneficial in high-throughput scenarios.

  3. Contended Scenarios: Under contention, the local waiter optimization ensures the first queued waiter incurs no allocation, while subsequent waiters benefit from pool reuse. Only SemaphoreSlim slightly outperforms in throughput with a non cancellable token but always at the cost of allocations.

  4. ValueTask Advantage: Returning ValueTask<Releaser> instead of Task allows always allocation free completion.

When to Choose AsyncLock:

  • High-throughput scenarios where lock acquisition is frequent
  • Memory-sensitive applications where allocation pressure matters
  • Scenarios where locks are typically contended or allocation free cancellation support is needed

Best Practices

DO: Use the using pattern to ensure lock release and await the result directly

// Good: Minimal time holding lock
public async Task UpdateAsync(Data newData)
{
    // Prepare outside lock
    var processed = await PrepareDataAsync(newData);

    // using ensures lock is released
    using (await _lock.LockAsync())
    {
        _data = processed;
    }
}

DO: Keep critical sections short

// Good: Minimal time holding lock
using (await _lock.LockAsync())
{
    _data = processed;
}

DO: Prefer avoiding CancellationToken for hot-path locks

Cancellation registrations allocate a small control structure. For hot-path code, omit the token when possible, or perform an early cancellationToken.IsCancellationRequested check before calling LockAsync to avoid allocations from Task.FromCanceled.

DO: Configure a larger pool under high contention

If you expect many concurrent waiters, provide a custom object pool with a larger retention size so allocations are avoided when the pool can satisfy requests.

DON'T: Create new locks repeatedly

// Bad: Creating new lock each time
public async Task OperationAsync()
{
    var lock = new AsyncLock(); // Don't do this!
    using (await lock.LockAsync())
    {
        // Work...
    }
}

DON'T: Hold the lock during long-running operations

// Bad: Holding lock during slow operation
using (await _lock.LockAsync())
{
    await SlowDatabaseQueryAsync(); // Don't hold lock!
}

DON'T: Nest locks (may deadlock)

// Bad: Risk of deadlock
using (await _lock1.LockAsync())
{
    using (await _lock2.LockAsync()) // Deadlock risk!
    {
        // Work...
    }
}

See Also


© 2026 The Keepers of the CryptoHives