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.