AlgoInsight

Free lesson · Concurrency Interview Mastery

Exercise: Reentrant Locks

Medium6 min readconcurrencymultithreadinginterview-prep

This exercise makes reentrancy concrete: the self-deadlock it prevents, the hold-count balance it demands, and the design-smell argument against it. Part 1 is the self-deadlock; Part 2 is the count-balance bug; Part 3 is the judgment call about whether reentrancy is the right fix or a mask. Reason each through before the solution - the goal is to understand reentrancy as a trade-off, not just a switch you flip to avoid deadlock.

Important: The deeper question this exercise asks: when a locked method needs to call another locked method, is reentrancy the answer, or is it telling you to restructure? Reentrancy works, but "it works" and "it's the right design" aren't the same - hold that distinction through all three parts.


Part 1 - The self-deadlock

Here's a thread-safe class using a non-reentrant lock, where one locked method calls another:

class Counter:
    lock = new NonReentrantMutex()
    count = 0

    function increment():
        lock.lock()
        count += 1
        lock.unlock()

    function incrementTwice():
        lock.lock()
        increment()        // <-- calls increment(), which locks the SAME lock
        increment()
        lock.unlock()

Your task: Explain exactly what happens when a thread calls incrementTwice(), and give two different fixes - one using reentrancy, one restructuring to avoid the nested lock entirely.

<details> <summary>Hint</summary>

incrementTwice locks the lock, then calls increment, which tries to lock the same non-reentrant lock the thread already holds - it blocks forever (self-deadlock). Fix 1: make the lock reentrant, so the re-acquire just increments a hold count. Fix 2: extract a private unlocked helper that does the work, called by both methods while one holds the lock. </details>


Part 2 - The hold-count balance

A developer switched to a reentrant lock and wrote this, expecting it to work:

function process():
    lock.lock()                  // count: 0 -> 1
    if someCondition:
        lock.lock()              // count: 1 -> 2 (reentrant re-acquire)
        doExtraWork()
        // forgot to unlock here!
    doWork()
    lock.unlock()                // count: 2 -> 1  ... or 1 -> 0?

Your task: When someCondition is true, what's wrong? Explain what state the lock is left in, and why this bug is especially nasty.

<details> <summary>Hint</summary>

A reentrant lock must be unlocked exactly as many times as it's locked. When someCondition is true, it locks twice but unlocks once, leaving the hold count at 1 - the lock stays held by this thread even after process returns. Every other thread that needs the lock now blocks forever. </details>


Part 3 - Is reentrancy the right fix?

A cache holds a lock while computing a value, and the computation invokes a user-supplied callback. With a reentrant lock, the callback sometimes calls back into the cache and reads a value mid-update:

function getOrCompute(key):
    lock.lock()
    if key not in cache:
        partial = startComputing(key)
        cache[key] = partial               // partially-initialized entry
        value = userCallback(key)          // callback may re-enter getOrCompute!
        cache[key] = finalize(partial, value)
    result = cache[key]
    lock.unlock()
    return result

The reentrant lock lets the callback's re-entrant getOrCompute proceed and read the partial entry. The developer says "reentrancy caused this bug; let's use a non-reentrant lock." Is that the right diagnosis?

<details> <summary>Hint</summary>

A non-reentrant lock would turn the re-entrant callback into a self-deadlock instead of a silent bad read - arguably worse, definitely not "fixed." The real problem is holding the lock across an arbitrary callback while the object is in an inconsistent state. The fix isn't about lock type; it's about not calling out to untrusted code while holding the lock mid-update. </details>


Solution

Part 1 - Self-deadlock and two fixes

What happens: incrementTwice acquires the non-reentrant lock, then calls increment, which tries to acquire the same lock the calling thread already holds. A non-reentrant lock has no notion of "the owner re-acquiring," so it blocks the thread, waiting for the lock to be released - but the only thread that could release it is the one now blocked. The thread deadlocks against itself and waits forever.

Fix 1 - make it reentrant:

lock = new ReentrantMutex()
// incrementTwice locks (count 1), increment re-locks (count 2) and unlocks (count 1),
// again (count 2 -> 1), then incrementTwice unlocks (count 0). No deadlock.

Fix 2 - restructure to avoid the nested lock:

function increment():
    lock.lock()
    incrementUnlocked()          // public method locks, then delegates
    lock.unlock()

function incrementUnlocked():     // private helper, assumes lock is HELD
    count += 1

function incrementTwice():
    lock.lock()
    incrementUnlocked()          // call the unlocked helper while holding the lock
    incrementUnlocked()
    lock.unlock()

