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 finally Block: 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 a CancellationToken to WaitAsync() 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 SemaphoreSlim instance as a singleton or static field rather than recreating it per request.
  • Dispose Correctly: SemaphoreSlim implements IDisposable. Dispose of the instance when its lifecycle ends, but ensure no active or pending tasks are still using it.