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 : IResettable

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<Releaser> instances from an object pool when waiters are queued.
  • ValueTask-Based: Returns ValueTask<Releaser> 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.

Properties

Property Type Description
IsTaken bool Gets whether the lock is currently held by a caller or queued handoff.

Methods

LockAsync

public ValueTask<Releaser> 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<Releaser> 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.

LockAsync (timeout)

public ValueTask<Releaser> LockAsync(TimeSpan timeout, CancellationToken cancellationToken = default)

Asynchronously acquires the lock, or throws TimeoutException if the timeout elapses before the lock becomes available.

Parameters:

  • timeout — The maximum time to wait. Pass Timeout.InfiniteTimeSpan to wait indefinitely (delegates to LockAsync() without allocating a TimeProvider).

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

Throws:

  • TimeoutException — If the timeout elapses before the lock can be acquired.
  • OperationCanceledException — If the operation is canceled via the cancellation token.
  • ArgumentOutOfRangeException — If timeout is negative and not equal to Timeout.InfiniteTimeSpan.

Allocation notes:

Scenario TimeProvider allocated?
Lock immediately available No
Timeout.InfiniteTimeSpan No
TimeSpan.Zero and locked No (immediate exception)
Finite positive timeout Yes — one instance, disposed on await

Example:

try
{
    using (await _lock.LockAsync(TimeSpan.FromSeconds(2)))
    {
        await DoWorkAsync();
    }
}
catch (TimeoutException)
{
    // Could not acquire lock within 2 seconds
    HandleTimeout();
}

Allocation Behavior

Immediate acquisitions are completely allocation-free using atomic operations. When the lock is contended, waiting without a timeout is allocation-free on .NET 6.0+ (using UnsafeRegister for cancellation), while older frameworks may allocate for cancellation registration. Specifying a finite timeout allocates a timer that is automatically disposed when the operation completes. Exception and task allocations occur only if a timeout actually elapses or cancellation is triggered; successful acquisitions are otherwise allocation-free. Pooled IValueTaskSource<Releaser> instances are reused to minimize allocation pressure across repeated lock operations.

public bool TryReset()

Implements IResettable to allow returning this instance to a DefaultObjectPool<AsyncLock>.

Behavior:

  • Attempts to acquire the internal spin lock. If the lock is already held by a concurrent operation, the method returns false immediately and the pool discards the instance.
  • If the lock is acquired but the logical lock is currently held (IsTaken == true) or waiters are queued, the method returns false — the instance is still in active use and must not be recycled.
  • Otherwise the local waiter is reset and the method returns true.

Thread Safety: TryReset() is safe to call concurrently with other operations. It will simply return false if the instance is in use.

Example:

// Using AsyncLock with an object pool
var pool = new DefaultObjectPool<AsyncLock>(
    new DefaultPooledObjectPolicy<AsyncLock>());

var lk = pool.Get();
try
{
    using (await lk.LockAsync(ct))
    {
        // critical section
    }
}
finally
{
    pool.Return(lk); // calls TryReset() internally
}

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
  • ProtoPromiseAsyncLock: The implementation from the Proto.Promises.Threading 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
  • VS.Threading AsyncSemaphore: The Microsoft.VisualStudio.Threading semaphore used as a lock-compatible baseline

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. ProtoPromise is currently a strong uncontended competitor and can beat the pooled implementation on raw throughput, while the pooled implementation stays allocation-free and keeps the same API shape and cancellation behavior used throughout this library. VS.Threading is also included as a semaphore-based comparison point, but in the published uncontended results it trails both ProtoPromise and the pooled implementation. 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 · Increment · System 0.0057 ns 0.001 -
Lock · Interlocked.Add · System 0.1939 ns 0.030 -
Lock · Interlocked.Inc · System 0.1952 ns 0.030 -
Lock · Interlocked.Exchange · System 0.5068 ns 0.078 -
Lock · Interlocked.CmpX · System 0.8521 ns 0.132 -
Lock · Lock · System 3.1394 ns 0.485 -
Lock · Lock.EnterScope · System 3.1720 ns 0.490 -
SpinLock · SpinLock · CryptoHives 3.3182 ns 0.513 -
Lock · lock() · System 3.9953 ns 0.617 -
LockAsync · AsyncLock · Pooled 6.4720 ns 1.000 -
LockAsync · AsyncLock · ProtoPromise 7.3760 ns 1.140 -
LockAsync · AsyncSemaphore · VS.Threading 16.1348 ns 2.493 -
LockAsync · SemaphoreSlim · System 16.3141 ns 2.521 -
LockAsync · AsyncLock · RefImpl 17.8354 ns 2.756 -
LockAsync · AsyncLock · NonKeyed 19.9833 ns 3.088 -
LockAsync · AsyncLock · Nito.AsyncEx 39.0541 ns 6.034 320 B
SpinWait · SpinOnce · System 42.1704 ns 6.516 -
SpinLock · SpinLock · System 45.3120 ns 7.001 -
LockAsync · AsyncLock · NeoSmart 56.5801 ns 8.742 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 and ProtoPromise require memory allocations on contention, as long as the ValueTask is not converted to Task. ProtoPromise is particularly competitive here and can outperform the pooled AsyncLock in several low- and mid-contention cases, especially when comparing pure throughput. The pooled implementation still distinguishes itself by combining allocation-free ValueTask usage with built-in cancellation support and predictable behavior when integrated with the rest of this library. VS.Threading is included as another real-world baseline, but its semaphore-based path is slower and allocates under contention in the published results.

