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
TKeyThe 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:
maxIdleEntriesbounds 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.maxRetainedWaitersbounds 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
comparerIEqualityComparer<TKey>Custom equality comparer for keys. Defaults to Default.
poolIGetPooledManualResetValueTaskSource<AsyncKeyedLock<TKey>.Releaser>Custom pool for this instance.
maxIdleEntriesintHow 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
0to 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.maxRetainedWaitersintHow 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
poolis supplied, since the caller's pool already carries its own bound.
Exceptions
- ArgumentOutOfRangeException
Thrown when
maxIdleEntriesis negative, ormaxRetainedWaitersis 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
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
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
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
keyTKeyThe key to check.
Returns
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
keyTKeyThe key to serialize operations by.
cancellationTokenCancellationTokenThe cancellation token.
Returns
- ValueTask<AsyncKeyedLock<TKey>.Releaser>
A ValueTask<TResult> that completes when the lock for
keyis 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
keyis 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
keyTKeyThe key to serialize operations by.
timeoutTimeSpanThe maximum time to wait. Use InfiniteTimeSpan to wait indefinitely.
cancellationTokenCancellationTokenThe cancellation token used to cancel the wait.
Returns
- ValueTask<AsyncKeyedLock<TKey>.Releaser>
A ValueTask<TResult> that completes when the lock for
keyis acquired. Dispose the returned releaser to release the lock.
Exceptions
- ArgumentNullException
Thrown when
keyis null.- ArgumentOutOfRangeException
Thrown when
timeoutis negative and not equal to InfiniteTimeSpan.- TimeoutException
Thrown when the timeout elapses before the lock can be acquired.
- OperationCanceledException
Thrown when
cancellationTokenis 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
keyTKeyThe key to serialize operations by.
releaserAsyncKeyedLock<TKey>.ReleaserThe 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
keywas 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
keyis null.