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
- Prefer
WaitAsync(token), alwaysRelease()infinally. - Keep the critical section as small as possible.
- Scope the semaphore to the resource you are protecting, not globally.
- Pass a
CancellationToken, then decide how to handle a canceled wait. - 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();
}
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 samect. 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);
Behavior notes
- If cancellation happens before entry,
WaitAsyncthrows and your delegate never executes. - If cancellation happens during the delegate, your delegate should observe the token and stop. The permit is released in
finallyin 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();
}
}
}
}
Usage:
using var _ = await locksByCustomer.LockAsync(customerId, ct);
// only one invoice per customer runs here at a time
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));
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(); }
}
}
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(); }
}
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
- Forgetting
Release()creates a leak, eventually everything stalls. Always release infinally. - Await inside
lockorMonitoris a design smell. Keeplockblocks synchronous and small. - One global semaphore for unrelated work couples hot paths that should not interact. Scope gates to resources.
-
ReaderWriterLockSlimcan starve writers under heavy reads. If this hurts you, switch to a simplelockor batch writes. - If you need condition waiting or timeouts, use
Monitor.WaitandMonitor.Pulsewith 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);
}
}
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
});
References
- SemaphoreSlim, class reference, Microsoft Learn. ([Microsoft Learn][1])
- Semaphore and SemaphoreSlim, concepts, Microsoft Learn. ([Microsoft Learn][2])
- Monitor, class reference, Microsoft Learn. ([Microsoft Learn][3])
- lock statement, C# language reference, Microsoft Learn. ([Microsoft Learn][5])
- ReaderWriterLockSlim, class reference, Microsoft Learn. ([Microsoft Learn][7])
- System.Threading.Channels, overview article, Microsoft Learn. ([Microsoft Learn][8])
- 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)