Locks
editExclusive access with AsyncLock
AsyncLock gives exclusive access to a resource, like the lock statement, but the critical section can contain await:
var asyncLock = new AsyncLock();
var counter = 0;
async Task AccessResourceAsync()
{
using (await asyncLock.LockAsync())
{
// Critical section - only one task can execute this at a time
var value = counter;
await Task.Yield();
counter = value + 1;
}
}
await Task.WhenAll(AccessResourceAsync(), AccessResourceAsync(), AccessResourceAsync());
Assert.Equal(3, counter);- Waiters acquire the lock in the order they called LockAsync.
- The lock is not reentrant: calling LockAsync again from inside the critical section waits for a release that never comes. Keep the code that needs the lock in one place rather than nesting acquisitions.
- Only the first disposal of a lease releases the lock. Disposing the same lease again, or a copy of a disposed lease, does nothing.
new AsyncLock(allowInliningAwaiters: true)lets the next waiter continue synchronously on the thread that releases the lock. By default, its continuation runs asynchronously, so releasing the lock never runs someone else's code.
Acquire the lock only if it is free
TryLock never waits. It returns false when the lock is held, and gives a lease to dispose when it returns true:
var asyncLock = new AsyncLock();
// TryLock never waits: it returns false when the lock is already held
Assert.True(asyncLock.TryLock(out var lease));
using (lease)
{
Assert.False(asyncLock.TryLock(out _));
}
// Disposing the lease released the lock
Assert.True(asyncLock.TryLock(out var secondLease));
secondLease.Dispose();Stop waiting for the lock
Pass a CancellationToken to LockAsync to give up waiting. A canceled wait throws an OperationCanceledException and does not acquire the lock:
var asyncLock = new AsyncLock();
using (await asyncLock.LockAsync())
{
// Another caller gives up waiting for the lock when its token is canceled
using var cts = new CancellationTokenSource();
var waiting = asyncLock.LockAsync(cts.Token).AsTask();
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await waiting);
}Readers and writers with AsyncReaderWriterLock
When most accesses only read the resource, AsyncReaderWriterLock lets several readers in at the same time, and gives writers exclusive access:
var rwLock = new AsyncReaderWriterLock();
var data = new List<int>();
// Multiple readers can execute concurrently
async Task<int> ReadAsync()
{
using (await rwLock.ReaderLockAsync())
{
return data.Count;
}
}
// Only one writer can execute at a time
async Task WriteAsync(int value)
{
using (await rwLock.WriterLockAsync())
{
data.Add(value);
}
}
await Task.WhenAll(WriteAsync(1), WriteAsync(2));
Assert.Equal([2, 2], await Task.WhenAll(ReadAsync(), ReadAsync()));- ReaderLockAsync and WriterLockAsync return a releaser; dispose it to release the lock. Both accept a
CancellationToken. - Writers have priority: once a writer waits, new readers queue behind it, so a steady stream of readers cannot starve the writers.
- Always await the returned value and dispose the releaser. A wait that is started and never awaited still gets the lock when its turn comes, and nothing releases it. To give up on a wait, cancel its token. To start a wait and await it later, store
ReaderLockAsync().AsTask()rather than the value task itself.
SemaphoreSlim in a using block
The SemaphoreSlim extensions wait on the semaphore and return an object that releases it when disposed, so a using block releases the semaphore even when the code inside throws. Use a semaphore to let a fixed number of callers in at the same time:
using var semaphore = new SemaphoreSlim(initialCount: 2);
// Disposing the returned object releases the semaphore, even when the block throws
using (await semaphore.DisposableWaitAsync())
{
Assert.Equal(1, semaphore.CurrentCount);
}
Assert.Equal(2, semaphore.CurrentCount);| Method | Returns | Disposing it more than once |
|---|---|---|
| DisposableWait, DisposableWaitAsync | An IDisposable | Releases the semaphore once |
| DisposableWaitUnsafe, DisposableWaitUnsafeAsync | A SemaphoreDisposer struct | Releases the semaphore again for each copy |
The unsafe variants avoid allocating an object, but every copy of the struct releases the semaphore: dispose exactly one copy, exactly once. Prefer DisposableWait and DisposableWaitAsync unless the allocation matters. All of them accept a CancellationToken.
Choose a lock
| You need | Use |
|---|---|
Exclusive access, with await inside | AsyncLock |
Many readers or one writer, with await inside | AsyncReaderWriterLock |
| Up to N callers at the same time | SemaphoreSlim with DisposableWaitAsync |
| One caller at a time per key | KeyedAsyncLock<TKey>, or KeyedLock<TKey> for synchronous code. See Keyed locks |