📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is SemaphoreSlim, and how do you use it as an async lock or to throttle concurrent async work?

SemaphoreSlim internals, WaitAsync with timeouts and cancellation, async mutual exclusion, throttling HTTP calls, non-reentrancy and release discipline.

Mid-Level Coding Intermediate async semaphoreslim synchronization throttling

🎯 Key points

  • SemaphoreSlim is a lightweight in-process semaphore with WaitAsync
  • Count 1 gives an async lock; count N throttles concurrency
  • Always pair a successful wait with one Release in finally
  • Not re-entrant and has no owner
  • Pass cancellation tokens or timeouts to WaitAsync
  • Share one instance for the resource being protected
  • Parallel.ForEachAsync is often simpler for loops

⏱ 30-second answer

SemaphoreSlim is a lightweight, in-process counting semaphore that supports WaitAsync, so it works with async/await. With a count of 1 it is an async lock: await gate.WaitAsync(ct); try { ... } finally { gate.Release(); }. With a count of N it limits how many async operations run at once, for example ten concurrent calls to a partner API. It is not re-entrant and has no owner, so calling it again from inside the protected section deadlocks, and every successful wait must be paired with exactly one Release in finally.

🗣 Full answer (senior)

How I would answer this in an interview

"SemaphoreSlim is a lightweight, in-process counting semaphore with WaitAsync, so waiting doesn't block a thread. That gives it two everyday jobs. With a count of 1 it's an async lock, the replacement for lock when the critical section contains await. With a count of N it's a throttle: at most N concurrent calls to a partner API, the database or the file system. The pattern is always the same: await gate.WaitAsync(ct), then try { ... } finally { gate.Release(); }, with the try starting only after the wait succeeds. Two things catch people out. It's not re-entrant: if the same flow waits on it again while holding it, it deadlocks itself. And it has no owner, so every successful wait must be paired with exactly one Release. I share one instance per protected resource, always pass a cancellation token or timeout, and for simple loops I often prefer Parallel.ForEachAsync with MaxDegreeOfParallelism."


1. Where it fits

lock / Lock SemaphoreSlim Semaphore (kernel)
await inside the protected section No Yes No
Async waiting No WaitAsync No
Count > 1 (throttling) No Yes Yes
Re-entrant Yes No No
Ownership / thread affinity Yes No No
Cross-process No No Named (Windows)
Cost Lowest Low (spins, then waits) Kernel call

2. Async lock (count 1)

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

public async Task RefreshAsync(CancellationToken ct)
{
    await _gate.WaitAsync(ct);           // may throw OperationCanceledException: nothing to release
    try
    {
        await ReloadCacheAsync(ct);
    }
    finally
    {
        _gate.Release();
    }
}

3. Throttle (count N)

private static readonly SemaphoreSlim PartnerLimit = new(10, 10);   // shared across requests

public async Task<Quote> GetQuoteAsync(string sku, CancellationToken ct)
{
    if (!await PartnerLimit.WaitAsync(TimeSpan.FromSeconds(2), ct))
        throw new TimeoutException("Partner API is saturated");      // fail fast rather than queue forever
    try { return await _client.GetQuoteAsync(sku, ct); }
    finally { PartnerLimit.Release(); }
}

4. API details that matter

Member Behaviour
WaitAsync() Waits indefinitely
WaitAsync(CancellationToken) Throws OperationCanceledException if cancelled while waiting
WaitAsync(TimeSpan[, ct]) Returns bool: release only if true
Release() / Release(n) Returns previous count; above maxCount → SemaphoreFullException
CurrentCount Free slots: useful as a saturation metric
AvailableWaitHandle Lazily created WaitHandle for interop
Ordering Not guaranteed FIFO: don't depend on it
Dispose Only when nobody can be waiting (usually long-lived; often never disposed)

5. Choosing a throttling approach

