C# SemaphoreSlim: Limit Async Concurrency
This article explains how SemaphoreSlim controls
concurrency limits for asynchronous methods in C#. It covers the
underlying mechanism of the semaphore's counter, the non-blocking nature
of WaitAsync, the standard try...finally
implementation pattern, and practical scenarios where throttling
asynchronous workloads is essential to maintain application
stability.
Understanding SemaphoreSlim
SemaphoreSlim is a lightweight synchronization primitive
designed for throttling access to a limited pool of resources within a
single process. Unlike the legacy Semaphore class,
SemaphoreSlim supports modern asynchronous programming
models via its WaitAsync method, making it the standard
choice for controlling concurrent async/await
operations in .NET.
The Internal Counter Mechanism
SemaphoreSlim controls concurrency using an internal
counter initialized by the developer:
SemaphoreSlim semaphore = new SemaphoreSlim(initialCount, maxCount);- initialCount: The number of concurrent requests that can be granted immediately.
- maxCount: The maximum number of concurrent requests the semaphore can grant.
Each time a task successfully enters the semaphore, the internal count decreases by one. When the count drops to zero, any subsequent task attempting to enter must wait until an active task exits and increments the counter.
Non-Blocking Execution with WaitAsync
In asynchronous programming, using standard thread-blocking locks
like lock or Monitor.Enter leads to
thread-pool starvation and exceptions when combined with
await. SemaphoreSlim solves this by providing
WaitAsync().
Instead of holding a thread captive while waiting for a slot:
WaitAsync()checks if the current count is greater than zero.- If a slot is available, it decrements the counter and execution continues immediately.
- If no slot is available, it returns an incomplete task and yields the thread back to the .NET thread pool.
- When another operation releases its slot, the waiting task is resumed on an available thread-pool thread.
The Standard Implementation Pattern
To prevent deadlocks and ensure that slots are always returned to the
pool—even when operations fail or are canceled—always call
Release() within a finally block.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
public class ConcurrencyLimiter
{
// Limit concurrency to a maximum of 3 concurrent tasks
private static readonly SemaphoreSlim Semaphore = new SemaphoreSlim(3, 3);
public async Task ProcessItemsAsync(IEnumerable<int> items)
{
var tasks = items.Select(async item =>
{
// Asynchronously wait to enter the semaphore
await Semaphore.WaitAsync();
try
{
await PerformWorkAsync(item);
}
finally
{
// Always release the semaphore slot in a finally block
Semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
private async Task PerformWorkAsync(int item)
{
Console.WriteLine($"Starting task {item}");
await Task.Delay(1000); // Simulate work
Console.WriteLine($"Completed task {item}");
}
}Practical Use Cases
Controlling asynchronous concurrency is critical in several common architectural scenarios:
- Throttling Outbound API Calls: External APIs often
enforce rate limits.
SemaphoreSlimprevents HTTP 429 (Too Many Requests) errors by capping simultaneous connections. - Database Connection Protection: Preventing hundreds of simultaneous async queries from exhausting the database connection pool.
- Disk and File I/O Management: Avoiding disk thrashing caused by simultaneous read/write operations on storage devices.
- Memory Consumption Safeguards: Processing large payloads in batches or with fixed parallelism to keep the application's memory footprint stable.