DEV Community

Cover image for Synchronization: Locks, Semaphores, Mutexes, and Deadlock Prevention
Outdated Dev
Outdated Dev

Posted on

Synchronization: Locks, Semaphores, Mutexes, and Deadlock Prevention

Hello there!👋🧔‍♂️ If you've ever had two threads step on each other's toes, corrupting data, double-spending a counter, or freezing your app in a silent deadlock; you know that concurrency is powerful but dangerous. As a senior engineer, getting synchronization right is non-negotiable. This post walks through the core primitives (locks, semaphores, mutexes) and how to avoid the classic pitfall: deadlock.

Think of synchronization like traffic rules: locks are like a single-lane bridge (one car at a time), semaphores are like a parking lot with a fixed number of spaces, and mutexes are like a key that only one person can hold. When two cars try to claim the same bridge in the wrong order, you get a deadlock—nobody moves. Let's see how to use these tools and prevent that.

Overview

When multiple threads or processes share resources, you need coordination. In this guide we'll cover:

  1. Locks (Monitors) - Mutual exclusion within a single process
  2. Semaphores - Controlling access by count (how many can enter)
  3. Mutexes - Mutual exclusion that can cross process boundaries
  4. Deadlock - What it is and how to prevent it

We'll use C# and .NET for examples, with the same ideas applying across languages and runtimes.

1. Locks (Monitors)

What Is a Lock?

