DEV Community

Spyros Ponaris
Spyros Ponaris

Posted on

SemaphoreSlim in .NET, a practical guide with the rest of the toolbox

Here is the updated, self contained article with the throttler explanation folded in, plus a short review checklist at the end.

SemaphoreSlim in .NET, a practical guide with the rest of the toolbox

Concurrency control is about letting enough work run in parallel for good throughput, without letting everything run at once and overloading a resource. SemaphoreSlim is the simplest async friendly tool for that job. This guide is practical, production oriented, and closes with a comparison against the other synchronization primitives you will meet in .NET.

What SemaphoreSlim is

A counting gate that allows up to N callers to enter at the same time. It lives in process memory, supports WaitAsync, and is lighter than kernel primitives. Use it to limit concurrency around I O bound work, for example HTTP calls, database work, file system operations, or any throttled external API.

Golden rules

  1. Prefer WaitAsync(token), always Release() in finally.
  2. Keep the critical section as small as possible.
  3. Scope the semaphore to the resource you are protecting, not globally.
  4. Pass a CancellationToken, then decide how to handle a canceled wait.
  5. Do not block async code with .Wait() or .Result.

Remember that lock compiles to Monitor.Enter and Monitor.Exit, and lock is for short synchronous sections only.


Recipes you will reuse

1) Throttle outbound HTTP calls, deep dive on the delegate and cancellation

A small helper that limits how many HTTP calls can run at once, while keeping your calling code clean.

public sealed class HttpThrottler : IDisposable
{
    private readonly SemaphoreSlim _gate;
    private readonly int _capacity;

    public HttpThrottler(int maxConcurrency)
    {
        _capacity = maxConcurrency;
        _gate = new SemaphoreSlim(maxConcurrency, maxConcurrency);
    }

    // Generic work, returns a value
    public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken ct)
    {
        await _gate.WaitAsync(ct);
        try { return await work(ct); }
        finally { _gate.Release(); }
    }

    // Non generic overload for tasks that return no value
    public async Task RunAsync(Func<CancellationToken, Task> work, CancellationToken ct)
    {
        await _gate.WaitAsync(ct);
        try { await work(ct); }
        finally { _gate.Release(); }
    }

    // Optional, avoid waiting forever, return false if you cannot enter in time
    public async Task<bool> TryRunAsync(Func<CancellationToken, Task> work, TimeSpan waitTimeout, CancellationToken ct)
    {
        if (!await _gate.WaitAsync(waitTimeout, ct)) return false;
        try { await work(ct); return true; }
        finally { _gate.Release(); }
    }

    // Quick observability
    public int Capacity => _capacity;
    public int PermitsInUse => _capacity - _gate.CurrentCount;

    public void Dispose() => _gate.Dispose();
}
Enter fullscreen mode Exit fullscreen mode

Why Func<CancellationToken, Task<T>> and a token parameter

  • The throttler owns admission to the scarce resource, your delegate owns the actual work.
  • There are two cancellation moments. First, cancellation while waiting to enter, handled by WaitAsync(ct). Second, cancellation while the work runs, handled by your delegate honoring the same ct. Passing the token into the delegate covers both cases with a single source of truth.

Usage:

var throttler = new HttpThrottler(8);
var urls = Enumerable.Range(0, 200).Select(i => new Uri($"https://api.example.com/items/{i}"));
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

var tasks = urls.Select(u =>
    throttler.RunAsync(async ct =>
    {
        using var resp = await http.GetAsync(u, ct);
        resp.EnsureSuccessStatusCode();
        return await resp.Content.ReadAsStringAsync(ct);
    }, cts.Token));

var results = await Task.WhenAll(tasks);
Enter fullscreen mode Exit fullscreen mode

Behavior notes

  • If cancellation happens before entry, WaitAsync throws and your delegate never executes.
  • If cancellation happens during the delegate, your delegate should observe the token and stop. The permit is released in finally in all cases.
  • Exceptions from the delegate bubble up to the caller. The permit is still released.

2) Keyed critical sections per tenant or customer