Description Iterations cancellationType Mean Ratio Allocated
Multiple · AsyncLock · Pooled (ValueTask) 0 None 9.524 ns 1.00 -
Multiple · AsyncLock · Pooled (Task) 0 None 11.026 ns 1.16 -
Multiple · AsyncLock · ProtoPromise 0 None 11.636 ns 1.22 -
Multiple · SemaphoreSlim · System 0 None 17.688 ns 1.86 -
Multiple · AsyncSemaphore · VS.Threading 0 None 18.970 ns 1.99 -
Multiple · AsyncLock · RefImpl 0 None 19.118 ns 2.01 -
Multiple · AsyncLock · NonKeyed 0 None 21.376 ns 2.24 -
Multiple · AsyncLock · Nito 0 None 44.416 ns 4.66 320 B
Multiple · AsyncLock · NeoSmart 0 None 58.186 ns 6.11 208 B
Multiple · AsyncLock · Pooled (ValueTask) 0 NotCancelled 9.580 ns 1.00 -
Multiple · AsyncLock · Pooled (Task) 0 NotCancelled 10.904 ns 1.14 -
Multiple · AsyncLock · ProtoPromise 0 NotCancelled 11.922 ns 1.24 -
Multiple · SemaphoreSlim · System 0 NotCancelled 17.639 ns 1.84 -
Multiple · AsyncSemaphore · VS.Threading 0 NotCancelled 19.830 ns 2.07 -
Multiple · AsyncLock · NonKeyed 0 NotCancelled 22.728 ns 2.37 -
Multiple · AsyncLock · Nito 0 NotCancelled 39.177 ns 4.09 320 B
Multiple · AsyncLock · NeoSmart 0 NotCancelled 58.758 ns 6.13 208 B
Multiple · AsyncLock · Pooled (ValueTask) 1 None 30.312 ns 1.00 -
Multiple · AsyncLock · ProtoPromise 1 None 38.128 ns 1.26 -
Multiple · SemaphoreSlim · System 1 None 43.053 ns 1.42 88 B
Multiple · AsyncSemaphore · VS.Threading 1 None 71.197 ns 2.35 168 B
Multiple · AsyncLock · RefImpl 1 None 76.606 ns 2.53 216 B
Multiple · AsyncLock · Nito 1 None 98.038 ns 3.23 728 B
Multiple · AsyncLock · NeoSmart 1 None 118.103 ns 3.90 416 B
Multiple · AsyncLock · Pooled (Task) 1 None 467.260 ns 15.42 272 B
Multiple · AsyncLock · NonKeyed 1 None 537.478 ns 17.73 352 B
Multiple · AsyncLock · Pooled (ValueTask) 1 NotCancelled 48.928 ns 1.00 -
Multiple · AsyncLock · ProtoPromise 1 NotCancelled 68.091 ns 1.39 -
Multiple · AsyncSemaphore · VS.Threading 1 NotCancelled 82.251 ns 1.68 168 B
Multiple · AsyncLock · NeoSmart 1 NotCancelled 121.842 ns 2.49 416 B
Multiple · AsyncLock · Nito 1 NotCancelled 381.676 ns 7.80 968 B
Multiple · AsyncLock · Pooled (Task) 1 NotCancelled 513.639 ns 10.50 272 B
Multiple · SemaphoreSlim · System 1 NotCancelled 597.392 ns 12.21 504 B
Multiple · AsyncLock · NonKeyed 1 NotCancelled 696.758 ns 14.24 640 B
Multiple · AsyncLock · ProtoPromise 10 None 270.858 ns 0.81 -
Multiple · SemaphoreSlim · System 10 None 283.837 ns 0.85 880 B
Multiple · AsyncLock · Pooled (ValueTask) 10 None 335.026 ns 1.00 -
Multiple · AsyncSemaphore · VS.Threading 10 None 526.298 ns 1.57 1680 B
Multiple · AsyncLock · Nito 10 None 553.332 ns 1.65 4400 B
Multiple · AsyncLock · NeoSmart 10 None 635.329 ns 1.90 2288 B
Multiple · AsyncLock · RefImpl 10 None 654.051 ns 1.95 2160 B
Multiple · AsyncLock · Pooled (Task) 10 None 3,158.086 ns 9.43 1352 B
Multiple · AsyncLock · NonKeyed 10 None 3,455.383 ns 10.31 2296 B
Multiple · AsyncLock · ProtoPromise 10 NotCancelled 496.349 ns 0.80 -
Multiple · AsyncLock · Pooled (ValueTask) 10 NotCancelled 623.256 ns 1.00 -
Multiple · AsyncLock · NeoSmart 10 NotCancelled 642.343 ns 1.03 2288 B
Multiple · AsyncSemaphore · VS.Threading 10 NotCancelled 734.316 ns 1.18 1680 B
Multiple · AsyncLock · Nito 10 NotCancelled 3,224.239 ns 5.17 6800 B
Multiple · AsyncLock · Pooled (Task) 10 NotCancelled 3,366.491 ns 5.40 1352 B
Multiple · SemaphoreSlim · System 10 NotCancelled 4,357.466 ns 6.99 3888 B
Multiple · AsyncLock · NonKeyed 10 NotCancelled 5,054.288 ns 8.11 5176 B
Multiple · SemaphoreSlim · System 100 None 2,538.924 ns 0.80 8800 B
Multiple · AsyncLock · ProtoPromise 100 None 2,616.672 ns 0.82 -
Multiple · AsyncLock · Pooled (ValueTask) 100 None 3,179.786 ns 1.00 -
Multiple · AsyncSemaphore · VS.Threading 100 None 4,894.898 ns 1.54 21120 B
Multiple · AsyncLock · Nito 100 None 5,325.309 ns 1.67 41120 B
Multiple · AsyncLock · NeoSmart 100 None 5,895.268 ns 1.85 21008 B
Multiple · AsyncLock · RefImpl 100 None 6,318.729 ns 1.99 21600 B
Multiple · AsyncLock · Pooled (Task) 100 None 33,600.780 ns 10.57 12216 B
Multiple · AsyncLock · NonKeyed 100 None 35,603.366 ns 11.20 21800 B
Multiple · AsyncLock · ProtoPromise 100 NotCancelled 4,689.983 ns 0.87 -
Multiple · AsyncLock · Pooled (ValueTask) 100 NotCancelled 5,360.730 ns 1.00 -
Multiple · AsyncLock · NeoSmart 100 NotCancelled 5,968.076 ns 1.11 21008 B
Multiple · AsyncSemaphore · VS.Threading 100 NotCancelled 7,014.726 ns 1.31 21120 B
Multiple · AsyncLock · Nito 100 NotCancelled 31,183.481 ns 5.82 65120 B
Multiple · AsyncLock · Pooled (Task) 100 NotCancelled 35,534.963 ns 6.63 12216 B
Multiple · SemaphoreSlim · System 100 NotCancelled 44,303.836 ns 8.27 37792 B
Multiple · AsyncLock · NonKeyed 100 NotCancelled 54,064.472 ns 10.09 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. ProtoPromise can outperform the pooled implementation in several published throughput measurements, while SemaphoreSlim is also competitive in some cases 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.

DO: Use LockAsync(TimeSpan) to bound wait time

try
{
    using (await _lock.LockAsync(TimeSpan.FromSeconds(5)))
    {
        _data = await FetchAsync();
    }
}
catch (TimeoutException)
{
    HandleTimeout();
}

DON'T: Create new locks repeatedly

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

public async Task Work(AsyncLock lock)
{
    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