AlgoInsight

Free lesson · Concurrency Interview Mastery

Exercise: Mutex (Mutual Exclusion)

Medium7 min readconcurrencymultithreadinginterview-prep

Reading about mutexes and using them correctly are different skills, and interviews test the second. This exercise walks you from spotting a race, to fixing it with a mutex, to the subtler trap that catches people once they're comfortable with single locks: holding two of them. Work each part before opening the solution - the value is in reasoning through it, not reading the answer. Language-agnostic pseudocode is used so the focus stays on the primitive; the logic transfers directly to any language from Module 2.

Important: Treat this as a whiteboard problem. For each part, first answer the question in your head or on paper - where exactly is the bug, and why - before looking at the solution. The mistakes you make reasoning through it are the ones an interviewer wants to see you catch, and catching them here is how you stop making them under pressure.


Part 1 - Find the race

Here's a shared counter incremented by many threads. After 10 threads each increment it 1,000 times, the final value is supposed to be 10,000. It usually isn't.

counter = 0

function worker():
    for i in 1..1000:
        counter = counter + 1     // <-- the problem lives here

// 10 threads each run worker(), then we read counter
// Expected: 10000.  Actual: some number less than 10000, varying each run.

Your task: Explain precisely why the result is less than 10,000 and non-deterministic. Name the exact sequence of operations that loses an update. Don't fix it yet - just diagnose.

<details> <summary>Hint 1</summary>

counter = counter + 1 is not one operation. How many machine-level steps does it take, and what are they? </details>

<details> <summary>Hint 2</summary>

Imagine two threads executing those steps in an interleaved order. Write out a specific interleaving where both threads read the same starting value. </details>


Part 2 - Fix it with a mutex

Now protect the shared counter so the final value is always exactly 10,000.

counter = 0
mutex = new Mutex()

function worker():
    for i in 1..1000:
        // TODO: make this increment safe
        counter = counter + 1

Your task: Add the mutex so the increment is atomic with respect to other threads. Then answer two follow-ups: (a) Should the lock go inside the loop or around the whole loop, and what's the trade-off? (b) What guarantees that you never leave the mutex locked if something goes wrong inside the critical section?

<details> <summary>Hint</summary>

The critical section is exactly the read-modify-write of counter. Lock immediately before it and unlock immediately after. For the placement follow-up, think about what "keep critical sections small" trades against "lock/unlock overhead per iteration." </details>


Part 3 - Two locks, and a trap

Now a harder, more realistic scenario: transferring money between accounts, each guarded by its own mutex. Two threads run transfers concurrently.

function transfer(from, to, amount):
    lock(from.mutex)
    lock(to.mutex)
        from.balance -= amount
        to.balance   += amount
    unlock(to.mutex)
    unlock(from.mutex)

// Thread 1 runs: transfer(accountA, accountB, 100)
// Thread 2 runs: transfer(accountB, accountA, 50)

