Free lesson · Concurrency Interview Mastery
Reentrant Locks
The mutex lesson mentioned reentrancy in passing as a property some locks have. This lesson treats it as what it really is: a strategic choice with real consequences. A reentrant lock lets a thread that already holds it acquire it again without blocking - and that single capability prevents a subtle self-deadlock, enables a natural code structure, and yet is deliberately refused by some languages (Go, C++ by default) that consider it a design smell. Whether your locks are reentrant changes how you write locked code, and the debate over whether reentrancy is a feature or a trap is itself instructive. It's also a reliable interview topic, because "what happens when a thread locks a lock it already holds?" has a different answer depending on the lock, and knowing why is the depth signal.
The problem reentrancy solves
Consider a thread-safe class where one synchronized method calls another:
class Account:
lock = new Mutex()
function withdraw(amount):
lock.lock()
if balance >= amount:
balance -= amount
lock.unlock()
function transfer(amount, other):
lock.lock()
withdraw(amount) // calls another method that locks the SAME lock
other.deposit(amount)
lock.unlock()
When transfer runs, it locks lock, then calls withdraw, which also tries to lock the same lock - but the calling thread already holds it. What happens now depends entirely on whether the lock is reentrant:
- Non-reentrant lock: the thread blocks, waiting to acquire a lock it itself is holding. It will wait forever, because the only thread that could release the lock is the one now blocked. This is self-deadlock - a thread deadlocking against itself.
- Reentrant lock: the thread recognizes it's the current owner and is allowed to re-acquire, incrementing a hold count.
withdrawproceeds, returns, and the lock isn't fully released until the matching number of unlocks brings the count back to zero. No deadlock.
This is the core thing reentrancy buys: a thread can call its own locked methods from within a locked method without self-deadlocking. Without it, the seemingly innocent "one synchronized method calls another" pattern is a deadlock.
How a reentrant lock works
A reentrant (also called recursive) lock tracks two extra pieces of state beyond a plain mutex: the owner (which thread currently holds it) and a hold count (how many times the owner has acquired it).
acquire():
if owner == currentThread: // already mine?
holdCount += 1 // just count it, don't block
return
else:
// normal acquire: wait if held by someone else, then:
owner = currentThread
holdCount = 1
release():
assert owner == currentThread // only the owner may release
holdCount -= 1
if holdCount == 0:
owner = none // fully released; now others can acquire
// wake a waiter
The logic is simple: on acquire, if the calling thread is already the owner, just bump the count (no blocking); otherwise acquire normally. On release, decrement the count, and only actually release the lock (allowing other threads in) when the count returns to zero. The crucial consequence: you must release exactly as many times as you acquired. Each lock() needs a matching unlock(); a mismatch either leaves the lock held (too few unlocks) or, in some implementations, errors (too many).
flowchart TD
A["thread calls lock()"] --> B{am I already the owner?}
B -->|yes| C["holdCount += 1<br/>proceed (no block)"]
B -->|no| D{lock held by another?}
D -->|yes| E[BLOCK until free]
D -->|no| F["owner = me<br/>holdCount = 1"]
E --> F
C --> G["... later: unlock() decrements count"]
F --> G
G --> H{holdCount == 0?}
H -->|yes| I[fully release; wake a waiter]
H -->|no| J[still held by me]
This flowchart shows how a reentrant lock handles acquisition. When a thread calls lock, it first checks: am I already the owner? If yes, it simply increments the hold count and proceeds without blocking - this is what prevents self-deadlock. If no, it checks whether another thread holds the lock; if so, it blocks until the lock is free, then becomes the owner with a hold count of one. On unlock, the hold count is decremented. Only when the count reaches zero is the lock fully released and a waiting thread woken. The key point is that acquiring and releasing must balance - the same thread must unlock as many times as it locked before the lock is truly free.
Reentrancy as a strategic choice - and the case against it
Here's where it gets interesting, and where the interview depth lives. Reentrancy seems purely beneficial - it prevents self-deadlock - so why would any language not make locks reentrant? Several do refuse: Go's sync.Mutex is non-reentrant (re-locking self-deadlocks), and C++'s std::mutex is non-reentrant (you must opt into std::recursive_mutex). This is a deliberate design stance, and the reasoning is worth understanding:
- Reentrancy can mask design problems. If your code re-acquires a lock it already holds, that often means your lock scope is tangled - a locked method calling another locked method may indicate the methods should be restructured (e.g., a private unlocked helper that both call while one holds the lock). Non-reentrant locks force you to confront this: the self-deadlock is a loud signal that your locking structure is unclear, which you fix by clarifying the structure rather than papering over it with reentrancy.
- Reentrancy makes lock scope harder to reason about. With a reentrant lock, the lock may be held longer and across more code than you realize, because a re-acquisition deep in a call chain extends the held region invisibly. The invariant "this code runs without holding the lock" becomes hard to verify when any callee might already hold it.
- Reentrancy has overhead. Tracking owner and count is more work than a plain mutex's single flag, though this cost is usually minor.
Important: Reentrancy is a genuine trade-off, not a free feature. Java and C# make locks reentrant by default (convenient, prevents self-deadlock, supports the natural "synchronized method calls synchronized method" pattern). Go and C++ make them non-reentrant by default (forcing explicit, clear lock scope, and surfacing tangled locking as a loud self-deadlock rather than a silent extended hold). Neither is wrong - they reflect different philosophies. The convenience camp says reentrancy removes a footgun; the explicit camp says it hides design problems you'd be better off seeing. Knowing that the choice is deliberate, and being able to argue both sides, is exactly the nuanced answer that distinguishes deep understanding from "reentrant is better because no self-deadlock."
The callback / reentrancy hazard
A specific danger illustrates the "reentrancy hides problems" argument. Suppose a method holds a lock and, while holding it, invokes a callback - and that callback calls back into the same object, re-acquiring the (reentrant) lock. With a reentrant lock, the callback succeeds in acquiring the lock and runs - but it may observe the object in a partially updated, inconsistent state, because the original method was in the middle of a multi-step update when it called out. The reentrancy let the callback in precisely when it should have been kept out. A non-reentrant lock would have blocked (or made the problem obvious), whereas reentrancy silently admitted the callback into an invariant-violating moment. This is the subtle case where reentrancy's convenience becomes a correctness hazard, and it's why holding locks across callbacks (reentrant or not) is generally discouraged.
Cross-language summary
The landscape, since "is this lock reentrant?" varies and matters: Java - synchronized and ReentrantLock are reentrant (the latter named for it). C# - lock/Monitor is reentrant. Go - sync.Mutex is non-reentrant; re-locking self-deadlocks, a deliberate choice to keep lock scope explicit. C++ - std::mutex is non-reentrant; std::recursive_mutex is the opt-in reentrant version (and its use is often considered a sign to reconsider the design). POSIX - configurable via mutex attributes (PTHREAD_MUTEX_RECURSIVE). The pattern: managed, application-level languages tend to default reentrant for convenience; systems languages tend to default non-reentrant for explicitness. Knowing the default for the language you're interviewing in matters, because the same nested-locked-call code is correct in Java and a self-deadlock in Go - and being able to say that crisply demonstrates you've actually been bitten by it.
The alternative to reentrancy: the unlocked-helper pattern
Because the non-reentrant camp's argument is "reentrancy lets you avoid confronting tangled lock scope," it's worth seeing the structure they'd use instead - because it's a genuinely good pattern even in languages where reentrancy is available. The idea is to separate locked entry points from unlocked workers. Public methods acquire the lock and delegate to a private helper that assumes the lock is already held and does no locking of its own. Any method that already holds the lock calls the helper directly rather than re-entering a public method.
// Public entry points: acquire the lock, then delegate
function withdraw(amount):
lock.lock()
withdrawLocked(amount) // delegate to the unlocked worker
lock.unlock()
function transfer(amount, other):
lock.lock()
withdrawLocked(amount) // call the WORKER, not the public withdraw()
// ... other work under the same held lock ...
lock.unlock()
// Private worker: assumes the lock is ALREADY held; never locks
function withdrawLocked(amount):
if balance >= amount:
balance -= amount
This structure makes lock scope explicit and visible: every method's name and contract tells you whether it expects the lock to be held. There's no re-acquisition anywhere, so it works identically with reentrant or non-reentrant locks - and it can't self-deadlock. More importantly, it forces you to think about which code runs under the lock, which is exactly the clarity the non-reentrant default is trying to push you toward. Many experienced engineers prefer this pattern even in Java or C# (where reentrancy would let them skip it), because the explicitness prevents the "lock held longer than I realized" confusion that reentrancy can hide. The convention of suffixing helpers with Locked (or documenting "caller must hold the lock") is common precisely because it makes the lock-scope contract part of the API. When an interviewer probes "how would you structure a thread-safe class where methods call each other," producing this locked-entry/unlocked-worker split is a strong answer that demonstrates you think about lock scope structurally rather than leaning on reentrancy as a crutch.
When reentrancy helps versus when it hides a problem
The honest synthesis, since this is a real judgment call you'll face: reentrancy is genuinely helpful in a few situations and a mask for a problem in others, and distinguishing them is the skill. It genuinely helps when a class has a natural hierarchy of operations where higher-level operations are legitimately composed of lower-level ones - a higher-level method that performs a small atomic action and also a compound action built from several of those small actions, all needing to be atomic as a unit. Here, reentrancy lets the compound method call the simple ones while holding the lock for the whole compound operation, which is exactly the desired semantics. Forcing the unlocked-helper refactor here is possible but arguably just ceremony; reentrancy expresses the intent cleanly.
Reentrancy masks a problem when the re-acquisition happens because lock scope is unclear or because the code calls out to other components that happen to call back in. If you only discovered you needed reentrancy when something self-deadlocked, that self-deadlock was information: it was telling you that your locked code reaches further than you thought. Reaching for reentrancy to silence it, rather than understanding why the re-entry happens, can paper over a design where the lock is held across far more code than intended - including, in the worst case, across callbacks into uncontrolled code that then observe inconsistent state. The discriminating question to ask yourself: do I want this re-entry to be possible, as a deliberate part of the design, or am I enabling it to make a symptom go away? If the former, reentrancy is fine and clean. If the latter, the non-reentrant lock's loud failure was doing you a favor, and the right move is to clarify the structure (often via the unlocked-helper pattern) rather than to permit the re-entry. That diagnostic - deliberate composition versus silenced symptom - is the mature way to think about reentrancy, and it's what separates someone who understands the trade-off from someone who treats reentrancy as simply "the safer default."
Note: A clean way to remember the whole topic: reentrancy is the lock answering the question "what if the owner asks again?" with "sure, just keep count" instead of "no, wait" (which, since the owner is the only one who could release, means wait forever). That one design decision ripples outward - it's the difference between nested locked calls Just Working and self-deadlocking, between a convenient default and an explicit-scope discipline, between Java/C# and Go/C++. When you hit "lock a lock I already hold," picture the owner-and-count machinery, and both the behavior and the trade-off fall out of it.
The interview angle
Interview: Reliable questions: (1) "What is a reentrant lock?" - One a thread can re-acquire while already holding it, tracked by owner + hold count, requiring an equal number of releases; it prevents self-deadlock when locked code calls other locked code. (2) "What happens if a thread locks a non-reentrant lock it already holds?" - Self-deadlock: it blocks forever waiting for a lock only it could release. (3) "Why are Go and C++ mutexes non-reentrant by default?" - A deliberate choice: non-reentrancy forces explicit, clear lock scope and surfaces tangled locking as a loud self-deadlock, where reentrancy might silently extend a held region or mask a design problem. (4) "Downside of reentrancy?" - It can hide structural issues, make lock scope harder to reason about, and admit callbacks into inconsistent state. (5) "How is it implemented?" - Owner thread + hold count: re-acquire by the owner increments the count; full release only at count zero. Arguing both sides of the reentrancy trade-off - convenience vs explicitness - is the standout answer; the shallow take is "reentrant avoids self-deadlock, so it's better."
Quick reference
The essentials, keeping in mind that "is this lock reentrant?" has a different answer per language and changes how you must write locked code:
REENTRANT (recursive) LOCK = the owner can re-acquire it without blocking.
implemented via: owner thread + hold count
acquire by owner -> count++ (no block)
release -> count--; FULLY released only at count 0
=> must unlock EXACTLY as many times as you locked
WHY IT MATTERS: prevents SELF-DEADLOCK when locked code calls other locked code
non-reentrant + re-lock-self = thread deadlocks against itself (waits forever)
THE TRADE-OFF (deliberate, both valid):
reentrant default (Java synchronized/ReentrantLock, C# lock):
+ convenient, prevents self-deadlock, supports nested synchronized calls
non-reentrant default (Go sync.Mutex, C++ std::mutex):
+ forces explicit/clear lock scope; tangled locking surfaces as a loud self-deadlock
(C++ opt-in: std::recursive_mutex; POSIX: PTHREAD_MUTEX_RECURSIVE)
HAZARD: holding a lock across a callback that re-enters (reentrant) can admit the callback
into a PARTIALLY-UPDATED, inconsistent state. Avoid locking across callbacks.
Reentrancy is about re-acquiring a lock you hold. The next lesson is about not committing to acquire at all: try-lock and timed locking let a thread attempt a lock and back off if it can't get it, turning the all-or-nothing blocking model into something more flexible - and giving you a powerful tool against deadlock.