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.