Your task: This code is correct most of the time and then occasionally hangs forever. Explain the exact interleaving that hangs it, and propose a fix that keeps per-account locking (don't collapse to one global lock). This is the trap that separates "I can use a mutex" from "I understand locking."

<details> <summary>Hint 1</summary>

Write out what each thread locks first. Thread 1 locks A then wants B. Thread 2 locks B then wants A. What if both get their first lock before either gets its second? </details>

<details> <summary>Hint 2</summary>

The problem is the order in which the two threads acquire the two locks. What if every transfer always acquired the accounts' locks in the same global order, regardless of transfer direction? </details>


Solution

Part 1 - Why the race happens

counter = counter + 1 compiles to three distinct steps: read counter into a register, add one, write the result back. These three steps are not atomic - the scheduler can switch threads between any of them. The lost update happens on this interleaving:

Thread A: read counter -> 0
Thread B: read counter -> 0        // B read before A wrote back
Thread A: add 1 -> 1, write -> counter = 1
Thread B: add 1 -> 1, write -> counter = 1     // overwrites A's write
// two increments happened, but counter only went up by 1 - one update lost

Because the timing of context switches varies every run, the number of lost updates varies, which is why the result is both too low and non-deterministic. Every time two threads' read-modify-write sequences overlap, one increment vanishes. This is the canonical data race, and the read-modify-write framing is exactly what an interviewer wants to hear - not just "it's a race," but which operations interleave to lose the update.

Part 2 - The fix

function worker():
    for i in 1..1000:
        lock(mutex)
        counter = counter + 1     // critical section: now atomic w.r.t. other threads
        unlock(mutex)

The lock makes the read-modify-write indivisible: once a thread holds the mutex, no other thread can read counter until it unlocks, so the interleaving from Part 1 can't occur. Final value is always 10,000.

(a) Inside the loop vs around it. Locking inside the loop (as above) holds the lock only for each individual increment, maximizing concurrency between iterations - but pays lock/unlock overhead 1,000 times per thread. Locking around the whole loop holds the lock for all 1,000 increments at once, paying the overhead once but serializing each thread's entire loop, so threads run one-at-a-time with no real concurrency. For this toy example, locking around the loop is actually faster (less overhead) but defeats the point of multiple threads; in real code where the loop body does other, non-shared work, you want the lock only around the shared access - the smallest critical section - so the non-shared work runs concurrently. The principle: hold the lock for exactly the shared access, no more. The right answer depends on what else is in the loop, and saying that is the mature response.

(b) Guaranteeing release. In real code you must ensure the unlock happens even if the critical section throws or returns early - otherwise the mutex stays locked forever and every other thread blocks permanently (a stranded lock). Every language gives you a scope-bound mechanism for exactly this: RAII lock guards in C++, defer mu.Unlock() in Go, try/finally in Java, using/lock in C#, a with block in Python. Never rely on manually reaching the unlock line, because an exception can skip it.

Part 3 - The deadlock and its fix

The hang is a deadlock from inconsistent lock ordering. The fatal interleaving:

Thread 1: lock(A.mutex)        // T1 holds A
Thread 2: lock(B.mutex)        // T2 holds B
Thread 1: lock(B.mutex)        // T1 WAITS for B (held by T2)
Thread 2: lock(A.mutex)        // T2 WAITS for A (held by T1)
// each thread holds one lock and waits for the other's - neither can proceed. Deadlock.

Thread 1 acquires A and wants B; Thread 2 acquires B and wants A. Each holds what the other needs, and neither will release until it gets the other - a circular wait, the textbook deadlock condition. It only hangs sometimes because it requires both threads to grab their first lock before either grabs its second; often one thread gets both locks before the other starts, and it works fine - which is exactly what makes this bug insidious.

The fix: a global lock ordering. Always acquire the two accounts' locks in a consistent order - say, by a unique account ID - regardless of transfer direction:

function transfer(from, to, amount):
    // acquire locks in a fixed global order, not in transfer order
    first, second = order_by_id(from, to)   // e.g. lower id first
    lock(first.mutex)
    lock(second.mutex)
        from.balance -= amount
        to.balance   += amount
    unlock(second.mutex)
    unlock(first.mutex)

Now both threads acquire locks in the same order (lower ID first), so the circular wait is impossible: whichever thread gets the lower-ID lock first will get the higher-ID lock too, while the other waits for just the first lock. The deadlock cannot form. This preserves per-account locking (transfers on disjoint account pairs still run concurrently) while eliminating the circular wait - the standard "consistent lock ordering" defense, and the answer that demonstrates you understand deadlock, not just mutexes.


Common mistakes

Warning: Watch for these, in the exercise and in real code: (1) Locking only the write, not the read-modify-write - you must hold the lock across the entire read, add, write, not just the assignment, or the race persists. (2) Forgetting exception-safe release - relying on manually reaching unlock instead of a scope-bound guard, so an exception strands the lock. (3) "Fixing" Part 3 with one global lock - it works but kills all concurrency between unrelated transfers; the ordering fix is better because it keeps disjoint transfers parallel. (4) Assuming the deadlock is rare enough to ignore - "it usually works" is precisely the property that lets it reach production and hang under load. A bug that's intermittent isn't minor; it's a bug that hides until the worst moment.

Why this exercise matters for interviews

Interview: This exact progression - spot the race → fix with a lock → handle the two-lock deadlock - is one of the most common concurrency interview arcs, because it tests the three skills in order: recognizing a data race and naming the read-modify-write, applying mutual exclusion correctly with exception-safe release and minimal critical sections, and then the deadlock-awareness that separates strong candidates. If you can walk an interviewer through Part 3's circular wait and propose consistent lock ordering as the fix, you've demonstrated that you understand locking as a system, not just the lock() call. Practice articulating each part out loud - the diagnosis is as much about clear explanation as correct code.

Back to Concurrency Interview Mastery