Synchronization in async code: lock and SemaphoreSlim

 
 
  • Gérald Barré

This post is part of the series 'Async/await in C#'. Be sure to check out the rest of the blog posts of the series!

Once operations run concurrently, they start touching the same state. The synchronous answer is lock, and it does not work here:

C#
lock (_gate)
{
    await LoadAsync();  // CS1996: Cannot await in the body of a lock statement
}

That is a compiler error, and it is protecting you from something worse than a compiler error.

#Why the compiler refuses

lock compiles to Monitor.Enter and Monitor.Exit, and Monitor is thread affine: the thread that entered must be the thread that exits. Everything about it, including its reentrancy counter, is keyed on a thread.

An await breaks that. The continuation usually resumes on a different thread, so the Monitor.Exit would run on a thread that never entered, throwing SynchronizationLockException. Worse, until then the lock is held by a thread that has moved on to unrelated work, so it is protecting nothing while blocking everyone.

There is no way to make this work, which is why the compiler rejects it outright rather than letting you find out at runtime.

#System.Threading.Lock does not change this

C# 13 and .NET 9 added System.Threading.Lock, and it gets misread as an async lock. It is not:

C#
var gate = new Lock();

lock (gate)
{
    // still no await allowed here
}

What it does give you is a dedicated type instead of locking on object, which means the compiler can stop you locking on something you did not mean to, and it is measurably faster:

MethodMeanRatioAllocated
Interlocked_Increment0.5469 ns1.00-
Monitor_Lock8.0285 ns14.70-
SystemThreadingLock3.9082 ns7.16-
SemaphoreSlim_Sync11.8298 ns21.66-
SemaphoreSlim_Async14.3891 ns26.3572 B
Benchmark project, measured with BenchmarkDotNet

Twice as fast as Monitor uncontended, and it has an EnterScope() returning a disposable for cases where the lock statement does not fit. Use it for new synchronous code. It is still not awaitable.

The other rows are worth reading too. Interlocked is fifteen times cheaper than any lock, which is the argument for using it when your shared state really is one number. And SemaphoreSlim used asynchronously allocates 72 bytes per acquisition even uncontended, because the async method needs its Task.

#SemaphoreSlim is the async lock

SemaphoreSlim counts permits rather than tracking an owning thread, so it does not care which thread releases it. A semaphore with one permit is a mutex you can hold across an await:

C#
await _semaphore.WaitAsync(cancellationToken);
try
{
    await LoadAsync();
}
finally
{
    _semaphore.Release();
}

Three things must be right:

  • WaitAsync, not Wait. The synchronous one blocks a thread, which is the problem we started with.
  • Release in a finally. A missed release permanently removes a permit. Miss enough and the semaphore is a deadlock.
  • new SemaphoreSlim(1, 1). The second argument caps the count, so a stray extra Release throws instead of silently letting two callers in.

Wrapping it makes the finally impossible to forget:

C#
public async ValueTask<Releaser> AcquireAsync(CancellationToken cancellationToken = default)
{
    await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
    return new Releaser(_semaphore);
}

public readonly struct Releaser(SemaphoreSlim semaphore) : IDisposable
{
    public void Dispose() => semaphore.Release();
}
C#
using var guard = await _asyncLock.AcquireAsync(cancellationToken);
await LoadAsync();

The Releaser is a struct, so acquiring allocates nothing beyond what the async method already costs. The sample's tests check that the lock is released even when the body throws.

#What to reach for

SituationUse
A counter or a single referenceInterlocked
Short synchronous critical section, no awaitSystem.Threading.Lock
Critical section containing an awaitSemaphoreSlim(1, 1)
Limiting concurrency to NSemaphoreSlim(N) or Parallel.ForEachAsync
Lazily initialising something asynca cached Task, not a lock
Producer and consumerChannel<T>

That fifth row deserves expanding, because it is the case people reach for a lock and should not:

C#
// Not a lock. The Lazy holds the Task, so every caller awaits the same operation
// and the work happens once.
private readonly Lazy<Task<Config>> _config = new(() => LoadConfigAsync());

public Task<Config> GetConfigAsync() => _config.Value;

The general principle: prefer designs where nothing needs a lock. Immutable state, a single owner per piece of mutable state, a channel between producer and consumer, or a cached Task for one-time work. Locks in async code are correct but they serialise your concurrency, which is usually the thing you were trying to gain.

#The other blocking primitives

Monitor, Mutex, ReaderWriterLockSlim, ManualResetEventSlim, CountdownEvent and Barrier are all synchronous, and all block a thread. In async code they are how you get thread pool starvation.

SemaphoreSlim is the only one in System.Threading with an async wait. For the rest you build your own on TaskCompletionSource or IValueTaskSource, or take a dependency on Microsoft.VisualStudio.Threading, which provides AsyncLazy, AsyncManualResetEvent, AsyncReaderWriterLock and an async-aware JoinableTaskFactory.

#What to take away

  • lock cannot contain an await, because Monitor is thread affine and the continuation resumes elsewhere.
  • System.Threading.Lock is a faster, better-typed Monitor. It is still not awaitable.
  • SemaphoreSlim(1, 1) is the async mutex, because it counts permits rather than tracking an owner.
  • Always WaitAsync, always Release in a finally, always cap the count.
  • A struct Releaser behind using makes the release automatic and costs nothing.
  • Async locks are not reentrant, and taking one twice deadlocks against yourself.
  • Interlocked is fifteen times cheaper than any lock when the state is one value.
  • The best answer is usually a design where no lock is needed at all.

The next post is about a much smaller decision that turns out to matter: whether to write return await or just return the task.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?