Async .NET · Lesson 21

Bounded async access with SemaphoreSlim

Limit active operations without blocking a thread for each waiting task.

How it works

A SemaphoreSlim can limit how many operations enter a protected section. WaitAsync waits for a permit without synchronously blocking a thread. The sample starts four operations with only two permits, so two can enter while the remaining operations wait. This is a concurrency bound, not a grouping of input records.

The explicit gate holds the first entered operations until all four tasks have been created. That makes the observed peak of two deterministic without relying on timer races. A lock protects the small diagnostic counters when continuations can run on different desktop threads; the same code does not assume that browser execution uses a desktop thread pool.

Release belongs in finally so ordinary exceptions from the protected work do not permanently consume a permit. Releasing only after a successful return can leave later operations waiting forever after an earlier failure. Acquire and release should be paired around exactly the work whose concurrency is being bounded.

WhenAll returns results in the order of its supplied tasks, even though work can finish in a different order. The printed one, two, three, four therefore should not be interpreted as evidence of sequential execution. The separate peak counter records the specific concurrency fact this example intends to demonstrate.

Practice by changing the permit count to one, then to three, and predicting the peak. Add a controlled failure after acquisition and verify that later work still completes because finally releases the slot. Handle the resulting group failure separately from the semaphore's lifetime.

Finally, pass an owning cancellation token into WaitAsync and any cancelable work. Decide what happens to callers waiting for a slot when the workflow ends. Dispose the semaphore only after all operations that use it have finished; disposal is not a substitute for canceling and observing the tasks that currently own or await its permits.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.CompilerServices;

using var slots = new SemaphoreSlim(2);
var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
object sync = new();
int active = 0, peak = 0;
Task<int>[] tasks = Enumerable.Range(1, 4).Select(Work).ToArray();
gate.SetResult(true);
Console.WriteLine(string.Join(",", await Task.WhenAll(tasks)));
Console.WriteLine(peak);
async Task<int> Work(int value) {
    await slots.WaitAsync();
    try {
        lock (sync) { active++; peak = Math.Max(peak, active); }
        await gate.Task;
        return value;
    }
    finally { lock (sync) { active--; } slots.Release(); }
}

Expected output

1,2,3,4
2
Try this
Acquire asynchronously and release in finally after the protected work.