How to Throttle Async Tasks with SemaphoreSlim in C
This article explains how to use SemaphoreSlim in C# to
throttle concurrent asynchronous operations. You will learn the core
concepts behind semaphore-based throttling, see a clear code
implementation using WaitAsync() and
Release(), and understand essential best practices for
preventing resource exhaustion when handling heavy I/O operations such
as external API calls or database queries.
Understanding SemaphoreSlim
SemaphoreSlim is a lightweight synchronization primitive
designed specifically for single-process concurrency. Unlike the
traditional Semaphore class, SemaphoreSlim
supports the async/await pattern through its
WaitAsync() method, making it ideal for non-blocking
resource throttling in modern C# applications.
When initializing SemaphoreSlim, you specify the number
of concurrent slots available:
// Allows a maximum of 3 concurrent operations
SemaphoreSlim throttler = new SemaphoreSlim(initialCount: 3, maxCount: 3);Basic Implementation Pattern
To throttle access to an asynchronous operation, follow the standard
WaitAsync and try...finally pattern. Using a
finally block ensures that the semaphore slot is freed even
if the underlying operation throws an exception.
await throttler.WaitAsync();
try
{
await PerformResourceIntensiveOperationAsync();
}
finally
{
throttler.Release();
}Practical Example: Throttling Batch API Requests
A common use case is downloading or processing a large collection of items concurrently without exceeding rate limits or exhausting system sockets.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
public class ResourceThrottler
{
private static readonly HttpClient HttpClient = new HttpClient();
public static async Task ProcessUrlsAsync(IEnumerable<string> urls, int maxConcurrency, CancellationToken cancellationToken = default)
{
using var throttler = new SemaphoreSlim(maxConcurrency, maxConcurrency);
var tasks = urls.Select(async url =>
{
// Wait for an available slot
await throttler.WaitAsync(cancellationToken);
try
{
var response = await HttpClient.GetAsync(url, cancellationToken);
Console.WriteLine($"Fetched {url}: {response.StatusCode}");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Console.WriteLine($"Error fetching {url}: {ex.Message}");
}
finally
{
// Always release the slot
throttler.Release();
}
});
// Wait for all throttled tasks to complete
await Task.WhenAll(tasks);
}
}Best Practices for SemaphoreSlim Throttling
- Always Release in a
finallyBlock: Failing to release a semaphore slot after an exception causes a leak, permanently reducing available concurrency and eventually leading to deadlocks. - Pass CancellationTokens to
WaitAsync: Long-running or queued tasks should be cancelable. Passing aCancellationTokentoWaitAsync()allows waiting tasks to abort cleanly if the application shuts down. - Scope the Lifetime Appropriately: If throttling
requests globally across an entire service (such as requests to a
specific third-party API), share a single
SemaphoreSliminstance as a singleton or static field rather than recreating it per request. - Dispose Correctly:
SemaphoreSlimimplementsIDisposable. Dispose of the instance when its lifecycle ends, but ensure no active or pending tasks are still using it.