ZetCode

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.

Program.cs
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.

Program.cs
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.

Program.cs
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.

Program.cs
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.

Program.cs
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.

Program.cs
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.

Program.cs
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:

A double release is easy to spot, because it throws immediately.

Program.cs
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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all C# tutorials.