AsyncReaderWriterLock
A pooled, allocation-free async reader-writer lock that supports multiple concurrent readers or a single exclusive writer using ValueTask-based waiters with cancellation tokens.
Overview
AsyncReaderWriterLock is an async-compatible reader-writer lock. It allows multiple readers to enter the lock concurrently, but only one writer can hold the lock exclusively. Writers are prioritized over readers to prevent writer starvation. One upgradeable reader at a time can share access with multiple other readers. Once the
upgradeable reader is upgraded to writer, it may have to wait until all readers release the lock. An upgradeable reader may release the lock while still upgraded writers are queued for write access.
An additional internal state, UpgradedWriterWithoutReader, is used when an upgradeable reader releases the lock before all concurrent readers have released while an upgrade to writer is in progress. This ensures correct handling of waiting writers under this scenario.
┌─────────────────────────────────────────────────────────────────────────────┐
│ ------------ │
│ | | <-----> READERS │
│ | | <-----> UPGRADEABLE READER + READERS │
│ | IDLE | <-----> UPGRADEABLE READER -----> UPGRADED WRITER --\ │
│ | NO LOCKS | ^ | │
│ | | |------- DEMOTE TO UPGRADEABLE READER <--/ │
│ | | <--------------- DEMOTE TO IDLE WITHOUT READER <--/ │
│ | | <-----> WRITER │
│ ------------ │
└─────────────────────────────────────────────────────────────────────────────┘
Usage
Basic Usage
using CryptoHives.Foundation.Threading.Async.Pooled;
private readonly AsyncReaderWriterLock _rwLock = new AsyncReaderWriterLock();
// Reader
public async Task<Data> ReadDataAsync(CancellationToken ct)
{
using (await _rwLock.ReaderLockAsync(ct))
{
// Multiple readers can hold the lock concurrently
return await FetchDataAsync();
}
}
// Writer
public async Task WriteDataAsync(Data data, CancellationToken ct)
{
using (await _rwLock.WriterLockAsync(ct))
{
// Exclusive access - no other readers or writers
await SaveDataAsync(data);
}
}
// Upgradeable reader
public async Task UpdateIfNeededAsync(CancellationToken ct)
{
using (var upgradeable = await _rwLock.UpgradeableReaderLockAsync(ct))
{
if (NeedsUpdate())
{
using (await upgradeable.UpgradeToWriterLockAsync(ct))
{
await SaveDataAsync();
}
}
}
}
Cache Pattern
private readonly AsyncReaderWriterLock _cacheLock = new AsyncReaderWriterLock();
private Dictionary<string, object> _cache = new();
public async Task<T> GetOrAddAsync<T>(string key, Func<Task<T>> factory, CancellationToken ct)
{
// Try to read first
using (await _cacheLock.ReaderLockAsync(ct))
{
if (_cache.TryGetValue(key, out var cached))
{
return (T)cached;
}
}
// Need to write
using (await _cacheLock.WriterLockAsync(ct))
{
// Double-check after acquiring write lock
if (_cache.TryGetValue(key, out var cached))
{
return (T)cached;
}
var value = await factory();
_cache[key] = value;
return value;
}
}
Constructor
public AsyncReaderWriterLock(
bool runContinuationAsynchronously = true,
IGetPooledManualResetValueTaskSource<Releaser>? pool = null)
| Parameter | Description |
|---|---|
runContinuationAsynchronously |
If true (default), continuations run on the thread pool. |
pool |
Optional custom pool for ValueTaskSource instances used by both readers and writers. |
Properties
| Property | Type | Description |
|---|---|---|
IsReadLockHeld |
bool |
Gets whether one or more readers currently hold the lock. |
IsWriteLockHeld |
bool |
Gets whether the lock is currently held by a writer. |
IsUpgradeableReadLockHeld |
bool |
Gets whether the lock is currently held by an upgradeable reader. |
IsUpgradedWriterLockHeld |
bool |
Gets whether an upgradeable reader is currently upgraded to writer mode. |
CurrentReaderCount |
int |
Gets the number of readers holding the lock. |
WaitingWriterCount |
int |
Gets the number of writers waiting. |
WaitingReaderCount |
int |
Gets the number of readers waiting. |
WaitingUpgradeableReaderCount |
int |
Gets the number of upgradeable readers waiting. |
WaitingUpgradedWritersCount |
int |
Gets the number of upgrade requests waiting for exclusive access. |
RunContinuationAsynchronously |
bool |
Gets or sets whether continuations run asynchronously. |
Methods
ReaderLockAsync
public ValueTask<Releaser> ReaderLockAsync(CancellationToken cancellationToken = default)
Asynchronously acquires a reader lock. Multiple readers can hold the lock concurrently.
ReaderLockAsync (timeout)
public ValueTask<Releaser> ReaderLockAsync(TimeSpan timeout)
Asynchronously acquires a reader lock, or throws TimeoutException if the timeout elapses first.
Throws: TimeoutException if the timeout elapses, OperationCanceledException if the operation is canceled via the cancellation token, ArgumentOutOfRangeException if timeout is negative and not Timeout.InfiniteTimeSpan.
UpgradeableReaderLockAsync
public ValueTask<Releaser> UpgradeableReaderLockAsync(CancellationToken cancellationToken = default)
Asynchronously acquires an upgradeable reader lock. One upgradeable reader can coexist with other readers and may later be promoted to a writer lock.
UpgradeableReaderLockAsync (timeout)
public ValueTask<Releaser> UpgradeableReaderLockAsync(TimeSpan timeout)
Asynchronously acquires an upgradeable reader lock, or throws TimeoutException if the timeout elapses first.
Throws: TimeoutException if the timeout elapses, OperationCanceledException if the operation is canceled via the cancellation token, ArgumentOutOfRangeException if timeout is negative and not Timeout.InfiniteTimeSpan.
WriterLockAsync
public ValueTask<Releaser> WriterLockAsync(CancellationToken cancellationToken = default)
Asynchronously acquires a writer lock. Only one writer can hold the lock.
WriterLockAsync (timeout)
public ValueTask<Releaser> WriterLockAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
Asynchronously acquires a writer lock, or throws TimeoutException if the timeout elapses first.
Throws: TimeoutException if the timeout elapses, OperationCanceledException if the operation is canceled via the cancellation token, ArgumentOutOfRangeException if timeout is negative and not Timeout.InfiniteTimeSpan.
Allocation notes for all timeout overloads:
| Scenario | TimeProvider allocated? |
|---|---|
| Lock immediately available | No |
Timeout.InfiniteTimeSpan |
No |
TimeSpan.Zero and contested |
No (immediate exception) |
| Finite positive timeout | Yes — one instance, disposed on await |
An already-cancelled token is checked before the zero timeout, on every acquisition method here and on UpgradeToWriterLockAsync: passing both a cancelled token and TimeSpan.Zero throws OperationCanceledException carrying that token, not a TimeoutException. This matches the other primitives in the package.
TryReaderLock / TryUpgradeableReaderLock / TryWriterLock
public bool TryReaderLock(out Releaser releaser)
public bool TryUpgradeableReaderLock(out Releaser releaser)
public bool TryWriterLock(out Releaser releaser)
Attempt to acquire the corresponding lock without waiting. Each returns true and a live releaser on success, or false and default(Releaser) on a miss.
Synchronous and non-throwing by design: unlike the timeout overloads with TimeSpan.Zero, a failed attempt never allocates an exception or a faulted ValueTask<Releaser> — there is nothing to await in the first place.
Semantics:
| Method | Succeeds when | Declines when |
|---|---|---|
TryReaderLock |
No writer holds or is queued, reader limit not reached | A writer holds or is waiting, or the reader limit is reached |
TryUpgradeableReaderLock |
No writer holds or is queued, no other upgradeable reader is active, reader limit not reached | A writer holds or is waiting, another upgradeable reader is active, or the reader limit is reached |
TryWriterLock |
The lock is completely uncontested | Any reader or writer currently holds it |
- Writer priority is honoured on the try path.
TryReaderLockandTryUpgradeableReaderLockdecline while a writer is queued, even though the lock is only read-held, so a caller polling in a loop cannot starve a writer. - Do not dispose the releaser from a failed attempt. It is
default(Releaser)and represents no acquired lock. InDEBUGbuilds disposing it throwsInvalidOperationException; release builds ignore it.
Example:
// Rebuild a cache only if nobody else is reading or writing right now
if (_rwLock.TryWriterLock(out var releaser))
{
using (releaser)
{
RebuildCache();
}
}
Releaser.TryUpgradeToWriterLock
public bool TryUpgradeToWriterLock(out Releaser releaser)
Attempts the upgrade of an upgradeable reader to an exclusive writer lock without waiting. Available only on a releaser obtained from UpgradeableReaderLockAsync / TryUpgradeableReaderLock.
Returns: true and an upgraded-writer releaser if no other reader currently holds the lock; false otherwise.
Non-throwing for contention — it simply returns false while other readers are still active — but calling it on a releaser that is not in the upgradeable reader state is a programming error and throws InvalidOperationException, matching UpgradeToWriterLockAsync.
On success the original upgradeable-reader releaser stays valid: dispose the returned upgraded-writer releaser to drop back to the upgradeable reader lock, then dispose the original to release it entirely.
using (var upgradeable = ...) // TryUpgradeableReaderLock succeeded
{
if (upgradeable.TryUpgradeToWriterLock(out var writer))
{
using (writer)
{
MutateState();
}
// back to upgradeable-reader mode here
}
}
Allocation Behavior
Immediate lock acquisitions via the fast path 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.
Example:
try
{
using (await _rwLock.ReaderLockAsync(TimeSpan.FromSeconds(5)))
{
return await ReadDataAsync();
}
}
catch (TimeoutException)
{
HandleTimeout();
}
Releaser
All lock-acquisition methods return a Releaser struct that implements IDisposable and IAsyncDisposable:
using (await _rwLock.ReaderLockAsync())
{
// Lock is held here
}
// Lock is automatically released
When the Releaser originated from UpgradeableReaderLockAsync / TryUpgradeableReaderLock, it also exposes:
public ValueTask<Releaser> UpgradeToWriterLockAsync(CancellationToken cancellationToken = default)
public ValueTask<Releaser> UpgradeToWriterLockAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
public bool TryUpgradeToWriterLock(out Releaser releaser)
These upgrade the currently held upgradeable reader to an exclusive writer lock — awaiting, awaiting with a bound, or without waiting at all. See Releaser.TryUpgradeToWriterLock.
A default Releaser (handed back by a failed TryReaderLock / TryUpgradeableReaderLock / TryWriterLock) represents no acquired lock. Do not dispose it; in DEBUG builds Dispose() throws InvalidOperationException to catch the mistake.
Fairness and Priority
- Writer Priority: New readers are queued behind waiting writers to prevent writer starvation
- Reader Batching: When a writer releases, all waiting readers are released together
- FIFO Readers and Writers: Waiting writers and readers are released in order
Performance
- O(1) reader acquisition with fast path when no writers are waiting/holding
- O(1) writer acquisition with fast path when lock is free
- O(n) reader batch release when writer releases
- Zero allocations on the fast path (uncontended) and contended path, unless the ObjectPool is exhausted. A custom pool can be provided to satisfy specific needs.
Benchmark Results
The following benchmarks compare AsyncReaderWriterLock against ReaderWriterLockSlim, Nito.AsyncEx.AsyncReaderWriterLock, Proto.Promises.Threading.AsyncReaderWriterLock, Microsoft.VisualStudio.Threading.AsyncReaderWriterLock, DotNext.Threading.AsyncReaderWriterLock (net10.0 only, reader and writer locks only - it has no per-acquisition releaser, and no distinct upgradeable-reader mode), and a reference implementation. Not all implementations support every lock mode; the set of compared implementations varies per benchmark.
Reader Lock Benchmark
Measures the performance of acquiring and releasing reader locks with varying numbers of nested acquisitions. At the lowest iteration count (Iterations = 0), the pooled implementation achieves lower latency than Proto.Promises; from Iterations = 1 onward, Proto.Promises achieves lower per-operation latency, reflecting a lower per-lock-call overhead at the cost of a slightly higher fixed invocation overhead. Both operate with zero allocations. Nito.AsyncEx allocates per acquisition. VS.Threading allocates per acquisition and shows substantially higher latency at all iteration counts.
View live Reader Lock benchmark results and trend history →
Writer Lock Benchmark
Measures the performance of acquiring and releasing a single writer lock. Proto.Promises achieves lower uncontended latency than the pooled implementation, with both operating at zero allocations. Nito.AsyncEx allocates per acquisition. VS.Threading allocates per acquisition and shows substantially higher latency.
View live Writer Lock benchmark results and trend history →
Upgradeable Reader Lock Benchmark
Measures the performance of acquiring an upgradeable reader lock in combination with varying numbers of additional reader locks. At the lowest iteration count (Iterations = 0), the pooled implementation is marginally faster; Proto.Promises achieves lower per-operation latency as the number of additional reader locks increases. Both operate with zero allocations. VS.Threading allocates per acquisition and shows substantially higher latency across all iteration counts.
View live Upgradeable Reader Lock benchmark results and trend history →
Upgraded Writer Lock Benchmark
Measures the performance of acquiring an upgradeable reader lock, holding additional reader locks concurrently, then upgrading to an exclusive writer lock. The pooled implementation is marginally faster at the lowest iteration count (Iterations = 0); Proto.Promises achieves lower per-operation latency as the number of held reader locks increases. Both operate with zero allocations. VS.Threading allocates proportionally to the number of held reader locks and shows substantially higher latency across all configurations.
View live Upgraded Writer Lock benchmark results and trend history →
Benchmark Analysis
Key Findings:
Reader and upgradeable reader performance: At a single acquisition per call, the pooled implementation has a slight latency advantage. As the number of lock operations per call increases, Proto.Promises achieves lower per-operation latency in both the reader and upgradeable reader benchmarks, with zero allocations in both cases.
Writer and upgraded writer performance: For single-operation writer lock acquisition, Proto.Promises achieves lower uncontended latency than the pooled implementation. The same pattern holds for the upgraded writer benchmark: the pooled implementation is marginally faster at minimal load, while Proto.Promises achieves lower per-operation latency as the number of additionally held reader locks increases. Both implementations operate with zero allocations.
Writer priority: The writer-priority design prevents writer starvation but may reduce reader throughput when writers are frequently queued.
Memory efficiency: A shared pool for readers, upgradeable readers, and writers allows fine-tuned pool sizing. The pooled implementation maintains zero allocations across all published benchmarks, matching Proto.Promises. Nito.AsyncEx allocates per acquisition in the reader and writer benchmarks. VS.Threading allocates per acquisition in all benchmarks, with memory usage growing proportionally to the number of concurrently held locks.
Releaser struct: The value-type
Releaserproduces no allocation for the lock handle itself. Allocations occur only for the pooledIValueTaskSourcewhen the pool is exhausted under sustained contention.
When to Choose AsyncReaderWriterLock:
- Read-heavy workloads with occasional writes
- Cache implementations with read/write patterns
- Document or configuration stores
Best Practices
✓ DO: Use timeout overloads to bound lock-wait time
try
{
using (await _rwLock.WriterLockAsync(TimeSpan.FromSeconds(5)))
{
await SaveDataAsync();
}
}
catch (TimeoutException)
{
HandleTimeout();
}
Design Trade-offs:
- Writer priority may reduce reader throughput under write-heavy loads
- Consider
AsyncLockfor simpler mutex-style locking - For read-only scenarios, no lock is needed
Comparison with ReaderWriterLockSlim
| Feature | AsyncReaderWriterLock | ReaderWriterLockSlim |
|---|---|---|
| Async support | Native | None |
| Allocation overhead | Minimal (pooled) | None (sync) |
| Writer priority | Yes | Configurable |
| Cancellation | Full support | None |
| Non-blocking attempt | TryReaderLock / TryUpgradeableReaderLock / TryWriterLock / Releaser.TryUpgradeToWriterLock |
TryEnter* methods |
See Also
- Threading Package Overview
- AsyncAutoResetEvent - Auto-reset event variant
- AsyncManualResetEvent - Manual-reset event variant
- AsyncLock - Async mutual exclusion lock
- AsyncKeyedLock - Per-key async exclusion
- AsyncCountdownEvent - Async countdown event
- AsyncBarrier - Async barrier synchronization primitive
- AsyncSemaphore - Async semaphore primitive
- AsyncConditionVariable - Wait until a condition guarded by an AsyncLock holds
- AsyncExchange - Two-party value rendezvous
- Benchmarks - Benchmark description
© 2026 The Keepers of the CryptoHives