skip to content

What is a reentrant (recursive) lock, how does it differ from a non-reentrant one, and what are the arguments against reentrancy?

level: middleimportance: should knowfreq 58%

answer

  1. owner + hold count, released at zero
  2. non-reentrant = self-deadlock
  3. re-entry with broken invariants
  4. never call alien code under a lock
  5. locked shell, unlocked core

basics

~20 s

A reentrant lock lets the thread that already holds it acquire it again, tracking a hold count and only freeing the lock when the count returns to zero. A non-reentrant lock self-deadlocks on the second acquire. Reentrancy eases recursion and callbacks but lets code re-enter a critical section while invariants are broken.

solid answer

~50 s

Reentrancy is per-owner: the lock records the owning thread plus a **hold count**. A re-acquire by the owner just increments the count; each release decrements it, and the lock is only released at zero. Without that, a method that takes the lock and then calls another method that takes the same lock deadlocks against itself — permanently, since the owner is the only one who could release it. Reentrancy exists because object-oriented and recursive code naturally re-enters. Public method calls public method; a subclass override calls the superclass version; a recursive traversal locks the root at each level. The argument against it: reentrancy silently permits re-entering a critical section at a moment when the invariant it protects is broken — classically, code holds the lock, mutates half the state, and then fires a callback that reads or mutates that same state through the front door. A non-reentrant lock would have deadlocked loudly. Note also that reentrancy does not extend across *different* locks and does not prevent lock-ordering deadlocks.

code

text · 11 lines
text
transfer(a, b, amount):
  lock.acquire()
  a.balance -= amount          // invariant broken here: money in flight
  notifyListeners()            // alien call, still holding the lock
  b.balance += amount
  lock.release()

listener.onEvent():
  lock.acquire()               // reentrant: succeeds instantly
  total = a.balance + b.balance  // reads a total short by 'amount'
  lock.release()

go deeper

for a junior

Explain the hold count and that a non-reentrant lock would deadlock a method calling another locked method on the same object.

for a middle

Contrast the two, and explain why recursion, subclass calls and layered APIs make reentrancy the practical default.

for a senior

Lead with the hazard: re-entry with broken invariants and alien calls under a lock; describe the locked-shell pattern and moving notifications outside the critical section.

for a principal

Position it as a policy choice — reentrancy trades a loud, testable failure for a silent correctness risk; decide per component whether re-entry should be legal at all, and enforce that with the lock type.

## The mechanic An **owned** lock knows which thread holds it. A **reentrant** (also called recursive) lock adds a hold count: ``` acquire(): if owner == me: holdCount += 1; return wait until free owner = me; holdCount = 1 release(): require owner == me holdCount -= 1 if holdCount == 0: owner = none; wake a waiter ``` The lock becomes available to others only when the count hits zero. A **non-reentrant** lock has no such case: a second acquire by the owner joins the wait queue and waits for a release that can only come from the thread now blocked — an unbreakable self-deadlock inside a single thread. ## Why reentrancy is the common default Layered code re-enters constantly, usually without the author noticing: - A synchronised public method calls another synchronised public method on the same object. - A subclass override acquires the lock and calls the superclass implementation, which acquires it again. - A recursive algorithm (tree walk, graph traversal) locks a shared structure at each level. - An event handler invoked from inside a critical section calls back into the guarded component. With a non-reentrant lock, each of these is a hang. Making locks reentrant makes ordinary composition work, so most language-level intrinsic locks and most general-purpose mutex types are reentrant by default, while low-level system mutexes often default to non-reentrant with a recursive mode you opt into. ## The case against reentrancy The critique is that reentrancy converts a loud failure into a silent one. A critical section exists because the state is *temporarily inconsistent* inside it. If, halfway through the mutation, the code calls out to a listener, a callback, or an overridden method that re-enters the guarded component, that code sees a half-updated structure and believes it is properly synchronised — it did, after all, hold the lock. A non-reentrant lock would have deadlocked immediately in testing, which is unpleasant but visible. This is the same reason the standard advice is: **never call alien code while holding a lock**. 'Alien' means anything you do not control — a user-supplied callback, a virtual method a subclass may override, a listener list. Reentrancy makes the mistake possible instead of impossible. A second, milder objection is cost and honesty: the hold count and owner field make acquire slightly more expensive, and a hold count above one can hide the fact that the lock is held far longer than the author thinks, which matters for hold-time discipline. ## Limits of what reentrancy buys you - Reentrancy is **per lock instance and per thread**. Holding lock A does not help you acquire lock B, so classic lock-ordering deadlocks are unaffected. - Reentrancy is **not** inheritable by other threads. If you hand work to a worker thread while holding the lock and that worker acquires the same lock, it blocks — a common cause of deadlock when a synchronous handoff is made inside a critical section. - Interaction with condition variables matters: waiting on a condition must release the lock for the waiter to be woken. A well-designed reentrant lock releases the *entire* hold count on wait and restores it on wake. If you wait while nested and the implementation released only one level, no one else could ever enter — which is exactly why blocking waits inside deeply nested acquisitions are a design smell. - Some designs deliberately choose non-reentrant locks so that any re-entry is a test failure, then structure the code as thin public wrappers (lock, then delegate) around private unlocked internals that assume the lock is already held. That layout — 'locked shell, unlocked core' — gets composition without recursion.

  • If a lock is reentrant, can a thread still deadlock on it?
    Yes, in two ways. Reentrancy only helps the owning thread with that one lock, so acquiring two locks in inconsistent orders across threads still deadlocks. And if the holder blocks waiting on a result produced by another thread that needs the same lock, the holder waits forever — reentrancy does not transfer to other threads.
  • How would you get the ergonomics of reentrancy without the invariant hazard?
    Use the 'locked shell, unlocked core' layout: public entry points acquire the lock and delegate to private methods that document 'caller must hold the lock' and never re-acquire. Then move every alien call — listeners, callbacks, overridable methods — outside the critical section by collecting what to notify under the lock and dispatching after releasing it.

saying these in an interview costs you the question

  • Thinks reentrancy means other threads can also enter while the owner holds the lock
  • Believes a reentrant lock prevents deadlock in general
  • Assumes one release is enough after several nested acquires
  • Sees no downside to reentrancy at all
  • Thinks reentrancy extends to a worker thread the holder hands work to

context