Concurrency Roadmap/Locks & Mutual Exclusion

Mutexes & Reentrant Locks

A mutex grants exclusive access to a critical section and blocks every other claimant — a reentrant lock extends that guarantee so a thread already holding the lock can safely re-enter it without deadlocking itself.

!!2/5Theory: 30m1 problems
Language-specific mechanics: Concurrency Language Manual — Locks, Mutexes & Synchronized Access

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):

PropertyWhat a correct mutex guarantees
Mutual exclusionAt most one thread executes the protected critical section at a time.
ProgressIf 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 waitingA 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 / runtimeDefault synchronized/basic lockReentrant?Explicitly non-reentrant option
Javasynchronized keyword, ReentrantLockYes, alwaysNone built-in — both are reentrant
Pythonthreading.LockNo — self-reacquire deadlocksthreading.RLock is the reentrant version
Kotlin coroutineskotlinx.coroutines.sync.MutexNo — explicitly documented as non-reentrant(no reentrant coroutine mutex in the standard library)
C++ (pthreads)std::mutexNostd::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.
Reference implementations in:

Basic Mutex: Guarding a Critical Section

The same shape everywhere: acquire, do the minimal amount of work, release — with the release guaranteed even if the critical section throws.

var mu sync.Mutex var counter int func increment() { mu.Lock() defer mu.Unlock() // always released, even on panic counter++ // critical section }

defer mu.Unlock() immediately after Lock() is the idiomatic try/finally — it runs even if the function panics. sync.Mutex is not reentrant and must never be copied (including via pass-by-value of a containing struct) after first use; the -copylocks vet check flags that.

Reentrancy: The Self-Deadlock Trap

The exact same call pattern — a locked function calling another locked function on the same lock — is either a non-issue or a permanent hang, depending entirely on whether the lock is reentrant.

var mu sync.Mutex func outer() { mu.Lock() defer mu.Unlock() inner() // DEADLOCKS: sync.Mutex is NOT reentrant } func inner() { mu.Lock() // blocks forever — this same goroutine already holds mu defer mu.Unlock() }

sync.Mutex has no owning-goroutine concept, so a second Lock() from the same goroutine blocks forever. Go deliberately provides no reentrant lock in the standard library; the usual fix is a locked public function plus an unlocked private helper that assumes the lock is already held. Never copy a Mutex.

Further Resources (Optional)

Practice Problems

Interview relevancy:!!!Critical — must-have!!Important — highly recommended!Good to have~Niche — rarely asked

Optional Practice (Extra Reps)

For once you've cleared the main set above and want more reps on this pattern. These don't count toward the roadmap's progress stats — solve them purely for your own benefit.