Table of Contents

Class AsyncKeyedLock<TKey>

Namespace
CryptoHives.Foundation.Threading.Async.Pooled
Assembly
CryptoHives.Foundation.Threading.dll

An allocation-light async-compatible per-key exclusive lock implemented with pooled ValueTask sources. Operations on different keys run fully concurrently; operations on the same key are serialized. Note that this lock is not recursive!

public sealed class AsyncKeyedLock<TKey> where TKey : notnull

Type Parameters

TKey

The type of key that operations are serialized by.

Inheritance
AsyncKeyedLock<TKey>
Inherited Members

Remarks

Optional timeout and cancellation token parameters on LockAsync(TKey, TimeSpan, CancellationToken).

Allocation behavior: Per-key lock state (ManualResetValueTaskSource<T> waiters, internal spin lock, waiter queue) is exactly as allocation-free as AsyncLock for a key that already has an active entry. Entries are evicted lazily rather than the moment their last reference is dropped: a released key stays mapped as an idle entry, so locking and releasing the same key repeatedly allocates nothing at all - neither the entry nor the dictionary node. At most maxIdleEntries idle entries are retained; once the cache is full, a key that is not mapped yet takes over the least recently idled entry instead of allocating one, so even a workload cycling through unboundedly many distinct keys only pays for the dictionary node. Every acquisition briefly takes an internal administrative spin lock to look up or create that entry and update its reference count - an O(1) dictionary operation, not the actual lock wait. Acquisition introduces no await boundary of its own: LockAsyncImpl(TKey, TimeSpan, CancellationToken) hands back the entry's own ValueTask<TResult> unchanged, so a queued waiter costs no more than the uncontended fast path does. Cleaning up an acquisition that never becomes a hold is driven by the entry's failure paths rather than by an enclosing async method, precisely so that no state machine has to be boxed to hold the two together.

The allocation-free guarantee is bounded by two caps, and holds only while a workload fits inside both:

  • maxIdleEntries bounds key cardinality. Beyond it, every acquisition of an unmapped key evicts an idle entry and allocates a fresh dictionary node, so a workload cycling through many more distinct keys than the cache holds is neither allocation free nor as fast.
  • maxRetainedWaiters bounds simultaneous contention. Each entry supplies one waiter itself; every further waiter queued at the same moment - summed across all keys - comes from the pool, and each one beyond the cap is allocated and then discarded rather than reused. A lock with 4 keys and 100 waiters behind each needs 396 pooled waiters, so at the default of DefaultMaxRetainedWaiters such a burst allocates on 268 of them.

Size both to the workload: the first to the set of keys that are hot at once, the second to the peak number of waiters queued at once. Note that the default waiter pool is shared process-wide per closed TKey, so passing maxRetainedWaiters is also what gives an instance a private pool rather than a share of the common one.

private readonly AsyncKeyedLock<string> _locksByAccount = new();