Pattern Best for
Shared SemaphoreSlim Many code paths sharing one limit (all calls to one dependency)
Parallel.ForEachAsync A loop over a known collection
Channel<T> with N readers Long-running pipelines with backpressure
System.Threading.RateLimiting / ASP.NET Core rate limiter Requests per time window, per-client limits, queueing policies
Polly / Microsoft.Extensions.Http.Resilience bulkhead Concurrency limit + queue + rejection policy as part of a resilience pipeline

Production example

Our [configuration cache] wrapped a refresh in lock and, to make that possible, called the config service with HttpClient.GetAsync(...).Result. Every [five minutes], when the cache expired, the API stalled: the ThreadPool queue length spiked and requests timed out. A dump showed dozens of threads blocked on the lock and the owner blocked inside .Result. I replaced it with a SemaphoreSlim(1, 1): WaitAsync with the request's token, a double-check after acquiring (so only one caller reloads), and serving the stale value to everyone else while the refresh ran. The stalls disappeared, and we added a load test around cache expiry plus an analyser rule against .Result.

🔍 What the interviewer is checking

Checks practical async synchronisation skills.

Green flags

  • Writes the WaitAsync/try/finally Release pattern correctly.
  • Uses it for throttling with a shared instance.
  • Knows it is not re-entrant.
  • Mentions cancellation and Parallel.ForEachAsync as an alternative.

Red flags

  • Uses lock around awaits or .Result.
  • Releases in the wrong place.

📚 Learn in depth

1. How it works inside

SemaphoreSlim
 ├─ m_currentCount        free slots
 ├─ m_maxCount
 ├─ m_lockObjAndDisposed  internal Monitor for sync waiters
 └─ m_asyncHead/m_asyncTail  linked list of TaskNode (async waiters)

WaitAsync():
   lock internal; if count > 0 -> count--, return completed task (no allocation in common case)
   else -> append TaskNode (a Task<bool>) to the async list, register cancellation/timeout, return it
Release():
   lock internal; if async waiters -> complete the head TaskNode (asynchronously) ; else count++
  • Uncontended WaitAsync returns an already-completed cached task.
  • Synchronous Wait() spins briefly before blocking on the internal monitor.
  • Released async waiters are completed so their continuations run on the pool, not inline inside Release.

2. Non-reentrancy deadlock

public async Task SaveAsync()
{
    await _gate.WaitAsync();
    try { await ValidateAsync(); }   // ValidateAsync also does await _gate.WaitAsync() -> waits forever
    finally { _gate.Release(); }
}

Design rule: public entry points take the gate; private helpers assume it's held (document it, or pass a token/marker parameter).

3. Release discipline

// WRONG: Release runs even if WaitAsync threw (cancelled) -> count increases
try
{
    await _gate.WaitAsync(ct);
    await WorkAsync(ct);
}
finally { _gate.Release(); }

// RIGHT
await _gate.WaitAsync(ct);
try { await WorkAsync(ct); }
finally { _gate.Release(); }

Passing maxCount to the constructor turns an accidental extra release into a loud SemaphoreFullException instead of silently raising concurrency.

4. A disposable helper

public static class SemaphoreExtensions
{
    public static async ValueTask<Releaser> LockAsync(this SemaphoreSlim s, CancellationToken ct = default)
    {
        await s.WaitAsync(ct).ConfigureAwait(false);
        return new Releaser(s);
    }

    public readonly struct Releaser(SemaphoreSlim s) : IDisposable
    {
        public void Dispose() => s.Release();
    }
}

using (await _gate.LockAsync(ct))
{
    await SaveAsync(ct);
}

5. Per-key locks

To serialise work per entity (per customer id), people create ConcurrentDictionary<string, SemaphoreSlim>. The trap: entries are never removed and the dictionary grows forever. Use reference counting and removal, a striped approach (semaphores[hash % N]), or a library such as AsyncKeyedLock.

6. Observability

Export CurrentCount (or max - CurrentCount as "in use") and wait time as metrics. A throttle that's always at zero free slots means the dependency is the bottleneck, or the limit is too low.