Fix 2 extracts the actual work into a private helper that assumes the lock is already held and does no locking itself. Public methods lock and call the helper; methods that already hold the lock also call the helper directly. No method ever tries to re-acquire a lock it holds, so the problem vanishes regardless of whether the lock is reentrant. This is the restructuring the non-reentrant camp argues for: instead of relying on reentrancy, make lock scope explicit by separating "locked entry points" from "unlocked workers." Both fixes are valid; Fix 2 is what you'd reach for in Go or C++ where the lock is non-reentrant by default, and many consider it cleaner even where reentrancy is available.

Part 2 - The hold-count imbalance

When someCondition is true, the code locks twice (count 0→1→2) but unlocks only once (count 2→1). The hold count never reaches zero, so the lock remains held by this thread even after process returns - a stranded lock. Every other thread that subsequently tries to acquire it blocks forever.

This bug is especially nasty for two reasons. First, it's conditional - it only manifests when someCondition is true, so it passes any test that doesn't exercise that path, then strikes in production under the right data. Second, the symptom (every other thread hanging on this lock) appears far from the cause (a missing unlock on a conditional branch), making it hard to trace back. The fix is to balance every acquire with a release, ideally using scope-bound release (RAII / defer / try-finally) so the unlock can't be skipped:

function process():
    lock.lock()
    try:
        if someCondition:
            lock.lock()
            try:
                doExtraWork()
            finally:
                lock.unlock()    // balanced release, guaranteed
        doWork()
    finally:
        lock.unlock()

The lesson: reentrancy's convenience comes with an obligation - balanced lock/unlock counts - and scope-bound release is how you guarantee balance even across conditionals and exceptions.

Part 3 - The wrong diagnosis

Switching to a non-reentrant lock is the wrong fix. It wouldn't make the code correct; it would change the symptom from a silent bad read into an outright self-deadlock when the callback re-enters - the callback would block forever trying to acquire the lock its own call chain holds. That's arguably worse: a hang instead of a wrong value. The non-reentrant lock doesn't fix the bug; it just makes the same broken design fail differently.

The real problem is calling out to arbitrary code (the user callback) while holding the lock and while the object is in an inconsistent, partially-updated state. The fix is structural - don't do that:

function getOrCompute(key):
    lock.lock()
    if key in cache:
        result = cache[key]; lock.unlock(); return result
    lock.unlock()                          // RELEASE before the callback

    partial = startComputing(key)
    value = userCallback(key)              // callback runs WITHOUT the lock held
    finalValue = finalize(partial, value)

    lock.lock()
    cache[key] = finalValue                // re-check / insert under the lock
    result = cache[key]
    lock.unlock()
    return result

By releasing the lock before invoking the callback and re-acquiring it only to store the finished result, the object is never observed mid-update, and the callback can't deadlock or read partial state. (You'd add a re-check for the case where another thread computed the same key in the gap, as in the read-write-lock exercise.) The principle: don't hold a lock across calls to code you don't control. Reentrancy didn't cause this bug and non-reentrancy wouldn't fix it - the bug is the lock-held-across-callback design. This is exactly the "reentrancy masks the real problem" argument from the lesson: the developer blamed the lock type when the actual issue was lock scope.


Common mistakes

Warning: Watch for: (1) Assuming a lock is reentrant when it isn't - calling a locked method from a locked method on a non-reentrant lock (Go, C++ default) self-deadlocks. (2) Unbalanced lock/unlock counts with reentrant locks - every acquire needs a matching release; conditionals and early returns are where the balance breaks; use scope-bound release. (3) Treating reentrancy as a fix for tangled locking - it works but may hide a structure that wants an unlocked-helper refactor. (4) Blaming the lock type for a callback bug - the real issue is usually holding the lock across uncontrolled code; fix the scope, not the lock. (5) Holding a lock across any callback, listener, or external call - it invites deadlock and inconsistent-state observation; release first.

Why this exercise matters for interviews

Interview: "What happens if you lock a lock you already hold?" is a direct interview question, and the answer (self-deadlock for non-reentrant, hold-count increment for reentrant) plus the language-specific defaults (Go/C++ non-reentrant, Java/C# reentrant) is exactly Part 1. Part 2 tests whether you understand the balanced-count obligation that reentrancy creates. Part 3 is the sophisticated one: recognizing that reentrancy is often blamed for bugs whose real cause is poor lock scope (holding locks across callbacks), and that the fix is structural. If you can argue both sides of the reentrancy trade-off and identify "don't hold a lock across uncontrolled code" as the real lesson of the callback bug, you've shown the kind of judgment that goes beyond reciting definitions. The unlocked-helper refactor in Part 1 is also worth having ready - it's the idiomatic answer in non-reentrant-lock languages.

Back to Concurrency Interview Mastery