Using C# SemaphoreSlim to Limit Concurrent Access
c# semaphoreslim: Learn how C# SemaphoreSlim limits concurrent access to shared resources, with async waiting, cancellation, and practical throttling examples.
c# semaphoreslim requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
SemaphoreSlim is the synchronization primitive you reach for when you need to limit how many threads or async operations can enter a section of code at the same time. A lock statement allows only one thread at a time, but a semaphore can allow N concurrent entrants, which makes it useful for throttling database connections, HTTP clients, or background workers.
How SemaphoreSlim Controls Concurrent Access
A semaphore maintains a count. When you create a SemaphoreSlim, you pass an initial count that represents the number of available slots. Each call to Wait() or WaitAsync() decrements the count. Each call to Release() increments it. If the count reaches zero, subsequent waiters block until another thread releases a slot.
var semaphore = new SemaphoreSlim(initialCount: 3);
The second constructor parameter sets the maximum count:
var semaphore = new SemaphoreSlim(initialCount: 3, maxCount: 3);
The maximum count prevents Release() from pushing the count above a safe ceiling. If you call Release() more times than the maximum allows, the method throws SemaphoreFullException. That exception is a signal that your release logic is unbalanced — either you released without waiting, or you released twice for a single acquisition.
Basic Usage: Wait, Release, and the Count
The synchronous path is straightforward. A thread calls Wait() before accessing the protected resource and Release() in a finally block so the slot is returned even if an exception occurs.
private readonly SemaphoreSlim _semaphore = new(2); public void ProcessFile(string path) { _semaphore.Wait(); try { // Open the file, transform it, write it back. } finally { _semaphore.Release(); } }
With an initial count of 2, two threads can be inside the try block at the same time. A third thread blocks in Wait() until one of the first two calls Release(). The finally block is not optional here; if an exception escapes the try block without a release, the semaphore permanently loses a slot and the effective capacity drops.
Using WaitAsync for Asynchronous Code
The synchronous Wait() blocks the calling thread, which is wasteful in async code. WaitAsync() returns a Task that completes when a slot becomes available, so the thread can return to the thread pool while waiting.
public async Task SendAsync(HttpClient client, string payload) { await _semaphore.WaitAsync(); try { await client.PostAsync("https://example.com/api", new StringContent(payload)); } finally { _semaphore.Release(); } }
WaitAsync also accepts a CancellationToken, which lets you abort the wait when a shutdown or timeout occurs:
public async Task SendAsync(HttpClient client, string payload, CancellationToken token) { await _semaphore.WaitAsync(token); try { await client.PostAsync("https://example.com/api", new StringContent(payload), token); } finally { _semaphore.Release(); } }
If the token is cancelled while the wait is pending, WaitAsync throws OperationCanceledException and the semaphore count is unchanged. That means you do not need to release in the catch path — the slot was never acquired.
Practical Scenarios for SemaphoreSlim
A common use is limiting the number of concurrent HTTP requests to an external API that has a rate limit. Instead of building a queue, you wrap the request in a semaphore with a count matching the API's allowed concurrency.
Another scenario is a connection pool. If you have a limited set of database connections or gRPC channels, a semaphore with a count equal to the pool size prevents callers from exhausting the pool.
A third scenario is throttling CPU-bound background work. When a batch job spawns many tasks, a semaphore can cap how many tasks run simultaneously, preventing memory pressure and thread pool starvation.
Performance Characteristics and Operational Considerations
SemaphoreSlim is lighter than the classic Semaphore because it uses a spin-wait loop before falling back to a kernel wait. For short critical sections, the spin phase avoids the cost of a kernel transition. For long waits, the kernel wait releases the CPU.
The CurrentCount property gives you a snapshot of available slots. It is useful for logging and diagnostics, but it is not a reliable coordination mechanism — the value can change between the read and the next operation.
One operational concern is fairness. SemaphoreSlim does not guarantee FIFO ordering. If your workload depends on strict ordering, you need an explicit queue. In practice, most throttling scenarios do not require fairness.
Another concern is disposal. SemaphoreSlim implements IDisposable because it may hold a kernel wait handle. In long-lived services, you typically create the semaphore once and keep it for the process lifetime, so disposal is rarely exercised. If you create semaphores per request, dispose them when the request completes.
Common Mistakes and Failure Modes
The most common mistake is forgetting to release. This usually happens when an early return path skips the finally block, or when the developer assumes WaitAsync succeeded when it actually threw. Always pair acquisition with release in a try/finally.
A second mistake is releasing without acquiring. Calling Release() when the count is already at the maximum throws SemaphoreFullException. This can happen when a retry loop calls Release() in a catch block even though the wait never completed.
A third mistake is using SemaphoreSlim where a lock would be simpler. If you only ever need one entrant, a semaphore with count 1 works, but lock is cheaper and clearer. Reserve the semaphore for cases where the concurrency limit is greater than one or where you need async waiting.
SemaphoreSlim vs. Other Synchronization Primitives
| Primitive | Concurrency limit | Async wait | Cross-process | Typical use |
|---|---|---|---|---|
lock | 1 | No | No | Short critical sections |
SemaphoreSlim | Configurable | Yes | No | Throttling within one process |
Semaphore | Configurable | No | Yes | Cross-process limits |
Mutex | 1 | No | Yes | Cross-process mutual exclusion |
SemaphoreSlim is the right choice when you need a configurable concurrency limit, async waiting, and no cross-process requirement. If you need to coordinate across processes, the classic Semaphore is the correct primitive, but it does not offer WaitAsync.
For async throttling specifically, SemaphoreSlim is the only built-in primitive that combines a configurable count with WaitAsync. That combination is why it appears so often in production code that wraps external calls, queues background work, or manages resource pools.