Protect the same resource key with its own gate, while allowing other keys to proceed.

public sealed class AsyncKeyedLocks<TKey> where TKey : notnull
{
    private sealed class RefCount
    {
        public readonly SemaphoreSlim Gate = new(1, 1);
        public int Count;
    }

    private readonly ConcurrentDictionary<TKey, RefCount> _map = new();

    public async Task<IDisposable> LockAsync(TKey key, CancellationToken ct)
    {
        var rc = _map.GetOrAdd(key, _ => new RefCount());
        Interlocked.Increment(ref rc.Count);
        await rc.Gate.WaitAsync(ct);
        return new Releaser(key, this);
    }

    private sealed class Releaser : IDisposable
    {
        private readonly TKey _key;
        private readonly AsyncKeyedLocks<TKey> _owner;
        private bool _disposed;
        public Releaser(TKey key, AsyncKeyedLocks<TKey> owner) { _key = key; _owner = owner; }
        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            var rc = _owner._map[_key];
            rc.Gate.Release();
            if (Interlocked.Decrement(ref rc.Count) == 0)
            {
                _owner._map.TryRemove(_key, out _);
                rc.Gate.Dispose();
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

using var _ = await locksByCustomer.LockAsync(customerId, ct);
// only one invoice per customer runs here at a time
Enter fullscreen mode Exit fullscreen mode

3) Bounded parallel pipeline with Channel<T>

When you want natural backpressure and handoff between stages, a bounded channel is often cleaner.

var channel = Channel.CreateBounded<string>(new BoundedChannelOptions(capacity: 200)
{
    FullMode = BoundedChannelFullMode.Wait
});

var writer = Task.Run(async () =>
{
    foreach (var url in urls) await channel.Writer.WriteAsync(url, ct);
    channel.Writer.Complete();
});

var consumers = Enumerable.Range(0, 8).Select(_ => Task.Run(async () =>
{
    await foreach (var url in channel.Reader.ReadAllAsync(ct))
    {
        using var resp = await http.GetAsync(url, ct);
        resp.EnsureSuccessStatusCode();
        // process
    }
}));

await Task.WhenAll(consumers.Append(writer));
Enter fullscreen mode Exit fullscreen mode

4) Protect a read heavy cache with ReaderWriterLockSlim

Many concurrent readers, rare writers. Readers run in parallel, writers are exclusive. Consider upgradeable reads when a read path may promote to a write.

public sealed class CachedCatalog
{
    private readonly ReaderWriterLockSlim _lock = new();
    private Dictionary<int, string> _data = new();

    public string? Get(int id)
    {
        _lock.EnterReadLock();
        try { return _data.TryGetValue(id, out var v) ? v : null; }
        finally { _lock.ExitReadLock(); }
    }

    public void Refresh(Dictionary<int, string> snapshot)
    {
        _lock.EnterWriteLock();
        try { _data = snapshot; }
        finally { _lock.ExitWriteLock(); }
    }
}
Enter fullscreen mode Exit fullscreen mode

5) Protect synchronous code paths, then bridge to async

If an SDK forces sync work, wrap the sync bit inside the semaphore, then call it from async.

private readonly SemaphoreSlim _syncGate = new(1, 1);

public async Task UseLegacySdkAsync(CancellationToken ct)
{
    await _syncGate.WaitAsync(ct);
    try { LegacySdk.DoWork(); }
    finally { _syncGate.Release(); }
}
Enter fullscreen mode Exit fullscreen mode

The rest of the toolbox, when to prefer what

Primitive Async friendly Cross process Reentrancy Typical use
lock or Monitor No No Yes for the same thread Very fast intra process mutual exclusion, short critical sections
SemaphoreSlim Yes No No Limit concurrency for async code, I O throttling
Semaphore No async wait Yes with names No Cross process counting gate
Mutex No async wait Yes Yes per owning thread Cross process exclusive lock, heavy
ReaderWriterLockSlim No async wait No Read mode can be concurrent Read heavy in memory caches
ManualResetEventSlim, AutoResetEvent No async wait No Not applicable Low level signaling, prefer higher level constructs
Channel<T> Yes via async read and write No Not applicable Pipelines with backpressure and bounded capacity
Parallel.ForEachAsync Yes No Not applicable Data parallel loops with a max degree of parallelism
BlockingCollection<T> No async wait No Not applicable Producer consumer in fully synchronous apps

