Thread Safety in .NET: lock, Semaphore and Mutex
Ivan Vydrin
Software Engineer | .NET & Azure Professional | AI/ML Enthusiast | Crafting Scalable and Resilient Solutions
Thread Safety matters: in multi-threaded .NET applications, multiple threads often access shared data concurrently. Without proper synchronization, this can lead to race conditions or corrupted state. We need thread-safety mechanisms to ensure that only one thread manipulates a shared resource at a time. .NET provides several synchronization primitives; here we'll focus on three key ones: lock, Semaphore, and Mutex.
What are them?
Lock
In C#, lock is a keyword that marks a critical section. Only one thread can execute a lock block at a time, blocking others until the lock is released. It's the simplest way to enforce mutual exclusion within a single process. Think of it as a guarded section of code: if one thread is inside, others must wait.
Under the hood, lock uses an exclusive monitor - the thread that enters the block holds a lock on a given object, and no other thread can enter any lock on that same object until it's released. This ensures serialized access to the protected resource. The lock statement is fast and convenient for intra-process synchronization.
// Example 1: Using lock to protect a critical section
private static object _lockObj = new object();
private static int _sharedCounter = 0;
void IncrementSafely() {
lock(_lockObj) {
// Only one thread can execute this at a time
_sharedCounter++;
Console.WriteLine($"Counter incremented to {_sharedCounter}");
}
}
?? Starting from .NET 9, there is a Lock Class.
? Pros
?? Cons
Semaphore
A semaphore is a counting synchronization mechanism that limits the number of threads that can access a resource simultaneously. It's like a nightclub with a fixed capacity enforced by a bouncer - once it's full, new entrants wait in line until someone leaves. A Semaphore (or SemaphoreSlim) starts with a specified count of available "slots". Each thread that enters (calls Wait) decrements the count, and when a thread exits (calls Release), the count increments, potentially allowing another waiting thread in.
For example, a semaphore initialized to 3 will let up to 3 threads run a certain code section in parallel; a 4th thread will wait until one of the first three exits. Importantly, a semaphore has no concept of ownership - any thread can release a slot back to the semaphore, not just the one that acquired it. This makes it a flexible throttling mechanism for protecting pools of resources (like limiting database connections or controlling concurrent I/O).
// Example 2: Using SemaphoreSlim to allow up to 3 threads concurrently
private static SemaphoreSlim _sem = new SemaphoreSlim(3); // 3 slots
void AccessResource() {
_sem.Wait(); // Wait (decrement) for an available slot
try {
Console.WriteLine($"{Thread.CurrentThread.Name} entered");
// Simulate work
Thread.Sleep(1000);
Console.WriteLine($"{Thread.CurrentThread.Name} leaving");
}
finally {
_sem.Release(); // Release (increment) the slot for others
}
}
There are two types of Semaphore:
? Pros
?? Cons
Mutex
Mutex stands for Mutual Exclusion. It is similar to a lock in that it allows only one thread to access a resource at a time, but a key difference is scope: a Mutex can work across multiple processes. When one thread (in any process) owns a Mutex, no other thread (even in other applications on the system) can obtain it until it's released. In .NET, a Mutex is a system-wide named object (if you give it a name) or a local object (if unnamed).
Because it uses operating system handles, acquiring an uncontended Mutex is significantly slower (on the order of microseconds, roughly 50x slower than a lock in .NET). Like a lock, a Mutex is exclusive and also enforces thread ownership - the same thread that acquired it must release it (attempting to release from a different thread throws an exception). A common use-case for a named Mutex is to ensure only one instance of an application is running on a machine.
// Example 3: Using a Mutex for cross-process exclusivity (single instance guard)
using var mutex = new Mutex(false, "Global\\MY_APP_MUTEX");
if (!mutex.WaitOne(TimeSpan.Zero, false)) {
Console.WriteLine("Another instance is already running. Exiting...");
return;
}
// If WaitOne succeeds, we have the mutex.
// ... proceed with exclusive access (only one process can be here) ...
// (When done)
mutex.ReleaseMutex();
Types of Mutexes:
? Pros
?? Cons
Differences
*Note: Semaphores don't enforce thread ownership; any thread can release a permit.
Why do we need them?
In concurrent applications, unsynchronized access to shared variables can cause unpredictable bugs. For example, two threads incrementing the same counter simultaneously might interfere with each other, leading to an incorrect result. Thread safety primitives like lock, semaphore, and mutex prevent such race conditions by coordinating threads. They make sure critical code executes atomically (one-at-a-time) and that resources (memory, files, etc.) are not accessed by multiple threads in conflicting ways. In short, these mechanisms preserve data consistency and program correctness under concurrency.
Lock
Use it to protect critical sections within a single process. It's lightweight and ideal for guarding in-memory data structures or any block of code that should not be executed by more than one thread at a time.
Semaphore
Use it when you need to limit concurrency within a single process in a resource-efficient way. It's great for asynchronous scenarios where you want to allow up to N threads to access a section of code concurrently while keeping overhead low.
Use it to limit concurrent access either in-process or across processes (with naming) when the scenario requires a counting mechanism provided by the OS. It's essential when you have a pool of identical resources or need to throttle calls (e.g., allowing up to N threads to access a service or database simultaneously).
Mutex
Use it when you need a cross-process lock or when integrating with OS-level synchronization. If you have multiple processes or services that must not access the same resource concurrently (e.g., a file or a hardware device), a named Mutex can enforce that exclusivity system-wide.
Use it for simple mutual exclusion within a single process when you require explicit control over thread ownership, though in many cases a lock or SemaphoreSlim is preferred for performance.
Best Practices
Always lock on a dedicated private object (avoid locking on this or string literals). If you have .NET 9+, use a Lock Class.
Use try / finally blocks to ensure proper release and avoid deadlocks.
Reduce the code inside locks to minimize blocking time.
- Use lock for simple in-process scenarios.
- Use SemaphoreSlim for efficient in-process throttling with async support.
- Use Semaphore for cross-process needs requiring a counting mechanism.
- Use Mutex (named or unnamed) when mutual exclusion is required, especially across processes.
Thread synchronization in .NET is about choosing the right tool for the job. Whether using a simple lock, a flexible Semaphore, or a cross-process Mutex, understanding these concepts is key to building robust and performant applications. Did you have a chance to work with all of them? Or you know other tools to handle the concurrency? Share your experience in the comments!
CTO at TRTech Enterprise System
15 小时前GREAT share ??????
Owner | Angel Investor | Founder of @USE4COINS and @Abbigli | Blogger
1 周Thread safety is critical in multi-threaded .NET applications! Choosing between lock, SemaphoreSlim, or Mutex depends on the use case—performance, scope, and resource sharing all matter. Great topic!
Entrepreneur | Founder @ XANT & Monoversity | Senior Software Enginer | Full Stack AI/ML Engineer | Engineering Intelligent SaaS & Scalable Software Solutions
1 周Great topic! Choosing the right synchronization mechanism is key to balancing performance and safety in multi-threaded .NET apps.