Reentrancy
intermediate · occasionally asked
One thread, one lock, no concurrency — and it hangs. A public method calls another public method, and both take the lock.
The problem it solves
A public method takes a lock, does some work, and calls a helper. The helper is also public, so it takes the lock too. The program hangs.
One thread. One lock. No concurrency of any kind. This is the cleanest counterexample to “deadlock needs two threads”, and it catches people who have only ever used one kind of mutex — which is most people, because whether a lock is reentrant is a property of the primitive rather than of the language, and both kinds sit side by side in every major ecosystem.
The thread is waiting for a lock. The lock is held. The holder is the thread that is waiting. There is no scheduling decision anywhere in the system that can resolve this, because the only thread that could release the lock is blocked on acquiring it.
The mechanism
A reentrant (or recursive) mutex remembers who owns it and keeps a count. Acquiring it when you already hold it increments the count and returns immediately; releasing decrements, and only the outermost release actually frees the lock. synchronized and ReentrantLock in Java behave this way, as does RLock in Python and a POSIX mutex configured with PTHREAD_MUTEX_RECURSIVE.
A non-reentrant mutex knows only whether it is held. Acquiring it when you already hold it blocks, forever, against yourself. A default pthread_mutex_t does this (technically undefined behaviour, in practice a hang or an error), and so does Go’s sync.Mutex and Rust’s Mutex — deliberately, in both cases.
That deliberateness is worth understanding, because “reentrant is obviously better” is the intuitive position and it is not what the designers of those languages concluded. A reentrant lock lets you re-enter a critical section in the middle of an operation that has not finished. The invariant the lock protects may be temporarily broken at that moment — that is what critical sections are for — and recursive acquisition means code can observe it in that state and believe it is safe, because after all it holds the lock. Go’s authors regard needing recursive locking as a signal that the code should be restructured, and there is a real argument for it.
What the enumeration shows
There is exactly one schedule here — one thread, so there is nothing to interleave — and that is the point. The whole outcome space of this program has a single member, and it is a deadlock.
Step through it: lock (public), do some work, lock (helper) — and the thread’s badge turns to blocked · mutex m held by T1. Held by itself. The wait-for graph shows a thread waiting on a lock whose owner is that same thread, which is a cycle of length one.
Switch to reentrancy and the same program runs straight through. Nothing about the code changed; the lock’s behaviour did.
The numbers worth carrying
- Non-reentrant, one thread: 1 schedule, 1 outcome, and it is a deadlock.
- Reentrant, same program: 1 schedule, completes.
- A reentrant lock costs an owner field and a counter — a few bytes and one comparison on the fast path. It is not the overhead that makes languages avoid it.
Where it breaks down
Reentrancy hides broken invariants. The mutex is held precisely because the data is mid-update. Recursive acquisition means a function can be entered while its own invariant is temporarily false, and nothing warns you — the code holds the lock, so every check says it is safe. The bug that results is much harder to find than the hang would have been.
Reentrancy does not compose across locks. It solves re-acquiring the same lock. Taking a second lock while holding the first is still deadlock territory, and a reentrant mutex does nothing for it.
Condition variables interact badly. wait releases the mutex — but on a reentrant lock held twice, releasing it once leaves it held, and the thread waits forever holding a lock nobody can take. Java’s Condition.await releases the full hold count and restores it on wake, precisely to avoid this. If you implement anything of the kind yourself, that is the subtlety.
Read locks are usually not reentrant in the way you expect. A thread holding a read lock that tries to upgrade to a write lock deadlocks against itself in most implementations, since the writer must wait for all readers including this one. Java’s ReentrantReadWriteLock explicitly forbids upgrading and permits downgrading.
What people get wrong
“Deadlock needs two threads.” This page. One thread, one lock.
“All mutexes are reentrant.” synchronized is, sync.Mutex is not, and the default pthread_mutex_t is not. Assuming either way is how this bug ships.
“Reentrant is strictly better.” It is more forgiving and it lets you observe half-finished state. Two language designers chose against it on purpose.
“I will just check whether I already hold it.” Then you have built a reentrant lock with extra steps, and you still have the invariant problem. If you need this, the honest fix is usually to split the method: a public one that locks and calls a private one, and a private one that assumes the lock is already held. That naming convention — doThingLocked — exists in a lot of codebases for exactly this reason.
In production
The practical rule that avoids the whole question: do not call public methods of your own class while holding its lock. Extract the shared work into a private method that documents “caller must hold the lock”, and have both the public entry point and the internal caller use it. This works with either kind of mutex and removes the recursion rather than tolerating it.
When you do need recursion — a tree walk that locks each node, a visitor that may revisit — a reentrant lock is the right tool and you should be explicit about it: RLock in Python, ReentrantLock in Java, PTHREAD_MUTEX_RECURSIVE in C. Naming it in the declaration tells the next reader that recursive acquisition is intended rather than accidental.
And if a hang appears with only one thread involved, this is the first thing to check. A thread dump showing a single thread blocked on a monitor it owns is unambiguous, and it takes seconds to spot once you know the shape.
The follow-up questions
“Can a single thread deadlock?” — Yes, on a non-reentrant lock. This is the question this page exists for.
“Is synchronized reentrant?” — Yes. Is sync.Mutex? No. Knowing that the answer differs by platform is the real content.
“Why would anyone choose non-reentrant?” — Because recursive acquisition means re-entering a critical section whose invariant is currently broken. Being able to give that reason is a much stronger answer than a preference.
“You need to call a public method from inside a critical section. What do you do?” — Extract a private lock-free version. Not “make the lock reentrant”.
In an interview
The cleanest counterexample to "deadlock needs two threads", and a question that catches people who have only ever used one kind of lock.
- reentrant
- recursive locking
- self-deadlock
Run these next
- DeadlockA cycle in the wait-for graph is the deadlock. Lock ordering removes it by construction, and it is only a policy when it is a total order over every lock.
- MutexesA lock removes orderings from the space. The critical section must span the whole read-modify-write — guarding only the write leaves the window exactly where it was.
- Starvation and fairnessAn ordinary mutex makes no promise about who gets in next — a released lock can go straight back to the thread that released it. Fairness is a feature you opt into, and it costs throughput.