Note for .NET 9 and C# 13
System.Threading.Lock exists as a dedicated lock type. The lock statement can target this type. This does not make lock async aware, it remains for synchronous critical sections.


Pitfalls and how to avoid them

  1. Forgetting Release() creates a leak, eventually everything stalls. Always release in finally.
  2. Await inside lock or Monitor is a design smell. Keep lock blocks synchronous and small.
  3. One global semaphore for unrelated work couples hot paths that should not interact. Scope gates to resources.
  4. ReaderWriterLockSlim can starve writers under heavy reads. If this hurts you, switch to a simple lock or batch writes.
  5. If you need condition waiting or timeouts, use Monitor.Wait and Monitor.Pulse with care, always under the same monitor.

Observability, prove your limit works

public static async Task<int> MaxConcurrentAsync(
    int total, int poolSize, Func<int, Task> work)
{
    var sem = new SemaphoreSlim(poolSize, poolSize);
    int concurrent = 0, maxConcurrent = 0;

    Task RunOne(int i) => Task.Run(async () =>
    {
        await sem.WaitAsync();
        try
        {
            var now = Interlocked.Increment(ref concurrent);
            InterlockedExtensions.Max(ref maxConcurrent, now);
            await work(i);
        }
        finally
        {
            Interlocked.Decrement(ref concurrent);
            sem.Release();
        }
    });

    await Task.WhenAll(Enumerable.Range(0, total).Select(RunOne));
    return maxConcurrent;
}

public static class InterlockedExtensions
{
    public static void Max(ref int target, int value)
    {
        int snapshot;
        while ((snapshot = target) < value)
            Interlocked.CompareExchange(ref target, value, snapshot);
    }
}
Enter fullscreen mode Exit fullscreen mode

Choosing a degree of parallelism

Start simple, then measure.

  • CPU bound work, set the limit near Environment.ProcessorCount.
  • I O bound work, start with 2 to 8 times the logical core count, then tune for latency and external limits.
  • Respect external rate limits, cap concurrency and add a rate limiter if needed.

Quick alternative, no semaphore at all

If you are only parallelizing a simple loop, the built in helper can be clearer.

await Parallel.ForEachAsync(urls,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
    async (url, token) =>
    {
        using var resp = await http.GetAsync(url, token);
        resp.EnsureSuccessStatusCode();
        // process
    });
Enter fullscreen mode Exit fullscreen mode

References

  1. SemaphoreSlim, class reference, Microsoft Learn. ([Microsoft Learn][1])
  2. Semaphore and SemaphoreSlim, concepts, Microsoft Learn. ([Microsoft Learn][2])
  3. Monitor, class reference, Microsoft Learn. ([Microsoft Learn][3])
  4. lock statement, C# language reference, Microsoft Learn. ([Microsoft Learn][5])
  5. ReaderWriterLockSlim, class reference, Microsoft Learn. ([Microsoft Learn][7])
  6. System.Threading.Channels, overview article, Microsoft Learn. ([Microsoft Learn][8])
  7. Parallel.ForEachAsync, API reference, Microsoft Learn. ([Microsoft Learn][10])

Source code :

https://github.com/stevsharp/HttpThrottleDemo

Review checklist

  • The throttler now explains why the delegate accepts a token, and how the two cancellation points work.
  • Overloads were added, one non generic and one timed try version, plus quick observability properties.
  • Keyed exclusivity shows correct lifetime and cleanup of per key semaphores.
  • Pipeline example uses a bounded channel for natural backpressure.
  • Toolbox table calls out async support, cross process scope, and typical use.
  • Pitfalls capture the most common failure modes you will see in production.

Top comments (0)