The question this answers
What happens if this function is called again — by a callback, a signal, or itself — before the first call returns?
A logging function that formats into a static buffer, interrupted by a signal handler that also logs; and a cache whose eviction callback calls back into the cache.
For the classic non-reentrant case, a static or global buffer reused across calls. For the callback case, the object's own invariants mid-mutation — the state is not shared between threads at all, and the bug still happens.
A call that begins observes the object in a consistent state and leaves it consistent; no call observes the intermediate state of another call, including its own outer invocation.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Two independent properties, four combinations
Thread safety asks: can two threads execute this concurrently? Reentrancy asks: can this be entered again before an earlier entry returns, on the same thread? The second happens without any concurrency at all — via recursion, via a callback the function itself invoked, via a signal handler that interrupts it, or via re-entering through a different path in the same call stack.
They are genuinely independent, and the four combinations all exist in real code. A function using a static buffer with no lock is neither. A function using a static buffer with a lock is thread-safe but not reentrant — and worse, if that lock is not recursive, re-entering it self-deadlocks. A function operating only on its arguments and locals is reentrant but not necessarily thread-safe if its arguments alias shared data. A pure function of its arguments is both.
The one that catches people is thread-safe-but-not-reentrant, because the lock creates a false sense of completeness. Adding a mutex answers the concurrency question and can make the reentrancy question worse: without the lock, re-entry corrupts the buffer; with a non-recursive lock, re-entry hangs the thread on a lock it already holds.
| Reentrant | Not reentrant | |
|---|---|---|
| Thread-safe | Pure function of its arguments, or one using only locals and immutable data: snprintf into a caller-supplied buffer, a hash of an immutable input. Safe from any thread and from any re-entry. | A function guarding a static buffer with a non-recursive mutex. Two threads are fine — they queue. The same thread re-entering via a signal handler or callback deadlocks on the lock it already holds. |
| Not thread-safe | A function mutating only state reachable from its arguments, with no internal static state. Re-entering with a different argument is fine; two threads passing the same object race. | The classic: formatting into a static buffer with no lock. strtok is the textbook case — a second call clobbers the first call's state, whether that second call came from another thread or from a signal handler on the same one. |
Re-entry without a second thread
The reason this is worth a lesson rather than a footnote is that re-entry happens in ordinary single-threaded code, constantly, through callbacks. A cache evicts an entry and calls the eviction listener; the listener consults the cache; the cache is mid-mutation with its size counter already decremented and the entry not yet removed. Nothing is shared between threads. There is no race condition in the timing sense. The observed state is simply inconsistent, because the object was entered while it was in the middle of updating itself.
Signal handlers are the same shape with harder rules. A handler can interrupt the main flow at any instruction, so anything it calls must be async-signal-safe — which is a much stronger property than reentrancy, and rules out malloc, printf and most of the standard library. Calling a non-reentrant allocator from a handler that interrupted the allocator is how a process deadlocks or corrupts its heap with no concurrency present.
Async/await produces a third variant. An await point is a re-entry opportunity: the caller's state is suspended mid-operation, the event loop runs something else, and that something else may call the same function on the same object. It is not reentrancy in the classical stack sense and it has the identical consequence — a second entry observing the first entry's intermediate state. Single-threaded runtimes do not exempt you from this. See Await Is a Yield Point and The Atomicity Illusion.
1class BoundedCache {2 private map = new Map<string, V>()3 private size = 04 onEvict?: (k: string) => void5 6 set(k: string, v: V) {7 if (this.size >= this.max) {8 const victim = this.oldest()9 this.size-- // <-- invariant broken here10 this.onEvict?.(victim) // <-- re-entry point: listener may call set()11 this.map.delete(victim) // <-- invariant restored only here12 }13 this.map.set(k, v)14 this.size++15 }16}17// A listener that calls cache.set() sees size decremented and the victim18// still present: size and map.size disagree, and the cache slowly overfills.19 20// The async variant, same failure, different mechanism:21async function refresh(id: string) {22 if (this.refreshing.has(id)) return // guard read23 await this.load(id) // <-- another call runs during this await24 this.refreshing.add(id) // guard set, far too late25}26// Two concurrent calls both pass the guard. One event loop, no threads,27// two loads. Set the guard BEFORE the await, not after.Making things reentrant, and what each fix costs
The strongest fix is to have no state that survives across the call: operate on arguments and locals, and let the caller own the buffer. This is why the reentrant variants of the classic C functions take an explicit context or buffer parameter — strtok_r over strtok, localtime_r over localtime. The cost is a less convenient signature, which is a small price.
The second fix is to restore the invariant before any re-entry point. In the cache example, delete the victim and fix the size counter *before* invoking the listener, so any re-entrant call sees a consistent object. Better still, defer the callback entirely: collect what needs notifying, complete the mutation, and invoke listeners after the method's own state is settled. That is the general rule — never call out to unknown code while your invariants are broken.
Recursive locks are the fix that deserves suspicion. A recursive mutex lets the same thread reacquire a lock it holds, which prevents the self-deadlock and does nothing about the invariant. The re-entrant call still observes the half-updated state; you have merely removed the hang that was drawing attention to it. Reach for one when integrating with code you cannot restructure, and treat it as a signal that the critical section is doing too much. See Mutexes: What They Protect and What They Do Not and Lock Scope: What You Hold It Across.
| # | Thread T — outer call | Thread T — re-entrant call via listener | State |
|---|---|---|---|
| 1 | set("k9") enters; cache is full | · | size=100 mapEntries=100 |
| 2 | size-- (victim chosen but not yet removed) | · | size=99 mapEntries=100 ✕ size and map disagree. The object is mid-update and, on this line, incorrect by design. |
| 3 | onEvict(victim) — calls out to unknown code | · | size=99 mapEntries=100 |
| 4 | · | listener calls set("k10"); reads size = 99 < max, skips eviction | size=100 mapEntries=101 ✕ The re-entrant call made a decision from the outer call's intermediate state, and the cache now exceeds its bound. |
| 5 | map.delete(victim) — outer call finally restores its own invariant | · | size=100 mapEntries=100 |
| 6 | --- fixed: restore the invariant before calling out --- | · | size=100 mapEntries=100 |
| 7 | map.delete(victim); size-- (both together) | · | size=99 mapEntries=99 |
| 8 | onEvict(victim) | · | size=99 mapEntries=99 |
| 9 | · | listener calls set("k10") — observes a consistent cache | size=100 mapEntries=100 |
Key points
- Reentrancy asks whether a function can be entered again before it returns; thread safety asks whether two threads can run it at once. They are independent properties.
- All four combinations exist, and thread-safe-but-not-reentrant is the dangerous one — with a non-recursive lock it self-deadlocks, and with a recursive one it silently observes broken invariants.
- Re-entry happens without any concurrency: recursion, callbacks, signal handlers, and await points all re-enter.
- The rule that prevents most of it: never call out to unknown code while your invariants are broken.
- A recursive mutex removes the self-deadlock and does not restore the invariant. It hides the symptom that was pointing at the design problem.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • Identify every point in a function where control can leave it before it finishes: a callback, a virtual call, a signal, an await, a recursive call.
- • At each of those points, ask what the object's invariant is at that instant. If it is broken, a re-entrant call will observe it broken.
- • Restructure so the invariant is restored before control leaves — complete the state change, then notify.
- • Remove state that survives across calls: take a caller-supplied buffer or context instead of using a static one.
- • Where re-entry cannot be prevented and state must persist, either defer the outbound call to after the method returns, or use a recursive lock knowingly and document why.
- • Callback re-entry: T decrements size, calls the listener, the listener calls set() and observes size < max, skips eviction, and the cache permanently exceeds its bound — one thread, one stack.
- • Self-deadlock: T holds a non-recursive mutex, calls a listener, the listener calls back into the locked method, and T blocks waiting for a lock it holds. The thread dump shows one thread waiting on a lock owned by itself.
- • Signal handler: a handler interrupts the allocator mid-update and calls printf, which allocates; the allocator's internal state is inconsistent and the heap is corrupted or the process hangs.
- • Static buffer clobber: T calls format() which fills a static buffer; a signal handler calls format() and overwrites it; T returns a pointer to the handler's output.
- • Await re-entry: task 1 reads the guard (absent), awaits the load; task 2 reads the guard (absent), awaits the load; both proceed. No threads, two loads. Setting the guard before the await removes the schedule.
- • Recursive lock, unchanged bug: T holds a recursive mutex mid-mutation, re-enters, reacquires successfully, and reads the half-updated state — no deadlock, same corruption, and now no symptom to notice.
- • A reentrant function guarantees that entering it again before a previous entry returns is safe — provided the arguments do not alias shared state.
- • It does NOT guarantee thread safety. A reentrant function operating on a shared object passed by reference races just like anything else.
- • A thread-safe function does NOT guarantee reentrancy. A lock serializes threads and does nothing about the same thread coming back in.
- • A recursive mutex guarantees re-acquisition by the owning thread succeeds; it does NOT guarantee the state protected by the lock is consistent at that moment.
- • Async-signal-safety is a strictly stronger guarantee than reentrancy, and most reentrant functions are not async-signal-safe.
- • A single-threaded runtime guarantees no preemption between statements; it does NOT guarantee anything across an await.
- • Reentrancy is not primarily a contention topic — the failure occurs with one thread and no waiting at all.
- • It becomes a contention topic through recursive locks: they extend hold time across whatever the re-entrant call does, including any I/O it performs.
- • Calling out to unknown code while holding a lock is the lock-across-I/O anti-pattern arriving by a side door — you do not know what the listener does.
- • A self-deadlock removes a thread from the pool permanently, so under load the symptom is pool exhaustion rather than a hang. See Pool Saturation.
- • Self-deadlock on a non-recursive lock reacquired by the owning thread.
- • Invariant violation observed by a re-entrant call, producing state corruption with no race and no error.
- • Static buffer clobbering, where the inner call overwrites the outer call's working data.
- • Heap corruption or hang from calling a non-async-signal-safe function in a signal handler.
- • Unbounded recursion when the re-entrant path re-triggers the same condition, ending in stack overflow.
- • Duplicate work across an await point when a guard is set after the await instead of before.
- • Iterator invalidation when a listener mutates the collection the outer call is iterating.
- • Writing reentrant functions helps everywhere and costs almost nothing: use locals and arguments, avoid static state, and the property comes for free.
- • It is mandatory for signal handlers, interrupt paths and anything invoked from unknown contexts.
- • It matters most for library code, which cannot know whether its callbacks will call back in.
- • Restoring invariants before calling out helps even when re-entry is impossible today, because it is the property that keeps being true when someone adds a listener next year.
- • Making a function reentrant by threading a context parameter through a long call chain can be a large, mechanical refactor with real churn.
- • Recursive locks make re-entry work and make the design worse, by permitting a class of bug that no longer announces itself.
- • Deferring callbacks until after a mutation changes observable ordering, which may be a contract change for existing listeners.
- • Over-applying the idea to code with genuinely no re-entry path adds parameters and indirection for nothing.
- • Thread dumps showing a thread waiting on a lock it owns — the unambiguous signature of a non-reentrant self-deadlock. See Reading a Thread Dump.
- • Stack depth or an explicit re-entry counter inside methods that invoke callbacks; a counter above one is the whole diagnosis.
- • Invariant assertions at method entry and exit (size equals map size), which catch the corruption at the moment it is observed rather than much later.
- • Duplicate-execution counters across await boundaries, which detect the async re-entry variant.
- • Static analysis for non-async-signal-safe calls inside signal handlers, which is a solved problem and worth wiring into CI.
- • It adds a second property to reason about for every shared function, and one that no type system tracks.
- • Callback ordering becomes part of your contract: listeners now fire after the mutation rather than during it, and someone depended on the old behaviour.
- • Threading an explicit context through a call chain to remove static state is invasive and touches every caller.
- • Recursive locks are simple to adopt and hard to remove, because code accumulates that depends on the re-entry working.
- • Pure functions of their arguments, which are reentrant by construction and usually thread-safe as a bonus.
- • Immutable data, which cannot be observed in an intermediate state because it has none. See Immutability as a Concurrency Strategy.
- • Deferring outbound calls: build a list of notifications during the mutation and dispatch after it completes.
- • Message passing instead of callbacks — post an event to a queue rather than invoking unknown code inside your critical region. See Message Passing.
- • Confining mutation to a single owner that processes one operation at a time to completion. See The Actor Model.
Two increments, twenty schedules: find the one that loses an update
| # | Task A — counter++ | Task B — counter++ | State |
|---|---|---|---|
| 1 | rA ← counter | · | counter=0 rA=0 rB=0 |
| 2 | rA ← rA + 1 | · | counter=0 rA=1 rB=0 |
| 3 | counter ← rA | · | counter=1 rA=1 rB=0 |
| 4 | · | rB ← counter | counter=1 rA=1 rB=1 |
| 5 | · | rB ← rB + 1 | counter=1 rA=1 rB=2 |
| 6 | · | counter ← rB | counter=2 rA=1 rB=2 |
Thread dump lab
The frames, thread ids and monitor address are made up to show the shape of the reasoning. Other runtimes print this differently — Go dumps goroutines, Python dumps frames per thread, Node has no equivalent because its work is not on threads at all. What transfers is the method: read states, group the blocked threads by the monitor they name, then find the one thread that does not say “waiting to lock”.
"http-nio-8080-exec-1" #21 daemon prio=5 tid=0x00007f9c1001 nid=0x3b01
java.lang.Thread.State: BLOCKED (on object monitor)
at com.shop.Inventory.reserve(Inventory.java:88)
- waiting to lock <0x00000007a1b2c3d4> (a com.shop.Inventory)
at com.shop.CheckoutService.placeOrder(CheckoutService.java:141)
at com.shop.CheckoutController.post(CheckoutController.java:57)
at org.apache.tomcat.util.threads.TaskThread.run(TaskThread.java:61)What people believe, and what is true
Reentrant and thread-safe are two words for the same thing.
They are independent. A static buffer with a mutex is thread-safe and not reentrant; a pure function over a shared mutable argument is reentrant and not thread-safe. All four combinations occur in real code.
A single-threaded program cannot have reentrancy bugs.
Callbacks, recursion, signal handlers and await points all re-enter with one thread. The cache-eviction listener bug happens on one stack with no concurrency present.
Use a recursive mutex and the problem is solved.
It solves the deadlock and leaves the invariant violation. The re-entrant call still reads half-updated state — you removed the hang that was making the bug visible.
Anything reentrant is safe to call from a signal handler.
Async-signal-safety is stronger. malloc is thread-safe, arguably reentrant in some designs, and absolutely not safe in a handler that interrupted an allocation.
Go deeper
Overview
Reentrancy is about being called again before you finish — by yourself, a callback, or a signal. Thread safety is about two threads at once. Different questions, different fixes.
Practical
Never call out to unknown code while your invariants are broken. Finish the state change, then notify. Set guards before an await, not after. Prefer locals and arguments to static state.
Advanced
Audit every callback, virtual call and await point in stateful methods and ask what the invariant is at that instant. Treat a recursive lock as a smell indicating the critical section is doing too much.
Internals
Signal handlers are the hardest case because the interruption can occur at any instruction, including inside the allocator or the standard library. Async-signal-safety requires that a function makes no assumption about the state of anything it might have interrupted, which is why the permitted list is so short.