Table of Contents

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

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, 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 7.728 ns 0.49 -
ReaderLock · AsyncRWLock · Pooled 0 None 15.803 ns 1.00 -
ReaderLock · AsyncRWLock · Proto.Promises 0 None 18.267 ns 1.16 -
ReaderLock · AsyncRWLock · RefImpl 0 None 18.839 ns 1.19 -
ReaderLock · AsyncRWLock · Nito.AsyncEx 0 None 40.865 ns 2.59 320 B
ReaderLock · AsyncRWLock · VS.Threading 0 None 224.367 ns 14.20 208 B
ReaderLock · AsyncRWLock · Pooled 0 NotCancelled 15.951 ns 1.00 -
ReaderLock · AsyncRWLock · Proto.Promises 0 NotCancelled 18.197 ns 1.14 -
ReaderLock · AsyncRWLock · Nito.AsyncEx 0 NotCancelled 40.386 ns 2.53 320 B
ReaderLock · AsyncRWLock · VS.Threading 0 NotCancelled 225.331 ns 14.13 208 B
ReaderLock · RWLockSlim · System 1 None 12.326 ns 0.30 -
ReaderLock · AsyncRWLock · Proto.Promises 1 None 28.524 ns 0.69 -
ReaderLock · AsyncRWLock · RefImpl 1 None 33.435 ns 0.80 -
ReaderLock · AsyncRWLock · Pooled 1 None 41.565 ns 1.00 -
ReaderLock · AsyncRWLock · Nito.AsyncEx 1 None 83.736 ns 2.01 640 B
ReaderLock · AsyncRWLock · VS.Threading 1 None 531.027 ns 12.78 416 B
ReaderLock · AsyncRWLock · Proto.Promises 1 NotCancelled 28.778 ns 0.71 -
ReaderLock · AsyncRWLock · Pooled 1 NotCancelled 40.399 ns 1.00 -
ReaderLock · AsyncRWLock · Nito.AsyncEx 1 NotCancelled 81.648 ns 2.02 640 B
ReaderLock · AsyncRWLock · VS.Threading 1 NotCancelled 532.178 ns 13.17 416 B
ReaderLock · RWLockSlim · System 10 None 61.963 ns 0.32 -
ReaderLock · AsyncRWLock · Proto.Promises 10 None 141.332 ns 0.73 -
ReaderLock · AsyncRWLock · RefImpl 10 None 143.397 ns 0.74 -
ReaderLock · AsyncRWLock · Pooled 10 None 194.403 ns 1.00 -
ReaderLock · AsyncRWLock · Nito.AsyncEx 10 None 467.103 ns 2.40 3520 B
ReaderLock · AsyncRWLock · VS.Threading 10 None 3,642.898 ns 18.74 2288 B
ReaderLock · AsyncRWLock · Proto.Promises 10 NotCancelled 139.359 ns 0.72 -
ReaderLock · AsyncRWLock · Pooled 10 NotCancelled 194.276 ns 1.00 -
ReaderLock · AsyncRWLock · Nito.AsyncEx 10 NotCancelled 464.283 ns 2.39 3520 B
ReaderLock · AsyncRWLock · VS.Threading 10 NotCancelled 3,640.725 ns 18.74 2288 B
ReaderLock · RWLockSlim · System 100 None 570.326 ns 0.33 -
ReaderLock · AsyncRWLock · Proto.Promises 100 None 1,223.857 ns 0.70 -
ReaderLock · AsyncRWLock · RefImpl 100 None 1,249.889 ns 0.71 -
ReaderLock · AsyncRWLock · Pooled 100 None 1,748.960 ns 1.00 -
ReaderLock · AsyncRWLock · Nito.AsyncEx 100 None 4,435.991 ns 2.54 32320 B
ReaderLock · AsyncRWLock · VS.Threading 100 None 86,096.879 ns 49.23 21008 B
ReaderLock · AsyncRWLock · Proto.Promises 100 NotCancelled 1,245.661 ns 0.71 -
ReaderLock · AsyncRWLock · Pooled 100 NotCancelled 1,743.212 ns 1.00 -
ReaderLock · AsyncRWLock · Nito.AsyncEx 100 NotCancelled 4,404.138 ns 2.53 32320 B
ReaderLock · AsyncRWLock · VS.Threading 100 NotCancelled 87,835.002 ns 50.39 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.931 ns 0.62 -
WriterLock · AsyncRWLock · Proto.Promises 8.353 ns 0.75 -
WriterLock · AsyncRWLock · Pooled 11.103 ns 1.00 -
WriterLock · AsyncRWLock · RefImpl 18.529 ns 1.67 -
WriterLock · AsyncRWLock · Nito.AsyncEx 55.739 ns 5.02 496 B
WriterLock · AsyncRWLock · VS.Threading 1,044.066 ns 94.04 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.741 ns 0.41 -
UpgradeableReaderLock · AsyncRWLock · Pooled 0 None 16.248 ns 1.00 -
UpgradeableReaderLock · AsyncRWLock · Proto.Promises 0 None 19.423 ns 1.20 -
UpgradeableReaderLock · AsyncRWLock · VS.Threading 0 None 1,064.281 ns 65.50 616 B
UpgradeableReaderLock · AsyncRWLock · Pooled 0 NotCancelled 16.334 ns 1.00 -
UpgradeableReaderLock · AsyncRWLock · Proto.Promises 0 NotCancelled 17.778 ns 1.09 -
UpgradeableReaderLock · AsyncRWLock · VS.Threading 0 NotCancelled 1,135.176 ns 69.50 616 B
UpgradeableReaderLock · RWLockSlim · System 1 None 6.725 ns 0.34 -
UpgradeableReaderLock · AsyncRWLock · Proto.Promises 1 None 17.560 ns 0.90 -
UpgradeableReaderLock · AsyncRWLock · Pooled 1 None 19.551 ns 1.00 -
UpgradeableReaderLock · AsyncRWLock · VS.Threading 1 None 1,048.830 ns 53.65 616 B
UpgradeableReaderLock · AsyncRWLock · Proto.Promises 1 NotCancelled 17.601 ns 0.92 -
UpgradeableReaderLock · AsyncRWLock · Pooled 1 NotCancelled 19.051 ns 1.00 -
UpgradeableReaderLock · AsyncRWLock · VS.Threading 1 NotCancelled 1,181.340 ns 62.01 616 B
UpgradeableReaderLock · RWLockSlim · System 2 None 6.733 ns 0.36 -
UpgradeableReaderLock · AsyncRWLock · Proto.Promises 2 None 17.646 ns 0.94 -
UpgradeableReaderLock · AsyncRWLock · Pooled 2 None 18.809 ns 1.00 -
UpgradeableReaderLock · AsyncRWLock · VS.Threading 2 None 1,045.428 ns 55.58 616 B
UpgradeableReaderLock · AsyncRWLock · Proto.Promises 2 NotCancelled 17.493 ns 0.90 -
UpgradeableReaderLock · AsyncRWLock · Pooled 2 NotCancelled 19.365 ns 1.00 -
UpgradeableReaderLock · AsyncRWLock · VS.Threading 2 NotCancelled 1,179.300 ns 60.90 616 B
UpgradeableReaderLock · RWLockSlim · System 5 None 24.080 ns 0.31 -
UpgradeableReaderLock · AsyncRWLock · Proto.Promises 5 None 52.959 ns 0.69 -
UpgradeableReaderLock · AsyncRWLock · Pooled 5 None 76.546 ns 1.00 -
UpgradeableReaderLock · AsyncRWLock · VS.Threading 5 None 2,477.664 ns 32.37 1240 B
UpgradeableReaderLock · AsyncRWLock · Proto.Promises 5 NotCancelled 53.909 ns 0.72 -
UpgradeableReaderLock · AsyncRWLock · Pooled 5 NotCancelled 74.742 ns 1.00 -
UpgradeableReaderLock · AsyncRWLock · VS.Threading 5 NotCancelled 2,586.760 ns 34.61 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.49 ns 0.53 -
UpgradedWriterLock · AsyncRWLock · Proto.Promises 0 None 23.76 ns 0.93 -
UpgradedWriterLock · AsyncRWLock · Pooled 0 None 25.64 ns 1.00 -
UpgradedWriterLock · AsyncRWLock · VS.Threading 0 None 1,716.69 ns 66.97 824 B
UpgradedWriterLock · AsyncRWLock · Proto.Promises 0 NotCancelled 24.74 ns 0.97 -
UpgradedWriterLock · AsyncRWLock · Pooled 0 NotCancelled 25.37 ns 1.00 -
UpgradedWriterLock · AsyncRWLock · VS.Threading 0 NotCancelled 1,785.84 ns 70.39 824 B
UpgradedWriterLock · RWLockSlim · System 1 None 34.65 ns 0.63 -
UpgradedWriterLock · AsyncRWLock · Proto.Promises 1 None 44.20 ns 0.81 -
UpgradedWriterLock · AsyncRWLock · Pooled 1 None 54.81 ns 1.00 -
UpgradedWriterLock · AsyncRWLock · VS.Threading 1 None 2,233.12 ns 40.74 1032 B
UpgradedWriterLock · AsyncRWLock · Pooled 1 NotCancelled 65.71 ns 1.00 -
UpgradedWriterLock · AsyncRWLock · Proto.Promises 1 NotCancelled 69.90 ns 1.06 -
UpgradedWriterLock · AsyncRWLock · VS.Threading 1 NotCancelled 2,269.89 ns 34.55 1032 B
UpgradedWriterLock · RWLockSlim · System 2 None 25.47 ns 0.33 -
UpgradedWriterLock · AsyncRWLock · Proto.Promises 2 None 53.43 ns 0.69 -
UpgradedWriterLock · AsyncRWLock · Pooled 2 None 77.54 ns 1.00 -
UpgradedWriterLock · AsyncRWLock · VS.Threading 2 None 2,717.26 ns 35.04 1240 B
UpgradedWriterLock · AsyncRWLock · Proto.Promises 2 NotCancelled 80.98 ns 0.86 -
UpgradedWriterLock · AsyncRWLock · Pooled 2 NotCancelled 94.63 ns 1.00 -
UpgradedWriterLock · AsyncRWLock · VS.Threading 2 NotCancelled 2,780.83 ns 29.39 1240 B
UpgradedWriterLock · RWLockSlim · System 5 None 43.55 ns 0.31 -
UpgradedWriterLock · AsyncRWLock · Proto.Promises 5 None 91.85 ns 0.66 -
UpgradedWriterLock · AsyncRWLock · Pooled 5 None 139.13 ns 1.00 -
UpgradedWriterLock · AsyncRWLock · VS.Threading 5 None 4,235.82 ns 30.45 1864 B
UpgradedWriterLock · AsyncRWLock · Proto.Promises 5 NotCancelled 116.29 ns 0.80 -
UpgradedWriterLock · AsyncRWLock · Pooled 5 NotCancelled 146.08 ns 1.00 -
UpgradedWriterLock · AsyncRWLock · VS.Threading 5 NotCancelled 4,381.78 ns 30.00 1864 B

Benchmark Analysis

Key Findings:

  1. 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.

  2. 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.

  3. Writer priority: The writer-priority design prevents writer starvation but may reduce reader throughput when writers are frequently queued.

  4. 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.

  5. Releaser struct: The value-type Releaser produces no allocation for the lock handle itself. Allocations occur only for the pooled IValueTaskSource when 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 AsyncLock for 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


© 2026 The Keepers of the CryptoHives