What mutual exclusion actually guarantees
A mutex (mutual exclusion lock) is a synchronization primitive with two states — locked and unlocked — and two operations, acquire()/lock() and release()/unlock(). Once a thread acquires it, every other thread that calls acquire() blocks until the owner calls release(). Wrapping a critical section between those two calls gives you the same three correctness properties any solution to the critical-section problem must provide (this table should look familiar from the previous topic):
| Property | What a correct mutex guarantees |
|---|
| Mutual exclusion | At most one thread executes the protected critical section at a time. |
| Progress | If no thread is inside the critical section, one of the threads waiting to enter is eventually chosen — the decision can't be postponed forever. |
| Bounded waiting | A thread that requests the lock is granted it within a bounded number of other threads' turns — no thread waits forever while others repeatedly cut in line. |
A mutex does not guarantee fairness (FIFO ordering) unless it's explicitly built as a fair lock — most default implementations (synchronized in Java, a default pthread_mutex_t) are unfair for throughput reasons, and a thread can in principle be repeatedly overtaken (though not indefinitely, per bounded waiting). It also says nothing about what you protect: a mutex enforces exclusive access to a piece of code, not to a piece of data. If two different critical sections both touch the same variable but are guarded by two different locks, you have zero real protection. The lock and the data it protects must be a documented 1:1 (or N:1) contract, and every access path to that data needs to go through the same lock.
Why plain mutexes aren't enough: the self-deadlock problem
A naive mutex tracks exactly one bit of state: locked or unlocked. It has no notion of who holds it. That's fine until a thread that already holds the lock calls into another function — directly, recursively, or through a callback — that tries to acquire the same lock again. The naive mutex sees "already locked" and blocks the calling thread, which is the one thread that could ever release it. The thread is now waiting for itself. This is called self-deadlock, and it's easy to trigger without noticing:
- A method acquires a lock, then calls a private helper method that (defensively, or through inheritance) acquires the same lock again.
- A recursive algorithm — a tree traversal, a parser — that needs to hold a lock across every recursive call.
- An event handler invoked while already inside a locked block, common with observer/listener patterns, that reacquires the lock as part of its own logic.
A reentrant lock (also called a recursive lock) fixes this by tracking two extra pieces of state: the owning thread and a hold count. acquire() succeeds immediately, without blocking, if the calling thread already owns the lock, and simply increments the count. release() decrements the count, and the lock is only actually freed for other threads once the count returns to zero — meaning every acquire must be matched by exactly one release, usually enforced by writing them in matching pairs (try/finally or an RAII-style block) so an exception or early return can't leave the count permanently elevated.
count = 0
owner = none
acquire():
if owner == current_thread:
count += 1
return # re-entry: no blocking
wait until unlocked
owner = current_thread
count = 1
release():
count -= 1
if count == 0:
owner = none
wake one waiter
Reentrancy is a convenience feature bought at a real, if usually small, cost: every acquire/release pair now does extra bookkeeping — checking and updating the owner and count — compared to a bare binary mutex. That's why some ecosystems make it opt-in rather than the default:
| Language / runtime | Default synchronized/basic lock | Reentrant? | Explicitly non-reentrant option |
|---|
| Java | synchronized keyword, ReentrantLock | Yes, always | None built-in — both are reentrant |
| Python | threading.Lock | No — self-reacquire deadlocks | threading.RLock is the reentrant version |
| Kotlin coroutines | kotlinx.coroutines.sync.Mutex | No — explicitly documented as non-reentrant | (no reentrant coroutine mutex in the standard library) |
| C++ (pthreads) | std::mutex | No | std::recursive_mutex |
The Kotlin row is a genuine gotcha worth internalizing: unlike Java, where "just use a lock" is safely reentrant by default, Kotlin's coroutine Mutex will suspend a coroutine forever if it tries to lock() a mutex it's already holding on the same logical call stack — the "recursive call through a suspending function" scenario above is a live footgun there, not a theoretical one.
Pitfalls and interview gotchas
- Assuming all locks are reentrant. The single most common bug this topic produces: code written and tested against Java's (always-reentrant)
synchronized, then ported to Python or Kotlin coroutines, deadlocks the first time a recursive or callback path re-enters the lock.
- Forgetting to release on every exit path. An exception thrown inside a critical section, or an early return, must still release the lock — this is why every mature lock API pairs with a structured "release no matter what" construct (
try/finally, with, withLock { }, RAII), and why a hand-rolled lock(); ...; unlock(); without that structure is considered unsafe in production code.
- Confusing "reentrant" with "thread-safe for concurrent access." Reentrancy only means the same thread can re-acquire the lock it holds; it says nothing about two different threads, which still fully exclude each other exactly as before.
- Using "I hold a lock" as the correctness argument, instead of "I hold the lock that guards this specific data." Two unrelated locks provide zero mutual exclusion between the sections they each guard, even if both sections modify the same shared variable.
- Holding a lock across a blocking or slow operation — I/O, another lock acquisition, a long computation — needlessly serializes every other thread waiting on it. The fix is almost never "use a fancier lock," it's "shrink what's inside the critical section." This concern becomes central in the next two subtopics: spinlocks make the cost of a long critical section brutally visible as 100% CPU burn while waiting, and lock granularity is precisely the question of how to shrink or split what a single lock protects.