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.
┌─────────────────────────────────────────────────────────────────────────────┐
│ ------------ │
│ | | <-----> 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.
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.
WriterLockAsync
public ValueTask<Releaser> WriterLockAsync(CancellationToken cancellationToken = default)
Asynchronously acquires a writer lock. Only one writer can hold the lock.
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, it also exposes:
public ValueTask<Releaser> UpgradeToWriterLockAsync(CancellationToken cancellationToken = default)
This upgrades the currently held upgradeable reader to an exclusive writer lock.
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, 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.
| Description | Iterations | cancellationType | Mean | Ratio | Allocated |
|---|---|---|---|---|---|
| ReaderLock · RWLockSlim · System | 0 | None | 6.774 ns | 0.40 | - |
| ReaderLock · AsyncRWLock · Pooled | 0 | None | 17.075 ns | 1.00 | - |
| ReaderLock · AsyncRWLock · Proto.Promises | 0 | None | 18.400 ns | 1.08 | - |
| ReaderLock · AsyncRWLock · RefImpl | 0 | None | 18.917 ns | 1.11 | - |
| ReaderLock · AsyncRWLock · Nito.AsyncEx | 0 | None | 40.428 ns | 2.37 | 320 B |
| ReaderLock · AsyncRWLock · VS.Threading | 0 | None | 224.937 ns | 13.17 | 208 B |
| ReaderLock · AsyncRWLock · Pooled | 0 | NotCancelled | 16.943 ns | 1.00 | - |
| ReaderLock · AsyncRWLock · Proto.Promises | 0 | NotCancelled | 18.193 ns | 1.07 | - |
| ReaderLock · AsyncRWLock · Nito.AsyncEx | 0 | NotCancelled | 39.961 ns | 2.36 | 320 B |
| ReaderLock · AsyncRWLock · VS.Threading | 0 | NotCancelled | 224.973 ns | 13.28 | 208 B |
| ReaderLock · RWLockSlim · System | 1 | None | 12.461 ns | 0.36 | - |
| ReaderLock · AsyncRWLock · Proto.Promises | 1 | None | 28.467 ns | 0.82 | - |
| ReaderLock · AsyncRWLock · Pooled | 1 | None | 34.709 ns | 1.00 | - |
| ReaderLock · AsyncRWLock · RefImpl | 1 | None | 34.977 ns | 1.01 | - |
| ReaderLock · AsyncRWLock · Nito.AsyncEx | 1 | None | 84.985 ns | 2.45 | 640 B |
| ReaderLock · AsyncRWLock · VS.Threading | 1 | None | 518.755 ns | 14.95 | 416 B |
| ReaderLock · AsyncRWLock · Proto.Promises | 1 | NotCancelled | 28.772 ns | 0.84 | - |
| ReaderLock · AsyncRWLock · Pooled | 1 | NotCancelled | 34.054 ns | 1.00 | - |
| ReaderLock · AsyncRWLock · Nito.AsyncEx | 1 | NotCancelled | 81.086 ns | 2.38 | 640 B |
| ReaderLock · AsyncRWLock · VS.Threading | 1 | NotCancelled | 518.476 ns | 15.23 | 416 B |
| ReaderLock · RWLockSlim · System | 10 | None | 61.761 ns | 0.31 | - |
| ReaderLock · AsyncRWLock · Proto.Promises | 10 | None | 142.806 ns | 0.72 | - |
| ReaderLock · AsyncRWLock · RefImpl | 10 | None | 144.870 ns | 0.74 | - |
| ReaderLock · AsyncRWLock · Pooled | 10 | None | 197.022 ns | 1.00 | - |
| ReaderLock · AsyncRWLock · Nito.AsyncEx | 10 | None | 481.419 ns | 2.44 | 3520 B |
| ReaderLock · AsyncRWLock · VS.Threading | 10 | None | 3,703.521 ns | 18.80 | 2288 B |
| ReaderLock · AsyncRWLock · Proto.Promises | 10 | NotCancelled | 147.119 ns | 0.74 | - |
| ReaderLock · AsyncRWLock · Pooled | 10 | NotCancelled | 197.696 ns | 1.00 | - |
| ReaderLock · AsyncRWLock · Nito.AsyncEx | 10 | NotCancelled | 473.137 ns | 2.39 | 3520 B |
| ReaderLock · AsyncRWLock · VS.Threading | 10 | NotCancelled | 3,614.872 ns | 18.29 | 2288 B |
| ReaderLock · RWLockSlim · System | 100 | None | 559.414 ns | 0.33 | - |
| ReaderLock · AsyncRWLock · RefImpl | 100 | None | 1,220.891 ns | 0.72 | - |
| ReaderLock · AsyncRWLock · Proto.Promises | 100 | None | 1,236.741 ns | 0.73 | - |
| ReaderLock · AsyncRWLock · Pooled | 100 | None | 1,700.744 ns | 1.00 | - |
| ReaderLock · AsyncRWLock · Nito.AsyncEx | 100 | None | 4,495.417 ns | 2.64 | 32320 B |
| ReaderLock · AsyncRWLock · VS.Threading | 100 | None | 87,591.125 ns | 51.50 | 21008 B |
| ReaderLock · AsyncRWLock · Proto.Promises | 100 | NotCancelled | 1,256.133 ns | 0.74 | - |
| ReaderLock · AsyncRWLock · Pooled | 100 | NotCancelled | 1,698.319 ns | 1.00 | - |
| ReaderLock · AsyncRWLock · Nito.AsyncEx | 100 | NotCancelled | 4,403.054 ns | 2.59 | 32320 B |
| ReaderLock · AsyncRWLock · VS.Threading | 100 | NotCancelled | 86,857.592 ns | 51.14 | 21008 B |
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.
| Description | Mean | Ratio | Allocated |
|---|---|---|---|
| WriterLock · RWLockSlim · System | 6.905 ns | 0.66 | - |
| WriterLock · AsyncRWLock · Proto.Promises | 8.379 ns | 0.80 | - |
| WriterLock · AsyncRWLock · Pooled | 10.460 ns | 1.00 | - |
| WriterLock · AsyncRWLock · RefImpl | 18.797 ns | 1.80 | - |
| WriterLock · AsyncRWLock · Nito.AsyncEx | 54.581 ns | 5.22 | 496 B |
| WriterLock · AsyncRWLock · VS.Threading | 1,027.868 ns | 98.27 | 584 B |
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.
| Description | Iterations | cancellationType | Mean | Ratio | Allocated |
|---|---|---|---|---|---|
| UpgradeableReaderLock · RWLockSlim · System | 0 | None | 6.855 ns | 0.43 | - |
| UpgradeableReaderLock · AsyncRWLock · Pooled | 0 | None | 15.959 ns | 1.00 | - |
| UpgradeableReaderLock · AsyncRWLock · Proto.Promises | 0 | None | 20.142 ns | 1.26 | - |
| UpgradeableReaderLock · AsyncRWLock · VS.Threading | 0 | None | 1,096.171 ns | 68.69 | 616 B |
| UpgradeableReaderLock · AsyncRWLock · Pooled | 0 | NotCancelled | 16.321 ns | 1.00 | - |
| UpgradeableReaderLock · AsyncRWLock · Proto.Promises | 0 | NotCancelled | 18.987 ns | 1.16 | - |
| UpgradeableReaderLock · AsyncRWLock · VS.Threading | 0 | NotCancelled | 1,127.899 ns | 69.11 | 616 B |
| UpgradeableReaderLock · RWLockSlim · System | 1 | None | 6.745 ns | 0.37 | - |
| UpgradeableReaderLock · AsyncRWLock · Pooled | 1 | None | 18.139 ns | 1.00 | - |
| UpgradeableReaderLock · AsyncRWLock · Proto.Promises | 1 | None | 19.333 ns | 1.07 | - |
| UpgradeableReaderLock · AsyncRWLock · VS.Threading | 1 | None | 1,089.110 ns | 60.04 | 616 B |
| UpgradeableReaderLock · AsyncRWLock · Proto.Promises | 1 | NotCancelled | 17.789 ns | 0.90 | - |
| UpgradeableReaderLock · AsyncRWLock · Pooled | 1 | NotCancelled | 19.749 ns | 1.00 | - |
| UpgradeableReaderLock · AsyncRWLock · VS.Threading | 1 | NotCancelled | 1,138.793 ns | 57.77 | 616 B |
| UpgradeableReaderLock · RWLockSlim · System | 2 | None | 6.822 ns | 0.37 | - |
| UpgradeableReaderLock · AsyncRWLock · Proto.Promises | 2 | None | 17.518 ns | 0.96 | - |
| UpgradeableReaderLock · AsyncRWLock · Pooled | 2 | None | 18.268 ns | 1.00 | - |
| UpgradeableReaderLock · AsyncRWLock · VS.Threading | 2 | None | 1,082.163 ns | 59.24 | 616 B |
| UpgradeableReaderLock · AsyncRWLock · Proto.Promises | 2 | NotCancelled | 17.317 ns | 0.93 | - |
| UpgradeableReaderLock · AsyncRWLock · Pooled | 2 | NotCancelled | 18.599 ns | 1.00 | - |
| UpgradeableReaderLock · AsyncRWLock · VS.Threading | 2 | NotCancelled | 1,124.084 ns | 60.44 | 616 B |
| UpgradeableReaderLock · RWLockSlim · System | 5 | None | 24.061 ns | 0.35 | - |
| UpgradeableReaderLock · AsyncRWLock · Proto.Promises | 5 | None | 54.872 ns | 0.80 | - |
| UpgradeableReaderLock · AsyncRWLock · Pooled | 5 | None | 68.248 ns | 1.00 | - |
| UpgradeableReaderLock · AsyncRWLock · VS.Threading | 5 | None | 2,631.592 ns | 38.56 | 1240 B |
| UpgradeableReaderLock · AsyncRWLock · Proto.Promises | 5 | NotCancelled | 53.997 ns | 0.80 | - |
| UpgradeableReaderLock · AsyncRWLock · Pooled | 5 | NotCancelled | 67.902 ns | 1.00 | - |
| UpgradeableReaderLock · AsyncRWLock · VS.Threading | 5 | NotCancelled | 2,693.549 ns | 39.67 | 1240 B |
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.
| Description | Iterations | cancellationType | Mean | Ratio | Allocated |
|---|---|---|---|---|---|
| UpgradedWriterLock · RWLockSlim · System | 0 | None | 13.46 ns | 0.60 | - |
| UpgradedWriterLock · AsyncRWLock · Proto.Promises | 0 | None | 21.66 ns | 0.97 | - |
| UpgradedWriterLock · AsyncRWLock · Pooled | 0 | None | 22.44 ns | 1.00 | - |
| UpgradedWriterLock · AsyncRWLock · VS.Threading | 0 | None | 1,823.37 ns | 81.24 | 824 B |
| UpgradedWriterLock · AsyncRWLock · Pooled | 0 | NotCancelled | 22.76 ns | 1.00 | - |
| UpgradedWriterLock · AsyncRWLock · Proto.Promises | 0 | NotCancelled | 24.61 ns | 1.08 | - |
| UpgradedWriterLock · AsyncRWLock · VS.Threading | 0 | NotCancelled | 1,910.35 ns | 83.95 | 824 B |
| UpgradedWriterLock · RWLockSlim · System | 1 | None | 20.15 ns | 0.41 | - |
| UpgradedWriterLock · AsyncRWLock · Proto.Promises | 1 | None | 43.06 ns | 0.87 | - |
| UpgradedWriterLock · AsyncRWLock · Pooled | 1 | None | 49.25 ns | 1.00 | - |
| UpgradedWriterLock · AsyncRWLock · VS.Threading | 1 | None | 2,325.23 ns | 47.21 | 1032 B |
| UpgradedWriterLock · AsyncRWLock · Pooled | 1 | NotCancelled | 60.90 ns | 1.00 | - |
| UpgradedWriterLock · AsyncRWLock · Proto.Promises | 1 | NotCancelled | 70.48 ns | 1.16 | - |
| UpgradedWriterLock · AsyncRWLock · VS.Threading | 1 | NotCancelled | 2,406.80 ns | 39.52 | 1032 B |
| UpgradedWriterLock · RWLockSlim · System | 2 | None | 25.36 ns | 0.37 | - |
| UpgradedWriterLock · AsyncRWLock · Proto.Promises | 2 | None | 54.15 ns | 0.80 | - |
| UpgradedWriterLock · AsyncRWLock · Pooled | 2 | None | 68.01 ns | 1.00 | - |
| UpgradedWriterLock · AsyncRWLock · VS.Threading | 2 | None | 2,823.31 ns | 41.56 | 1240 B |
| UpgradedWriterLock · AsyncRWLock · Pooled | 2 | NotCancelled | 76.14 ns | 1.00 | - |
| UpgradedWriterLock · AsyncRWLock · Proto.Promises | 2 | NotCancelled | 86.37 ns | 1.13 | - |
| UpgradedWriterLock · AsyncRWLock · VS.Threading | 2 | NotCancelled | 2,948.51 ns | 38.73 | 1240 B |
| UpgradedWriterLock · RWLockSlim · System | 5 | None | 41.38 ns | 0.33 | - |
| UpgradedWriterLock · AsyncRWLock · Proto.Promises | 5 | None | 92.94 ns | 0.75 | - |
| UpgradedWriterLock · AsyncRWLock · Pooled | 5 | None | 123.96 ns | 1.00 | - |
| UpgradedWriterLock · AsyncRWLock · VS.Threading | 5 | None | 4,468.96 ns | 36.05 | 1864 B |
| UpgradedWriterLock · AsyncRWLock · Proto.Promises | 5 | NotCancelled | 116.85 ns | 0.90 | - |
| UpgradedWriterLock · AsyncRWLock · Pooled | 5 | NotCancelled | 130.50 ns | 1.00 | - |
| UpgradedWriterLock · AsyncRWLock · VS.Threading | 5 | NotCancelled | 4,578.74 ns | 35.09 | 1864 B |
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
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 |
See Also
- Threading Package Overview
- AsyncAutoResetEvent - Auto-reset event variant
- AsyncManualResetEvent - Manual-reset event variant
- AsyncLock - Async mutual exclusion lock
- AsyncCountdownEvent - Async countdown event
- AsyncBarrier - Async barrier synchronization primitive
- AsyncSemaphore - Async semaphore primitive
- Benchmarks - Benchmark description
© 2026 The Keepers of the CryptoHives