7. Version notes

Version Change
.NET Framework 4.0 SemaphoreSlim
.NET Framework 4.5 WaitAsync
.NET Core 2.x+ Lower-allocation async waits
.NET 6 Parallel.ForEachAsync as a loop alternative
.NET 7 System.Threading.RateLimiting and ASP.NET Core rate limiting
.NET 10 Same API

8. Try it yourself

  1. Run the code example: peak concurrency is 5 and the cache loads once.
  2. Remove the double-check in RefreshingCache and count loads.
  3. Put the WaitAsync inside the try, cancel some waits, and watch CurrentCount exceed the initial count (or throw with maxCount).
  4. Call a method that re-acquires the same gate and observe the deadlock.
  5. Rewrite the 50-task loop with Parallel.ForEachAsync(..., new ParallelOptions { MaxDegreeOfParallelism = 5 }, ...).
  6. Implement the LockAsync helper and use it with using.

💻 Code

using System.Threading;

var limiter = new SemaphoreSlim(initialCount: 5, maxCount: 5);
int inside = 0, peak = 0;

var tasks = Enumerable.Range(1, 50).Select(async id =>
{
    await limiter.WaitAsync();
    try
    {
        int now = Interlocked.Increment(ref inside);
        InterlockedMax(ref peak, now);
        await Task.Delay(200); // stands in for an HTTP call
    }
    finally
    {
        Interlocked.Decrement(ref inside);
        limiter.Release();
    }
});

await Task.WhenAll(tasks);
Console.WriteLine($"Peak concurrency: {peak}"); // 5

var cache = new RefreshingCache();
await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => cache.GetAsync()));
Console.WriteLine($"Loads: {cache.Loads}"); // 1

static void InterlockedMax(ref int target, int value)
{
    int current;
    while (value > (current = Volatile.Read(ref target)) &&
           Interlocked.CompareExchange(ref target, value, current) != current) { }
}

public sealed class RefreshingCache
{
    private readonly SemaphoreSlim _gate = new(1, 1);
    private string? _value;
    public int Loads;

    public async Task<string> GetAsync(CancellationToken ct = default)
    {
        if (_value is { } v) return v;
        await _gate.WaitAsync(ct);
        try
        {
            if (_value is { } again) return again; // double-check after acquiring
            await Task.Delay(100, ct);             // load once
            Interlocked.Increment(ref Loads);
            return _value = "loaded";
        }
        finally
        {
            _gate.Release();
        }
    }
}

⚠ Common mistakes

1. Releasing after a cancelled or timed-out wait

What happens: WaitAsync is inside the try, or the bool result of a timed wait is ignored. Why it is wrong: a slot that was never taken is returned: concurrency silently increases. Do instead: start the try after a successful wait; release only when the timed wait returned true.

2. Re-entering the same SemaphoreSlim(1, 1)

What happens: a method holding the gate calls another method that waits on it. Why it is wrong: not re-entrant: self-deadlock. Do instead: acquire once at the public entry point; helpers assume it's held.

3. Synchronous Wait() in async code

What happens: _gate.Wait() inside an async method. Why it is wrong: blocks a pool thread; contributes to starvation. Do instead: await _gate.WaitAsync(ct).

4. A new semaphore per request

What happens: var limiter = new SemaphoreSlim(10); inside the handler. Why it is wrong: every request has its own limit, so nothing is throttled. Do instead: share one instance per protected resource (field, static or singleton).

5. No cancellation or timeout

What happens: await _gate.WaitAsync(); in request code. Why it is wrong: requests queue forever when the dependency is slow; client disconnects don't free waiters. Do instead: pass the request token and/or a timeout and fail fast.

6. Doing slow unrelated work inside the protected section

What happens: logging, serialisation and other calls happen while holding a count-1 gate. Why it is wrong: lengthens hold time and reduces throughput. Do instead: keep the protected section minimal.

7. Unbounded per-key semaphore dictionaries