A lock (in C#, the lock statement and Monitor) ensures that only one thread at a time can execute a block of code. It's mutual exclusion for a critical section.

Real-world analogy: One bathroom key. Only the person holding the key can enter; everyone else waits.

lock in C

In C#, lock(obj) { ... } is syntactic sugar for Monitor.Enter / Monitor.Exit (with proper exception handling). You lock on an object that all contending threads agree on.

public class Counter
{
    private readonly object _sync = new object();
    private int _value;

    public void Increment()
    {
        lock (_sync)
        {
            _value++;
        }
    }

    public int Value
    {
        get
        {
            lock (_sync)
            {
                return _value;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Rules of thumb:

  • Lock on a private object – Never lock on this, public objects, or types. Use a dedicated object _sync = new object(); (or similar) to avoid external code locking the same instance and causing deadlocks or unnecessary contention.
  • Keep the critical section small – Do only the minimal work inside the lock; no I/O, no extra calls that might block or take locks.

Monitor.TryEnter and Timeouts

If you don't want to block forever, use Monitor.TryEnter with a timeout:

if (Monitor.TryEnter(_sync, TimeSpan.FromSeconds(2)))
{
    try
    {
        // Critical section
    }
    finally
    {
        Monitor.Exit(_sync);
    }
}
else
{
    // Could not acquire lock in time; handle (retry, log, fail, etc.)
}
Enter fullscreen mode Exit fullscreen mode

Always pair Enter/TryEnter with Exit in a finally block (or use lock), so the lock is released even when an exception is thrown.


2. Semaphores

What Is a Semaphore?

A semaphore is a counter that controls how many threads (or processes) can be in a given region at once. You wait (decrement) before entering and release (increment) when leaving.

  • Binary semaphore – Count is 0 or 1; similar to a lock, but the “key” can be released by a different thread (not always the one that acquired it), which is dangerous in many designs.
  • Counting semaphore – Count is N; at most N threads can be inside at the same time (e.g. limit concurrent DB connections or API calls).

Real-world analogy: A parking lot with 10 spaces. When a car enters, the count goes down; when it leaves, the count goes up. When the count is 0, new cars must wait.

SemaphoreSlim in C

SemaphoreSlim is the usual choice in .NET for in-process, async-friendly throttling:

public class ThrottledApiClient
{
    private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(5, 5); // At most 5 concurrent

    public async Task<Response> CallApiAsync(CancellationToken cancellationToken = default)
    {
        await _semaphore.WaitAsync(cancellationToken);
        try
        {
            return await DoCallAsync(cancellationToken);
        }
        finally
        {
            _semaphore.Release();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Constructor: new SemaphoreSlim(initialCount, maxCount). For pure limiting, use new SemaphoreSlim(N, N) so N threads can be “inside” at once.
  • Wait / Release – Always release in finally so you don’t leak permits and starve other threads.

When to Use a Semaphore

  • Limit concurrency (e.g. max 10 outgoing HTTP requests, max 5 DB connections per process).
  • Not for mutual exclusion of a single critical section; for that, use a lock or mutex. Use semaphores when “up to N at a time” is the requirement.

3. Mutexes

What Is a Mutex?

A mutex (mutual exclusion) is like a lock, but it can be system-wide or cross-process. Only one thread (or process) can own it at a time. In .NET, Mutex can be named so different processes share the same mutex.

Real-world analogy: A single key to a server room. Only one person (or process) can hold the key; others wait at the door.

Mutex in C

public class SingleInstanceAppGuard : IDisposable
{
    private readonly Mutex _mutex;
    private bool _hasHandle;

    public SingleInstanceAppGuard(string name)
    {
        _mutex = new Mutex(false, name);
        _hasHandle = _mutex.WaitOne(TimeSpan.Zero, false);
    }

    public bool IsFirstInstance => _hasHandle;

    public void Dispose() => GC.SuppressFinalize(this);

    ~SingleInstanceAppGuard()
    {
        if (_hasHandle)
        {
            _mutex.ReleaseMutex();
        }
    }
}

// Usage: ensure only one instance of the app per machine
using var guard = new SingleInstanceAppGuard("Global\\MyApp-SingleInstance");
if (!guard.IsFirstInstance)
{
    Console.WriteLine("Another instance is already running.");
    return;
}
Enter fullscreen mode Exit fullscreen mode
  • Named mutex – Same name across processes = same mutex. Use a prefix like Global\\ for machine-wide visibility (with appropriate permissions).
  • Ownership – Only the thread that acquired the mutex should release it. Don’t use a mutex like a semaphore (releasing from another thread).

Lock vs Mutex (Quick Comparison)

Aspect lock / Monitor Mutex
Scope Single process Can be cross-process
Performance Generally faster Heavier (kernel)
Typical use In-process sync Single-instance app, cross-process resources

Use a lock when all contenders are in the same process; use a mutex when you need to coordinate across processes.


4. Deadlock: What It Is and How to Prevent It

What Is Deadlock?

Deadlock occurs when two or more threads (or processes) are each waiting for a resource held by another, so none of them can make progress. Classic example: Thread A holds Lock 1 and waits for Lock 2; Thread B holds Lock 2 and waits for Lock 1. Both wait forever.

Real-world analogy: Two cars on a narrow bridge, each waiting for the other to reverse. If neither backs up, traffic is deadlocked.

The Four Conditions for Deadlock

Deadlock is only possible when all of these hold (Coffman conditions):

  1. Mutual exclusion – Resources cannot be shared (e.g. only one thread can hold a lock).
  2. Hold and wait – A thread holds at least one resource while waiting for another.
  3. No preemption – Resources cannot be forcibly taken away (e.g. locks are not stolen).
  4. Circular wait – There is a cycle in the “who waits for whom” graph (A waits for B, B waits for A, or longer cycle).

To prevent deadlock, you must break at least one of these. In practice, the one we control most is circular wait, via ordering.

Strategy 1: Lock Ordering (Break Circular Wait)

Define a global order for all locks (e.g. by object identity or a fixed ID). Every thread must acquire locks only in that order. That removes circular wait.

public class TransferService
{
    private readonly object _lockA = new object();
    private readonly object _lockB = new object();

    public void Transfer(Account from, Account to, decimal amount)
    {
        // Order by a stable criterion (e.g. account ID) so all threads use same order
        var first = from.Id < to.Id ? from : to;
        var second = from.Id < to.Id ? to : from;

        lock (first.SyncObject)
        {
            lock (second.SyncObject)
            {
                from.Debit(amount);
                to.Credit(amount);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If every transfer always locks the “smaller” account first, no two threads can form “A waits for B” and “B waits for A” at the same time.

Strategy 2: Try-Lock / Timeout (Reduce Hold-and-Wait Impact)

Don’t block indefinitely. Try to acquire all needed resources with a timeout; if you can’t get one, release what you have and retry (perhaps with backoff).

public bool TryTransfer(Account from, Account to, decimal amount)
{
    var first = from.Id < to.Id ? from : to;
    var second = from.Id < to.Id ? to : from;

    if (!Monitor.TryEnter(first.SyncObject, TimeSpan.FromMilliseconds(100)))
        return false;

    try
    {
        if (!Monitor.TryEnter(second.SyncObject, TimeSpan.FromMilliseconds(100)))
            return false;

        try
        {
            from.Debit(amount);
            to.Credit(amount);
            return true;
        }
        finally
        {
            Monitor.Exit(second.SyncObject);
        }
    }
    finally
    {
        Monitor.Exit(first.SyncObject);
    }
}
Enter fullscreen mode Exit fullscreen mode

This doesn’t eliminate deadlock by itself (two threads can still block each other), but it avoids unbounded blocking and can be combined with retries and logging.

Strategy 3: One Lock for Multiple Resources

When possible, use a single lock to protect all related resources so you never hold multiple locks.

public class DoubleCounter
{
    private readonly object _sync = new object();
    private int _a;
    private int _b;

    public void IncrementBoth()
    {
        lock (_sync)
        {
            _a++;
            _b++;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

No second lock ⇒ no circular wait. Trade-off: coarser granularity and potentially more contention.

Strategy 4: Avoid Locking When You Can

  • Use lock-free structures where they exist and are well-understood (e.g. ConcurrentQueue<T>, ConcurrentDictionary<K,V>).
  • Use immutability and message passing so shared mutable state is minimized.
  • Keep locks out of callbacks and long-running work; do minimal work inside the lock.

Quick Reference: When to Use What

Need Primitive to use
One thread at a time in a section lock / Monitor
Up to N threads at a time SemaphoreSlim
Single instance across processes Named Mutex
In-process, async-friendly limit SemaphoreSlim + WaitAsync
Shared collections, high concurrency Concurrent* or lock around minimal work

Conclusion

Synchronization is the set of tools that keep concurrent access to shared resources correct and predictable. Locks and mutexes give mutual exclusion; semaphores limit how many threads can proceed at once. Deadlock is avoided by design: lock ordering, timeouts, fewer locks, and less shared mutable state.

Key takeaways:

  1. Locks (Monitor) – Use for in-process mutual exclusion; lock on a private object and keep critical sections short.
  2. Semaphores – Use when you need to cap concurrency (e.g. N concurrent operations), not for simple “one at a time” critical sections.
  3. Mutexes – Use when you need cross-process mutual exclusion (e.g. single-instance app or shared OS resource).
  4. Deadlock – Prevent it by lock ordering, try-lock/timeouts, single locks where possible, and by reducing shared mutable state.

Use the right primitive for the job, keep critical sections small, and always release in finally (or equivalent). Your future self (and your production logs) will thank you.

Stay safe, and happy coding! 🚀🔒

Top comments (0)