skip to content

A teammate's code calls a `suspend` function while holding a `Mutex`, and that function in turn calls `withLock` on the same mutex. It hangs forever. Explain why, and how you'd fix or avoid it.

level: seniorimportance: should knowfreq 45%

answer

  1. Mutex is NON-reentrant (unlike ReentrantLock)
  2. re-lock by holder → suspends forever = self-deadlock
  3. fix: split public-locks vs internal-assumes-locked
  4. don't hold a mutex across arbitrary suspend/I/O
  5. owner detects misuse, doesn't enable reentrancy

basics

~20 s

Kotlin's Mutex is not reentrant. A coroutine that already holds the lock can't take it again — it suspends waiting for itself and never resumes. Fix by not re-locking: split the locked and unlocked parts.

solid answer

~50 s

Unlike `ReentrantLock`, `kotlinx.coroutines.sync.Mutex` is **non-reentrant**. The mutex tracks that *something* holds it, not *which coroutine*; a second `lock()`/`withLock` from the holder simply joins the wait queue and suspends — and since the holder never reaches `unlock` (it's stuck waiting), it self-deadlocks permanently. Fixes: (1) restructure so the critical section doesn't call back into code that re-locks — keep locked regions small and call collaborators *outside* the lock; (2) split into a public `withLock { internalNoLock() }` plus an internal function that assumes the lock is held and never re-acquires; (3) use the `owner` parameter only to *detect* misuse, not to enable reentrancy (it doesn't). Holding a mutex across an arbitrary `suspend` call is itself a smell — it can extend the critical section over I/O and increase contention. Often a `Mutex` is the wrong tool: confinement (single-threaded dispatcher / actor) or atomics avoid the hazard entirely.

code

kotlin · 12 lines
kotlin
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

val m = Mutex()

// BAD: re-enters the same mutex -> hangs forever
suspend fun bad() = m.withLock { reenter() }
suspend fun reenter() = m.withLock { /* unreachable */ }

// GOOD: lock once; internal helper assumes lock is held
suspend fun good() = m.withLock { core() }
private fun core() { /* mutates state, never re-locks */ }

go deeper

for a junior

Recognizes the hang and that the lock isn't reentrant, even if not the full mechanism.

for a middle

Explains the self-deadlock precisely and applies the public/internal split fix.

for a senior

Identifies holding a mutex across suspend as the deeper smell and proposes confinement/atomics/StateFlow alternatives.

for a principal

Designs the component to avoid shared mutable state and locks, reasoning about lock ordering, contention, and testability of invariants.

## Why it hangs `kotlinx.coroutines.sync.Mutex` is **not reentrant**. When locked, it records that it is held; it does not grant a second entry to the same coroutine. So: ```kotlin val m = Mutex() suspend fun outer() = m.withLock { inner() // still holding m } suspend fun inner() = m.withLock { // tries to take m again /* never reached */ } ``` `inner`'s `lock()` finds the mutex held, **suspends** the coroutine, and joins the wait queue. But the only thing that could release the mutex is `outer`'s `finally`, which can't run because the same coroutine is now parked inside `inner`. The coroutine is waiting on itself → **permanent self-deadlock** (no exception, just hangs). This differs from JVM `ReentrantLock`/`synchronized`, which track the *owning thread* and let it re-enter. ## Fixes ### 1. Don't call back into locking code while holding the lock Do the minimum under the lock; call collaborators outside it: ```kotlin suspend fun outer() { val snapshot = m.withLock { readState() } process(snapshot) // no lock held; safe to re-lock if needed } ``` ### 2. Public-locks / internal-assumes-locked split ```kotlin suspend fun publicOp() = m.withLock { coreOp() } // takes lock private fun coreOp() { /* assumes lock held; never re-locks */ } ``` ### 3. Don't try to fake reentrancy with `owner` The `owner` argument helps *detect* unbalanced/foreign unlocks; it does **not** make the mutex reentrant. ## Deeper smell: holding a mutex across `suspend` Holding a `Mutex` across an arbitrary `suspend` call (especially I/O) lengthens the critical section, raises contention, and risks exactly this re-entry trap. Prefer: - **Confinement**: run all mutations on a single-threaded context (`newSingleThreadContext` / a `Dispatchers.Default.limitedParallelism(1)` / an actor), so no lock is needed. - **Atomics** (`AtomicInteger`, `kotlinx.atomicfu`) for simple counters. - **Immutability + `StateFlow.update { }`** for shared state. ## Detecting it A hang with one coroutine parked in `lock()` and the same coroutine being the holder is the signature. `holdsLock(owner)` can assert invariants in tests.

  • Does passing the same `owner` to both `withLock` calls make it reentrant?
    No. `owner` is for detecting unbalanced/foreign unlocks (it can throw on a mismatched unlock); it does not allow recursive acquisition.
  • Is holding a `Mutex` across a network call a good idea?
    Generally no. It widens the critical section over slow I/O, increasing contention and deadlock risk. Capture a snapshot under the lock, do I/O outside it, then re-lock to apply results.

It's like locking yourself in a room, then waiting at the same locked door for whoever's inside to leave — but that's also you.

saying these in an interview costs you the question

  • Claiming `Mutex` is reentrant like `ReentrantLock`
  • Trying to 'fix' reentrancy by passing an `owner`
  • Holding the mutex across long suspending I/O without concern
  • Adding a second mutex to 'work around' it, risking lock-ordering deadlocks
  • Not recognizing confinement/atomics as alternatives

context