What happens: ConcurrentDictionary<string, SemaphoreSlim> grows with every key. Why it is wrong: memory leak. Do instead: reference-counted removal, striping, or a dedicated library.

8. Using it for cross-process coordination

What happens: SemaphoreSlim expected to limit work across pods. Why it is wrong: in-process only. Do instead: distributed limiter, queue with fixed consumers, or DB/Redis-based limits.

9. Expecting FIFO fairness

What happens: code assumes waiters are served in arrival order. Why it is wrong: ordering isn't guaranteed. Do instead: use a Channel<T> when order matters.

10. Disposing a semaphore that may still have waiters

What happens: a shared semaphore is disposed during shutdown while requests wait. Why it is wrong: waiters get ObjectDisposedException or hang. Do instead: keep it alive for the app's lifetime; cancel waiters via tokens on shutdown.

🏛 Senior insight: trade-offs

SemaphoreSlim is the everyday async coordination tool. I keep the protected section minimal, always pass a cancellation token, and expose CurrentCount as a metric so saturation is visible.

Use Configuration
Async mutual exclusion new SemaphoreSlim(1, 1)
Limit downstream calls new SemaphoreSlim(n, n) shared
Cache stampede guard Count 1 plus double-check
Per-key locks Dictionary of semaphores with cleanup, or a library

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares with the kernel Semaphore Why: Checks cross-process semaphores
    How to answer

    A semaphore holds a count of available slots: WaitOne takes a slot (blocking if the count is zero) and Release returns one, so up to N threads can be inside at once. System.Threading.Semaphore is the kernel-backed version; it can be named for cross-process use on Windows (named semaphores are not supported on Linux or macOS). Unlike a Mutex, it has no owner: any thread can release it, and it is not re-entrant. Releasing past the maximum throws SemaphoreFullException. For in-process and async throttling, use SemaphoreSlim instead.

  2. When: Candidate explains why lock cannot be used Why: Checks lock rules
    How to answer

    lock (x) { ... } gives mutual exclusion: only one thread at a time runs the block for the same lock object, and entering and leaving act as memory barriers. For a normal object the compiler emits Monitor.Enter(x, ref lockTaken) in a try/finally with Monitor.Exit. Since .NET 9 and C# 13, if x is a System.Threading.Lock, the compiler instead uses x.EnterScope() and disposes the scope, which is a dedicated, slightly faster lock type. Locks are re-entrant on the same thread, you cannot await inside one (CS1996), and you should lock on a private, readonly object and keep the block short.

  3. When: Candidate uses WaitAsync with Task.WhenAll Why: Checks Task fundamentals
    How to answer

    A Task is an object that represents an operation that will complete in the future, with a status, an optional exception and the ability to attach continuations. It is not a thread. Some tasks run code on a pool thread (Task.Run, called delegate tasks); many represent work with no thread at all, such as a pending network read, a timer (Task.Delay) or a TaskCompletionSource (promise tasks). You compose tasks with await, Task.WhenAll, Task.WhenAny and WaitAsync, and avoid blocking on them.

🛠 Real-world problems: how you handle them

Cache refresh lock blocking threads
Situation
A [configuration cache] used `lock` around a synchronous `.Result` HTTP refresh to prevent duplicate loads.
Signals
ThreadPool queue length spiked every [five minutes] when the cache expired; requests timed out.
Investigation
A dump showed dozens of threads blocked on the lock and one blocked in `.Result`.
Resolution
Replaced it with `SemaphoreSlim(1, 1)`, `WaitAsync` and a double-check, and served the stale value while refreshing.
Prevention
Added an analyser rule against `.Result` and a load test around cache expiry.

How to tell it (STAR)

Every [five minutes] our API stalled when the config cache expired. The dump showed threads queued on a lock while one thread blocked on an HTTP call inside it. I switched to an async semaphore with a double-check and served stale data during refresh. The stalls disappeared.

↩ Usually asked after

More in Synchronization

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me