public async Task TransferAsync(string accountId, CancellationToken ct) { using (await _locksByAccount.LockAsync(accountId, ct).ConfigureAwait(false)) { // Only one concurrent operation per accountId; other accounts proceed in parallel. await ApplyTransferAsync(accountId).ConfigureAwait(false); } }

Constructors

AsyncKeyedLock(IEqualityComparer<TKey>?, IGetPooledManualResetValueTaskSource<Releaser>?, int, int)

Constructs a new AsyncKeyedLock instance with an optional custom key comparer and custom pool.

public AsyncKeyedLock(IEqualityComparer<TKey>? comparer = null, IGetPooledManualResetValueTaskSource<AsyncKeyedLock<TKey>.Releaser>? pool = null, int maxIdleEntries = 128, int maxRetainedWaiters = 128)

Parameters

comparer IEqualityComparer<TKey>

Custom equality comparer for keys. Defaults to Default.

pool IGetPooledManualResetValueTaskSource<AsyncKeyedLock<TKey>.Releaser>

Custom pool for this instance.

maxIdleEntries int

How many released keys stay cached for reuse before the least recently released one is evicted. Larger values trade memory - each retained entry keeps its key alive - for fewer allocations on a large hot key set; a value below the number of keys that are hot at once gives up the allocation-free path entirely, so err on the generous side. Pass 0 to evict eagerly for the smallest possible footprint, at the cost of allocating an entry and a dictionary node on every acquisition - there is nothing retained to reuse. Defaults to DefaultMaxIdleEntries.

maxRetainedWaiters int

How many waiter objects this lock keeps for reuse, and therefore how many waiters can be queued simultaneously - across all of its keys at once - before a contended acquisition starts allocating. Leave at DefaultMaxRetainedWaiters to use the pool shared by every AsyncKeyedLock<TKey> with this key type; pass a larger value to give this instance a private pool sized to its own peak. Ignored when pool is supplied, since the caller's pool already carries its own bound.

Exceptions

ArgumentOutOfRangeException

Thrown when maxIdleEntries is negative, or maxRetainedWaiters is less than one.

Fields

DefaultMaxIdleEntries

Default number of idle entries retained for reuse when the caller does not specify one.

public const int DefaultMaxIdleEntries = 128

Field Value

int

Remarks

Shares DefaultMaxRetainedItems so the library keeps one retention story across its pools. Sized to span the set of keys that are hot at the same time: a cache smaller than that set degrades into evicting an entry per acquisition, which is both slower and no longer allocation free. Retention is bounded by the keys actually used rather than by this cap, so a generous default costs a lock with few keys nothing.

DefaultMaxRetainedWaiters

Default number of waiter objects retained for reuse when the caller does not specify one, and the number of simultaneously queued waiters up to which a contended acquisition is allocation free.

public const int DefaultMaxRetainedWaiters = 128

Field Value

int

Remarks

Unlike DefaultMaxIdleEntries, this bounds concurrency rather than cardinality: what matters is how many waiters are queued at the same moment across all keys, not how many distinct keys exist. Each entry supplies one waiter of its own, so the pool covers the rest - a lock with 4 keys and 100 waiters queued behind each needs 396 pooled waiters, and every one beyond this cap is allocated and then discarded rather than returned.

Note also that the default pool is shared process-wide per closed TKey (see ValueTaskSourceObjectPools<TKey>), so simultaneous waiters across every AsyncKeyedLock<TKey> instance with the same key type draw on the same budget. Pass maxRetainedWaiters to give an instance its own pool sized to its own peak.

Properties

Count

Gets the number of keys currently tracked, i.e. either held or awaited.

public int Count { get; }

Property Value

int

Remarks

Keys that were released but are still cached for reuse are not counted. This is a best-effort diagnostic snapshot, not a value that can be relied upon for synchronization - it can change immediately after being read.

Methods

IsInUse(TKey)

Gets whether key currently has an active entry, i.e. it is either held or awaited.

public bool IsInUse(TKey key)

Parameters

key TKey

The key to check.

Returns

bool

Remarks

Returns false for a key whose entry is merely cached for reuse. This is a best-effort diagnostic snapshot, not a value that can be relied upon for synchronization - it can change immediately after being read.

LockAsync(TKey, CancellationToken)

Asynchronously acquires the lock for key, with a cancellation token. The cancellation token is only observed if the lock can not be acquired immediately.

public ValueTask<AsyncKeyedLock<TKey>.Releaser> LockAsync(TKey key, CancellationToken cancellationToken = default)

Parameters

key TKey

The key to serialize operations by.

cancellationToken CancellationToken

The cancellation token.

Returns

ValueTask<AsyncKeyedLock<TKey>.Releaser>

A ValueTask<TResult> that completes when the lock for key is acquired. Dispose the returned releaser to release the lock.

Remarks

Note that this lock is not recursive! The returned ValueTask must be disposed to release the lock. Use the following pattern to synchronize async Tasks.

private readonly var _locks = new AsyncKeyedLock<string>();
public async Task DoStuffAsync(string key, CancellationToken ct)
{
    using (await _locks.LockAsync(key, ct))
    {
        await Task.Delay(TimeSpan.FromSeconds(1));
    }
}

Exceptions

ArgumentNullException

Thrown when key is null.

LockAsync(TKey, TimeSpan, CancellationToken)

Asynchronously acquires the lock for key, or throws if the lock cannot be acquired before the timeout elapses.

public ValueTask<AsyncKeyedLock<TKey>.Releaser> LockAsync(TKey key, TimeSpan timeout, CancellationToken cancellationToken = default)

Parameters

key TKey

The key to serialize operations by.

timeout TimeSpan

The maximum time to wait. Use InfiniteTimeSpan to wait indefinitely.

cancellationToken CancellationToken

The cancellation token used to cancel the wait.

Returns

ValueTask<AsyncKeyedLock<TKey>.Releaser>

A ValueTask<TResult> that completes when the lock for key is acquired. Dispose the returned releaser to release the lock.

Exceptions

ArgumentNullException

Thrown when key is null.

ArgumentOutOfRangeException

Thrown when timeout is negative and not equal to InfiniteTimeSpan.

TimeoutException

Thrown when the timeout elapses before the lock can be acquired.

OperationCanceledException

Thrown when cancellationToken is cancelled before the lock can be acquired.

TryLock(TKey, out Releaser)

Attempts to acquire the lock for key immediately, without waiting.

public bool TryLock(TKey key, out AsyncKeyedLock<TKey>.Releaser releaser)

Parameters

key TKey

The key to serialize operations by.

releaser AsyncKeyedLock<TKey>.Releaser

The releaser for the acquired lock, if this method returns true. Dispose it to release the lock. Undefined if this method returns false.

Returns

bool

true if the lock for key was acquired immediately; false if it is currently held or awaited by someone else.

Remarks

Synchronous and non-throwing by design: unlike LockAsync(TKey, TimeSpan, CancellationToken) with a zero timeout, a failed attempt here never allocates an exception or a faulted ValueTask<TResult> - there is nothing to await in the first place, since this either succeeds immediately or doesn't.

Exceptions

ArgumentNullException

Thrown when key is null.