C# SemaphoreSlim
last modified October 4, 2026
C# SemaphoreSlim tutorial shows how to limit concurrent access to a resource
with the SemaphoreSlim class.
A semaphore keeps a count of permits. A task that calls Wait or
WaitAsync takes one permit and continues; when the count is already
zero, the call blocks until another task returns a permit with
Release. SemaphoreSlim is the lightweight, in-process
semaphore. Its asynchronous WaitAsync method can be awaited without
blocking a thread, which makes it the replacement for the lock
statement in asynchronous code. A semaphore created with
new SemaphoreSlim(1, 1) has a single permit, so it behaves like a
mutex or a lock.
C# SemaphoreSlim example
In the first example, a semaphore with one permit serializes access to a shared counter.
using System.Threading;
using System.Threading.Tasks;
var semaphore = new SemaphoreSlim(1, 1);
int counter = 0;
async Task WorkAsync(string name)
{
await semaphore.WaitAsync();
try
{
Console.WriteLine($"{name} entered");
counter++;
await Task.Delay(200);
Console.WriteLine($"{name} leaving");
}
finally
{
semaphore.Release();
}
}
await Task.WhenAll(WorkAsync("task 1"), WorkAsync("task 2"));
Console.WriteLine($"Counter: {counter}");
Console.WriteLine($"CurrentCount: {semaphore.CurrentCount}");
The semaphore is created with an initial count of one and a maximum count of one, so a single permit exists.
var semaphore = new SemaphoreSlim(1, 1);
The first task takes the permit with WaitAsync. The second task
calls the same method while the count is zero, so its await does not complete
until the first task returns the permit.
await semaphore.WaitAsync();
try
{
...
}
finally
{
semaphore.Release();
}
The Release call runs in a finally block, so the permit
is returned even when the protected code throws. The
CurrentCount property reports how many permits are available at the
moment.
$ dotnet run task 1 entered task 1 leaving task 2 entered task 2 leaving Counter: 2 CurrentCount: 1
The two tasks never run their critical sections at the same time: the second one enters only after the first one has left. The counter is therefore updated without a race, and one permit is free again at the end.
C# SemaphoreSlim as an async lock
The lock statement releases its lock when the block ends, and the
compiler has to know where that block ends. For this reason an
await inside a lock block is not allowed and produces
the compiler error CS1996.
lock (_syncRoot)
{
await Task.Delay(100); // error CS1996
}
A SemaphoreSlim created with new SemaphoreSlim(1, 1) is
the asynchronous equivalent of lock: it grants exclusive access,
but it can be awaited.
using System.Threading;
using System.Threading.Tasks;
var cache = new AsyncCounter();
await Task.WhenAll(cache.UpdateAsync("task 1"), cache.UpdateAsync("task 2"));
Console.WriteLine($"Value: {cache.Value}");
sealed class AsyncCounter
{
private readonly SemaphoreSlim _gate = new(1, 1);
private int _value;
public int Value => _value;
public async Task UpdateAsync(string name)
{
await _gate.WaitAsync();
try
{
Console.WriteLine($"{name} reads {_value}");
await Task.Delay(200);
_value++;
Console.WriteLine($"{name} writes {_value}");
}
finally
{
_gate.Release();
}
}
}
The _gate field holds one permit and guards the read-modify-write
sequence around the await, which a lock statement
cannot protect.
$ dotnet run task 1 reads 0 task 1 writes 1 task 2 reads 1 task 2 writes 2 Value: 2
Dropping the finally block would be a serious bug. A permit that is
never returned is lost, and every task that later calls WaitAsync
waits forever. Unlike lock, SemaphoreSlim is not
thread-affine: the permit does not have to be returned by the thread that took
it, and the release may happen after an await has resumed the
method on another thread.
using System.Threading;
using System.Threading.Tasks;
var gate = new SemaphoreSlim(0, 1);
var waiter = Task.Run(async () =>
{
await gate.WaitAsync();
Console.WriteLine("The waiter entered");
gate.Release();
});
gate.Release();
await waiter;
Console.WriteLine($"CurrentCount: {gate.CurrentCount}");
The semaphore starts with no permits, so the task blocks on
WaitAsync. The main thread calls Release and hands the
permit to the waiter, which is running on a different thread.
$ dotnet run The waiter entered CurrentCount: 1
C# limiting concurrency
The usual purpose of a semaphore with more than one permit is to limit how many operations run at the same time. The next example allows at most three of the six jobs to be active simultaneously.
using System.Threading;
using System.Threading.Tasks;
var semaphore = new SemaphoreSlim(3);
int active = 0;
async Task JobAsync(int id)
{
await semaphore.WaitAsync();
try
{
int running = Interlocked.Increment(ref active);
Console.WriteLine($"job {id} started (active: {running})");
await Task.Delay(id * 50);
running = Interlocked.Decrement(ref active);
Console.WriteLine($"job {id} finished (active: {running})");
}
finally
{
semaphore.Release();
}
}
var jobs = Enumerable.Range(1, 6).Select(JobAsync).ToArray();
await Task.WhenAll(jobs);
The single argument constructor sets both the initial and the maximum count to three, so three permits are available from the start.
var semaphore = new SemaphoreSlim(3);
The active counter is updated with
Interlocked.Increment and Interlocked.Decrement,
because several jobs update it concurrently.
int running = Interlocked.Increment(ref active);
Each job delays for a different time so that the output order stays stable between runs. A real job would do file, network, or database work instead.
$ dotnet run job 1 started (active: 1) job 2 started (active: 2) job 3 started (active: 3) job 1 finished (active: 2) job 4 started (active: 3) job 2 finished (active: 2) job 5 started (active: 3) job 3 finished (active: 2) job 6 started (active: 3) job 4 finished (active: 2) job 5 finished (active: 1) job 6 finished (active: 0)
The active count never exceeds three. Jobs wait in the order in which they reach
WaitAsync, and each finished job lets the next waiting job start.
C# SemaphoreSlim.Wait with timeout
Wait and WaitAsync also accept a timeout. The call then
returns a Boolean value instead of waiting indefinitely.
using System.Threading;
using System.Threading.Tasks;
var semaphore = new SemaphoreSlim(1, 1);
await semaphore.WaitAsync();
Console.WriteLine($"Permit taken, CurrentCount: {semaphore.CurrentCount}");
bool acquired = semaphore.Wait(TimeSpan.FromMilliseconds(250));
Console.WriteLine($"Wait with a 250 ms timeout: {acquired}");
if (acquired)
{
semaphore.Release();
}
acquired = await semaphore.WaitAsync(TimeSpan.FromMilliseconds(250));
Console.WriteLine($"WaitAsync with a 250 ms timeout: {acquired}");
if (acquired)
{
semaphore.Release();
}
semaphore.Release();
Console.WriteLine($"After the original release, CurrentCount: {semaphore.CurrentCount}");
acquired = await semaphore.WaitAsync(TimeSpan.FromSeconds(1));
Console.WriteLine($"WaitAsync with a 1 s timeout: {acquired}");
semaphore.Release();
The first call takes the only permit, so both timed waits have nothing to
acquire. Wait blocks the calling thread, while
WaitAsync suspends the method without occupying a thread.
bool acquired = semaphore.Wait(TimeSpan.FromMilliseconds(250));
A true return means that a permit was taken. A false
return means that the timeout elapsed and no permit was taken, so
Release must not be called in that case. The if
statements above make that rule explicit.
$ dotnet run Permit taken, CurrentCount: 0 Wait with a 250 ms timeout: False WaitAsync with a 250 ms timeout: False After the original release, CurrentCount: 1 WaitAsync with a 1 s timeout: True
Releasing a permit that was never acquired raises
SemaphoreFullException as soon as the count reaches the maximum, and
releasing on behalf of a timed out wait would silently allow more work than the
semaphore is meant to permit.
C# SemaphoreSlim and CancellationToken
WaitAsync has an overload that takes a
CancellationToken. The await then ends with an
OperationCanceledException when the token is cancelled while the
task is still waiting for a permit.
using System.Threading;
using System.Threading.Tasks;
var semaphore = new SemaphoreSlim(1, 1);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
await semaphore.WaitAsync();
Console.WriteLine($"Permit taken, CurrentCount: {semaphore.CurrentCount}");
try
{
await semaphore.WaitAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("The second waiter was cancelled");
}
Console.WriteLine($"CurrentCount after cancellation: {semaphore.CurrentCount}");
semaphore.Release();
Console.WriteLine($"CurrentCount after release: {semaphore.CurrentCount}");
The CancellationTokenSource cancels itself after two hundred
milliseconds. The second WaitAsync call is still pending at that
moment, because the only permit is held by the outer scope.
await semaphore.WaitAsync(cts.Token);
Cancelling a waiter does not consume a permit. The count stays at zero after the exception, and it returns to one only when the acquired permit is released.
$ dotnet run Permit taken, CurrentCount: 0 The second waiter was cancelled CurrentCount after cancellation: 0 CurrentCount after release: 1
Always pass the token to WaitAsync rather than relying on a timeout
when the operation can be abandoned by the caller; a cancelled wait removes the
waiter from the queue immediately.
C# SemaphoreSlim vs Semaphore
SemaphoreSlim lives in the current process and uses only managed
data structures plus, optionally, an event handle. Semaphore from
System.Threading wraps a kernel semaphore: it is slower, it has no
asynchronous wait method, and it can be given a name that makes it visible to
other processes.
using System.Threading;
var slim = new SemaphoreSlim(1, 1);
Console.WriteLine($"Slim permits: {slim.CurrentCount}");
using var kernel = new Semaphore(2, 2);
bool acquired = kernel.WaitOne(TimeSpan.FromSeconds(1));
Console.WriteLine($"Kernel semaphore acquired: {acquired}");
if (acquired)
{
kernel.Release();
Console.WriteLine("Kernel semaphore released");
}
slim.Dispose();
Console.WriteLine("Slim semaphore disposed");
The kernel semaphore is acquired with WaitOne, which blocks the
whole thread, and it is released with Release.
$ dotnet run Slim permits: 1 Kernel semaphore acquired: True Kernel semaphore released Slim semaphore disposed
Named semaphores, created with the constructor that takes a name and an
out bool createdNew parameter, coordinate work between separate
processes on Windows and throw PlatformNotSupportedException on
platforms where named synchronization primitives are unavailable. Inside a
single process prefer SemaphoreSlim, which supports
WaitAsync and needs no kernel transition for its fast path.
SemaphoreSlim implements IDisposable. It allocates an
internal wait handle only when the AvailableWaitHandle property is
accessed, and Dispose frees that handle. Dispose the semaphore when
you are done with it, and never use it afterwards, because
WaitAsync and Release throw
ObjectDisposedException on a disposed instance.
C# SemaphoreSlim guidelines
Keep the following rules in mind when working with SemaphoreSlim:
- Choose a meaningful
maxCount. It caps the number of permits thatReleasecan hand out and protects against over-releasing. - Always release in a
finallyblock, and release only after a successfulWaitorWaitAsync. - Never release more times than you acquired. Once the count reaches
maxCount, anotherReleaseraisesSemaphoreFullException. - Pass the same value for
initialCountandmaxCountunless you deliberately want to start with fewer permits, for example zero. - Do not
awaitinside alockstatement. UseSemaphoreSlimwhen the critical section contains asynchronous work. - Keep the protected region short, and pass a
CancellationTokentoWaitAsyncin long-running services.
A double release is easy to spot, because it throws immediately.
using System.Threading;
var semaphore = new SemaphoreSlim(1, 1);
try
{
semaphore.Release();
}
catch (SemaphoreFullException)
{
Console.WriteLine("SemaphoreFullException: the count is already at maxCount");
}
Console.WriteLine($"CurrentCount: {semaphore.CurrentCount}");
$ dotnet run SemaphoreFullException: the count is already at maxCount CurrentCount: 1
Source
SemaphoreSlim Class - Microsoft Learn
SemaphoreSlim.WaitAsync Method - Microsoft Learn
Semaphore Class - Microsoft Learn
The lock statement - Microsoft Learn
In this article we have worked with the SemaphoreSlim class in C#.
Author
List all C# tutorials.