What happens when a method holding a sync.Mutex calls another method that locks the same mutex?
answer
- the mutex records no owner
- it waits for a lock only it can free
- nothing counts how many times you locked
- split the method in two
- a Locked suffix on the inner helper
basics
~20 sThe goroutine blocks forever. A sync.Mutex is not reentrant and records no owner, so a second Lock from the goroutine that already holds it waits for itself. The fix is an unexported helper that assumes the lock is held.
solid answer
~50 s`sync.Mutex` is not recursive: it keeps no record of which goroutine holds it, only that it is held. So when a method that already took `s.mu` calls a second method whose first line is `s.mu.Lock()`, that goroutine parks waiting for a lock only it can release - a self-deadlock, and it takes the guarded data down with it because no other goroutine can get in either. The Go idiom that prevents this is a naming split: exported methods take the lock and do nothing else interesting, while the real work lives in unexported `xxxLocked` helpers documented as *caller must hold mu*. Locking methods then only ever call non-locking ones. The rule that follows is: never call out of your own package - a callback, an interface method, a user-supplied function - while holding the mutex, because you cannot know whether it will re-enter you.
code
go · 13 linesfunc (s *Scoreboard) RecordWin() {
s.mu.Lock()
defer s.mu.Unlock()
s.wins++
}
func (s *Scoreboard) RecordSweep(n int) {
s.mu.Lock()
defer s.mu.Unlock()
for i := 0; i < n; i++ {
s.RecordWin() // blocks forever: this goroutine already holds s.mu
}
}go deeper
Remember the rule as a slogan: a Go mutex cannot be locked twice by the same goroutine. If a locking method calls another locking method on the same object, the code hangs.
Explain the mechanism - no owner field, no recursion counter, so the second Lock parks on a queue only the blocked goroutine could drain - and show the exported-locks, unexported-assumes-the-lock split.
Demonstrate the review habits that keep it out: never call exported methods or caller-supplied callbacks from inside a critical section, and give every lock-assuming helper a name that states the precondition.
Own the convention as a package-wide contract: decide where the single lock acquisition point sits for a shared component, and treat a second acquisition anywhere below it as an architectural defect rather than a local bug.
## The mechanism A `sync.Mutex` is a tiny struct holding a state word and a semaphore. The state word says *locked* or *not locked*; it does not say *locked by goroutine G*. There is no owner field, no recursion counter, and no bookkeeping that could tell one goroutine's second `Lock` apart from a different goroutine's first one. So this happens: ```go func (s *Scoreboard) RecordWin() { s.mu.Lock() defer s.mu.Unlock() s.wins++ } func (s *Scoreboard) RecordSweep(n int) { s.mu.Lock() defer s.mu.Unlock() for i := 0; i < n; i++ { s.RecordWin() // parks here, forever } } ``` `RecordSweep` takes the lock. `RecordWin` asks for the same lock. The mutex is held, so the runtime parks the goroutine on the mutex's wait queue. The only code that could release it is the deferred `Unlock` in `RecordSweep` - which cannot run, because `RecordSweep` is blocked inside `RecordWin`. The goroutine waits on itself. The damage is not limited to that one goroutine. The mutex stays held, so every other goroutine that touches the scoreboard queues behind it. A single self-deadlock in a shared object usually looks, from the outside, like the whole feature has stopped: requests pile up, the queue depth climbs, and the process is otherwise healthy. ## Why Go does not give you a recursive mutex This is a design choice, not an oversight. A lock exists to protect an *invariant*: while it is held, the guarded fields may be temporarily inconsistent. A recursive lock lets a function re-enter a critical section that some outer frame has already half-finished, and see exactly that inconsistent state while believing it holds the lock legitimately. Go's answer is to make the impossible case loud - it deadlocks immediately and reproducibly - rather than quietly correct-looking. It is also why the mutex has no owner in the first place: without ownership there is no way to make it recursive, and without recursion there is no need to track ownership. That same absence is what makes it legal for one goroutine to lock a mutex and another to unlock it. ## The idiom that prevents it Go codebases converge on one convention. Split every operation into two functions: ```go // recordWinLocked assumes s.mu is already held. func (s *Scoreboard) recordWinLocked() { s.wins++ } func (s *Scoreboard) RecordWin() { s.mu.Lock() defer s.mu.Unlock() s.recordWinLocked() } ``` The rules that fall out of it: 1. **A method either locks or assumes the lock, never both.** The exported one locks; the unexported one assumes. 2. **Name the assumption.** The `Locked` suffix (or a prefix like `unsafeXxx`, or simply a comment) is the only documentation the compiler will not give you. Write it on every such helper. 3. **Locking methods call only non-locking ones.** Once a critical section calls another exported method, the invariant is broken and the deadlock is a matter of time. 4. **Never call foreign code while holding the mutex.** A callback field, an interface value, a `fmt.Stringer` on a caller's type, a `log` handler somebody swapped out - any of these can call back into your package and re-enter. Gather what you need under the lock, unlock, then call out. ## Recognising it in the wild The symptom is a goroutine that is parked and never comes back, with a stack that shows two frames of *your own type* between `sync.(*Mutex).Lock` and the entry point - the outer method and the inner one. That shape, one type appearing twice in the same stack around a lock, is the tell. Because the mutex records no owner, nothing in the runtime can say *who* holds it; you infer the holder from the stacks of the other goroutines. That is why the naming convention matters so much: the code has to be reviewable, since the runtime will not explain it for you. ## Bad fixes to avoid - **Unlocking before the nested call and relocking after.** This does stop the hang, and it silently breaks atomicity: the guarded fields are exposed, mid-update, to every other goroutine in the gap. - **A hand-rolled recursion counter.** To make it correct you would need to know which goroutine holds the lock, and Go deliberately gives you no goroutine identity to key that on. - **Swapping in `TryLock` on the inner call.** It stops the block, but the inner method then either skips its work or proceeds without the guarantee it was written to assume. The honest fix is always structural: one lock acquisition per operation, at the outermost layer, with everything below it written to run under a lock it did not take.
- How do you name and document a helper that assumes the sync.Mutex is already held?Make it unexported and give it a suffix that says so - `recordWinLocked`, or a doc comment reading *caller must hold s.mu*. The convention is the whole safety net: the compiler cannot check the precondition, so the name has to. Reviewers then apply a simple rule - a function whose name ends in `Locked` must never call `Lock`, and a function that calls `Lock` must call only `Locked` helpers.
- Why is it risky to call a caller-supplied callback while holding a sync.Mutex?You do not control what that code does. If it calls back into an exported method of the same object, it re-enters a lock the current goroutine already holds and deadlocks; if it blocks on I/O or another lock, it holds your critical section open for that entire time. The safe shape is to read what you need under the lock, release it, and then invoke the callback.
- Is unlocking before the nested call and locking again afterwards an acceptable fix?It removes the hang and introduces a correctness bug. The whole point of the outer critical section is that the guarded fields stay consistent for its duration; opening a gap lets another goroutine observe or modify them mid-update. If the operation genuinely can be split into two independent atomic steps, restructure it explicitly and say so - do not disguise it as a deadlock fix.
It is like locking yourself in a room and then knocking on the same door to be let in: the only person who can open it is you, and you are busy knocking.
saying these in an interview costs you the question
- Says Go counts recursive locks from the same goroutine
- Expects the second Lock to return false or an error
- Claims the runtime detects and reports every such hang
- Adds a locked bool or a counter field to fake reentrancy
- Unlocks mid-method and relocks, breaking the invariant
- Calls a user-supplied callback from inside the critical section