Synchronization Primitives
Vaibhav • September 11, 2025
In the previous article, we explored concurrent collections-thread-safe data structures that help you share data across tasks without risking corruption. But sometimes, you need more control over how threads interact. Maybe you want to limit access to a resource, coordinate execution order, or prevent race conditions in custom logic. That’s where synchronization primitives come in.
In this article, we’ll explore the core synchronization primitives in C#, including lock
, Monitor
, Mutex
, Semaphore
, and AutoResetEvent
. We’ll learn how they work, when to use them, and how they help
you build safe and predictable multi-threaded applications.
Why Synchronization Is Needed
When multiple threads access shared resources-like variables, files, or network connections-you need to ensure that only one thread modifies the resource at a time. Without synchronization, you risk race conditions, deadlocks, and unpredictable behavior.
Synchronization primitives give you tools to control access and coordinate execution. They’re especially useful when concurrent collections aren’t enough, or when you need fine-grained control over thread behavior.
The lock Statement
The simplest and most commonly used synchronization primitive in C# is lock
. It
wraps a critical section of code so that only one thread can enter at a time.
object sync = new object();
int counter = 0;
void Increment()
{
lock (sync)
{
counter++;
}
}
In this example, the lock
ensures that only one thread can increment counter
at a time. The sync
object acts as a
gatekeeper. If another thread tries to enter the lock
, it waits until the first
thread exits.
Always use a private, readonly object for locking. Avoid locking on this
or
public objects, as it can lead to unexpected behavior if other code
locks on the same object.
Monitor for Advanced Locking
The Monitor
class provides more control than lock
. It lets you try to acquire a lock with a timeout, and it separates lock
acquisition and release.
object sync = new object();
if (Monitor.TryEnter(sync, TimeSpan.FromSeconds(1)))
{
try
{
Console.WriteLine("Lock acquired");
}
finally
{
Monitor.Exit(sync);
}
}
else
{
Console.WriteLine("Could not acquire lock");
}
This code tries to enter the lock for up to one second. If it fails, it moves on. This is useful when you want to avoid blocking indefinitely or implement retry logic.
Mutex for Cross-Process Synchronization
Mutex
is similar to lock
, but it works across
processes. If you need to synchronize access to a file or resource shared between multiple applications, use a
Mutex
.
using var mutex = new Mutex(false, "Global\\MyAppMutex");
if (mutex.WaitOne(TimeSpan.FromSeconds(2)))
{
try
{
Console.WriteLine("Mutex acquired");
}
finally
{
mutex.ReleaseMutex();
}
}
else
{
Console.WriteLine("Mutex timeout");
}
This code creates a named mutex that can be shared across processes. WaitOne
blocks until the mutex is available or the timeout expires. ReleaseMutex
releases the lock.
Note: Named mutexes use system-wide identifiers. Use a unique name to avoid collisions. Prefix with Global\\
for cross-session access.
Semaphore for Limited Access
A Semaphore
limits the number of threads that can access a resource at the same
time. It’s useful when you want to allow a fixed number of concurrent operations.
SemaphoreSlim semaphore = new SemaphoreSlim(3); // allow 3 threads
async Task AccessResourceAsync()
{
await semaphore.WaitAsync();
try
{
Console.WriteLine("Resource accessed");
await Task.Delay(1000);
}
finally
{
semaphore.Release();
}
}
This code allows up to 3 threads to access the resource concurrently. Additional threads wait until one of the current threads releases the semaphore.
Use SemaphoreSlim
for async code. The older Semaphore
class is designed for synchronous scenarios and doesn’t support
await
.
AutoResetEvent for Signaling
AutoResetEvent
is used to signal between threads. One thread waits for a
signal, and another thread sends it. It’s useful for coordination and sequencing.
AutoResetEvent signal = new AutoResetEvent(false);
void Worker()
{
Console.WriteLine("Waiting...");
signal.WaitOne();
Console.WriteLine("Signal received");
}
void Controller()
{
Thread.Sleep(1000);
signal.Set();
}
The worker thread waits for the signal. The controller thread sends the signal after a delay. AutoResetEvent
automatically resets after releasing one thread.
ManualResetEvent for Broadcast Signaling
ManualResetEvent
is similar to AutoResetEvent
,
but it stays signaled until you reset it manually. This allows multiple threads to proceed after a single
signal.
ManualResetEvent ready = new ManualResetEvent(false);
void Waiter()
{
Console.WriteLine("Waiting...");
ready.WaitOne();
Console.WriteLine("Proceeding");
}
void Broadcaster()
{
Thread.Sleep(1000);
ready.Set(); // all waiting threads proceed
}
This is useful when you want to release a group of threads at once-like starting a race or initializing a system.
Choosing the Right Primitive
Each synchronization primitive serves a different purpose. Use lock
for simple
mutual exclusion, Monitor
for advanced locking, Mutex
for cross-process coordination, SemaphoreSlim
for limiting concurrency, and AutoResetEvent
or ManualResetEvent
for
signaling.
Avoid overusing synchronization. Too much locking can lead to contention and performance issues. Prefer concurrent collections and async patterns when possible.
Keep critical sections short. Don’t perform I/O or long computations inside a lock. This reduces contention and improves scalability.
Common Mistakes
Synchronization is tricky. Here are some common mistakes to avoid:
One mistake is forgetting to release a lock. If you use Monitor.Enter
without
Exit
, the thread stays locked and others are blocked indefinitely. Always use
try-finally
to ensure release.
Another mistake is locking on public objects. If other code locks on the same object, you can get unexpected behavior or deadlocks. Use private lock objects.
Also, avoid nested locks unless absolutely necessary. Locking multiple resources increases the risk of deadlocks. If you must lock multiple objects, always lock them in a consistent order.
You can use Interlocked
for atomic operations like
incrementing counters. It’s faster than locking and avoids contention.
Summary
Synchronization primitives give you fine-grained control over thread coordination and access to shared
resources. You’ve learned how to use lock
, Monitor
, Mutex
, SemaphoreSlim
, and signaling events to build safe and scalable multi-threaded
applications.
In the next article, we’ll explore ConfigureAwait, a subtle but important concept that affects
how async methods resume execution. You’ll learn how context capture works, when to use ConfigureAwait(false)
, and how it impacts performance and deadlocks.