SemaphoreSlim is the async-compatible synchronisation primitive in .NET. Unlike lock (which cannot be used with await), SemaphoreSlim provides WaitAsync — making it the right tool for limiting concurrent access to resources in asynchronous code.
Why Not lock?
The lock keyword works by blocking the current thread until the lock is acquired. You cannot await inside a lock block — the compiler prevents it because releasing the lock on a different thread than it was acquired on leads to undefined behaviour:
// This will not compile
lock (_syncRoot)
{
await _database.SaveAsync(); // Error: cannot await in lock body
}
SemaphoreSlim solves this by providing an async wait:
private readonly SemaphoreSlim _semaphore = new(1, 1);
await _semaphore.WaitAsync();
try
{
await _database.SaveAsync();
}
finally
{
_semaphore.Release();
}
Mutual Exclusion (1,1)
A semaphore initialised with (1, 1) acts as an async mutex — only one caller can enter at a time:
public class CachedConfigService
{
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private AppConfig? _cached;
private DateTime _lastRefresh;
public async Task<AppConfig> GetConfigAsync(CancellationToken ct)
{
if (_cached is not null && DateTime.UtcNow - _lastRefresh < TimeSpan.FromMinutes(5))
return _cached;
await _refreshLock.WaitAsync(ct);
try
{
// Double-check after acquiring the lock
if (_cached is not null && DateTime.UtcNow - _lastRefresh < TimeSpan.FromMinutes(5))
return _cached;
_cached = await LoadConfigFromDatabaseAsync(ct);
_lastRefresh = DateTime.UtcNow;
return _cached;
}
finally
{
_refreshLock.Release();
}
}
}
The double-check pattern prevents multiple callers from redundantly refreshing the config when the first one is already doing so.
Throttling Concurrent Operations
The real power of SemaphoreSlim is limiting concurrency to N. Set the initial count to the maximum number of concurrent operations you want:
public class ThrottledApiClient
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _throttle = new(5); // Max 5 concurrent requests
public ThrottledApiClient(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<string> GetAsync(string url, CancellationToken ct)
{
await _throttle.WaitAsync(ct);
try
{
return await _httpClient.GetStringAsync(url, ct);
}
finally
{
_throttle.Release();
}
}
}
If 20 callers invoke GetAsync simultaneously, only 5 HTTP requests run at a time. The rest await asynchronously without blocking any threads.
Throttling a Batch of Tasks
Combine SemaphoreSlim with Task.WhenAll to process a collection with bounded concurrency:
public async Task ProcessAllAsync(IEnumerable<WorkItem> items, CancellationToken ct)
{
using var throttle = new SemaphoreSlim(10);
var tasks = items.Select(async item =>
{
await throttle.WaitAsync(ct);
try
{
await ProcessItemAsync(item, ct);
}
finally
{
throttle.Release();
}
});
await Task.WhenAll(tasks);
}
Note that all tasks are created immediately (the Select is eager), but only 10 execute their HTTP/IO work concurrently thanks to the semaphore gate.
Timeouts
WaitAsync accepts a TimeSpan for timeout-based acquisition:
if (await _semaphore.WaitAsync(TimeSpan.FromSeconds(5), ct))
{
try
{
await DoWorkAsync(ct);
}
finally
{
_semaphore.Release();
}
}
else
{
_logger.LogWarning("Could not acquire semaphore within 5 seconds");
throw new TimeoutException("Resource is busy");
}
The boolean return tells you whether the semaphore was acquired. If it returns false, you must not call Release — you did not acquire the semaphore.
Common Mistakes
Forgetting to release. Always use try/finally. If an exception occurs between WaitAsync and Release without a finally block, the semaphore count is permanently decremented, eventually deadlocking.
Releasing without acquiring. If WaitAsync times out or is cancelled, calling Release increments the semaphore beyond its intended maximum. This allows more concurrent access than configured.
Using SemaphoreSlim as a long-lived lock. Semaphores do not track ownership. Any thread can release a semaphore that any other thread acquired. If you need reentrancy or ownership tracking, SemaphoreSlim is the wrong tool.
Disposing SemaphoreSlim
SemaphoreSlim implements IDisposable. Dispose it when you are done, particularly in services with bounded lifetimes:
public class MyService : IDisposable
{
private readonly SemaphoreSlim _semaphore = new(10);
public void Dispose()
{
_semaphore.Dispose();
}
}
For singleton services in ASP.NET Core, disposal happens at application shutdown. For scoped services, consider whether a shared semaphore (injected as a singleton) is more appropriate than a per-request instance.