SemaphoreSlim and Mutex are fundamentally different in how they manage access, with lock being a syntactic sugar for a specific implementation of mutual exclusion.

Let’s see how these play out in practice. Imagine you have a shared resource, say a counter, that multiple threads need to increment.

public class SharedCounter
{
    private int _count = 0;
    private readonly object _lock = new object(); // For lock
    private readonly Mutex _mutex = new Mutex(); // For Mutex
    private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); // For SemaphoreSlim

    public void IncrementWithLock()
    {
        lock (_lock)
        {
            _count++;
            Console.WriteLine($"Lock: {_count}");
        }
    }

    public void IncrementWithMutex()
    {
        _mutex.WaitOne();
        try
        {
            _count++;
            Console.WriteLine($"Mutex: {_count}");
        }
        finally
        {
            _mutex.ReleaseMutex();
        }
    }

    public async Task IncrementWithSemaphoreSlim()
    {
        await _semaphore.WaitAsync();
        try
        {
            _count++;
            Console.WriteLine($"SemaphoreSlim: {_count}");
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

Here, lock is the simplest. It’s compiled down to Monitor.Enter and Monitor.Exit calls. It’s perfect for protecting a small, contiguous block of code within a single process. Mutex, on the other hand, is a more heavyweight primitive. It can be named and thus used to synchronize access across different processes. This inter-process communication (IPC) capability comes with overhead. SemaphoreSlim is a lighter-weight, asynchronous-friendly cousin of Semaphore. It’s designed for intra-process synchronization and excels when you need to control the number of threads that can access a resource concurrently, not just exclusively. The SemaphoreSlim(1, 1) in the example makes it behave like a binary semaphore, effectively acting as a mutex for a single resource, but with the added benefit of WaitAsync.

The core problem these primitives solve is race conditions – situations where the outcome of a computation depends on the unpredictable timing of multiple threads accessing shared data. Without synchronization, our _count++ operation, which is actually three distinct CPU instructions (load, increment, store), could be interleaved between threads, leading to lost updates.

lock is ideal when you have a simple, single-threaded critical section within your application domain and don’t need cross-process or asynchronous waiting. Mutex is your go-to when you absolutely need to synchronize between separate processes, like two instances of your application reading and writing to the same file. SemaphoreSlim shines when you need fine-grained control over concurrency, especially in asynchronous scenarios where blocking threads with lock or Mutex would be detrimental to scalability. Its WaitAsync method allows other operations to proceed while a thread is waiting for the semaphore, preventing thread pool starvation.

The most surprising thing about Mutex is that even when used within a single process, it incurs the overhead of checking for cross-process ownership and potentially waiting for OS-level synchronization mechanisms, making it noticeably slower than lock for purely intra-process scenarios.

When you’ve mastered SemaphoreSlim, the next logical step is understanding CountdownEvent for scenarios where multiple threads signal completion and one thread waits for all of them.

Want structured learning?

Take the full Csharp course →