Why does `defer mu.Unlock()` at the top of a Go method hold the mutex longer than the data needs?
answer
- defer is scoped to what, exactly
- the lock outlives the map write
- everything after it rides inside
- an inner func gives you block scope
basics
~20 sA deferred unlock runs when the whole function returns, not when a block ends, so encoding, file or network work written after it still holds the mutex. Narrow it with an inner function or an early unlock.
solid answer
~50 sIn Go a deferred call is scheduled per function, not per block: `defer s.mu.Unlock()` on line two releases the lock only when the function returns, so every statement after it — a `json.Marshal`, an `os.WriteFile`, an HTTP call — runs inside the critical section even though it never touches the guarded field. The pattern is still the right default because it survives an early `return` and a panic; the fix is not to abandon `defer` but to shrink what the function does under the lock. Two idiomatic narrowings: take the lock, mutate or copy out the data, `Unlock()` explicitly, then do the slow work; or wrap just the guarded lines in an immediately-invoked `func() { ... }()` that carries its own `defer`. Either way the rule is: hold the lock for map and field access, never for I/O.
code
go · 10 linesfunc (s *Store) Save(id string, sess Session) error {
s.mu.Lock()
defer s.mu.Unlock()
s.entries[id] = sess
b, err := json.Marshal(sess) // still holding the mutex
if err != nil {
return err
}
return os.WriteFile(id+".json", b, 0o600) // and still holding it here
}go deeper
Be ready to say when a deferred call runs: at function return, not at the end of the block it was written in. Then show the two-line fix, an inner function or an early unlock, and say why only the map write needed the lock.
Explain the tradeoff between the two narrowings: an explicit unlock is simplest but loses panic and early-return safety, while an immediately-invoked function keeps both and gives you real block scope.
An interviewer expects you to spot the widened critical section in review, on the request path especially, and to name what makes it dangerous: I/O, channel sends and calls into unowned code inside the guarded span.
Frame it as a code-review standard rather than a one-off fix: locked regions stay short, named and free of I/O, so the team never needs to litigate lock scope in a diff at three in the morning.
## What `defer` actually promises `defer` in Go registers a call to be run when the **surrounding function** returns — after the return values are set, in last-in-first-out order, and on every exit path including a panic that is unwinding the stack. It has no block scope. Writing ```go if cond { s.mu.Lock() defer s.mu.Unlock() } ``` does **not** release the lock at the closing brace of the `if`; it releases it when the enclosing function returns. This is the single most important difference between Go's lock idiom and a scoped guard in a language with destructors or a `synchronized` block, and it is where the critical section quietly grows. ## How the critical section widens A method usually starts life small: ```go func (s *Store) Save(id string, sess Session) error { s.mu.Lock() defer s.mu.Unlock() s.entries[id] = sess return nil } ``` That is correct and cheap: the lock is held for one map assignment. Then a later change adds a durability step — marshal the session and write it to a file — and a still later change adds a metrics push. Nobody moves the `defer`, because it is at the top where the idiom says to put it. Now the lock is held across an allocation-heavy encode and a syscall that can block for milliseconds. Only the map assignment needed protection; the rest of the body is riding along inside it. Every other goroutine calling any method that takes `s.mu` waits out the file write. This matters most for a structure on the request path — a session store consulted by every authenticated request — because the time under the lock is multiplied by the arrival rate. A microsecond map write serialises fine at high request rates; a two-millisecond file write does not, and adding CPU cores does not help, because the added cores just queue behind the same lock. ## The three narrowings **1. Unlock explicitly before the slow part.** ```go s.mu.Lock() s.entries[id] = sess s.mu.Unlock() b, err := json.Marshal(sess) ``` Simple and obvious, but you give up the panic-safety and early-return safety `defer` buys: if code between `Lock` and `Unlock` panics or returns, the mutex stays locked forever and the next caller deadlocks. Use it only when the guarded region is a few straight-line statements with no `return` inside. **2. Wrap the guarded lines in an inner function.** ```go func() { s.mu.Lock() defer s.mu.Unlock() s.entries[id] = sess }() ``` The deferred unlock now belongs to the literal, so it runs at the literal's return — the closing `}()`. You keep the panic safety and get block scope. This is the standard Go answer to "I want a scoped lock". **3. Extract a small locked method.** Best of all when the guarded work has a name: `s.put(id, sess)` takes the lock, does the map write, and returns; the caller does the encoding and the I/O outside it. Locked methods that are three lines long are easy to review, and the compiler often inlines them anyway. ## Copy out, don't compute in A related habit: when the guarded data is only read, take the lock, copy what you need into a local, unlock, and compute on the local. For a map you may need to copy the entries you care about rather than the whole map — but even copying a slice of ten values is far cheaper than holding a lock across formatting or serialisation. ## What not to conclude - **Do not read this as "defer is slow".** Deferred calls are cheap; the cost here is the *scope*, not the mechanism. - **Do not drop `defer` from methods that are already tight.** If the whole body is the critical section, `defer` at the top is exactly right and is the more robust code. - **Do not "help" by unlocking twice.** Unlocking a `sync.Mutex` that is not locked panics with "sync: unlock of unlocked mutex", and an early `Unlock()` plus the deferred one does precisely that. If you unlock explicitly, remove the `defer`. - **Do not hold a lock while sending on or receiving from a channel** unless you have proved the peer cannot block, since that turns a lock hold into an unbounded wait. ## Reviewing for it When reading a diff, find the `Lock()` and ask what the last line before the function's return is. Everything between them is the critical section. If that span contains a network call, a file operation, a `time.Sleep`, a channel operation, a log write, or a call into code you do not control, the section is too wide — and the shape of the fix is almost always "move it out", not "use a different lock type".
- If you unlock explicitly instead of deferring, what do you lose?Panic and early-return safety. A `defer`ed unlock runs on every exit path, including while a panic unwinds; an explicit `s.mu.Unlock()` at the bottom is skipped if anything above it returns early or panics, and the mutex stays locked forever, so the next caller blocks permanently. Use the explicit form only for a few straight-line statements with no `return` between `Lock` and `Unlock`.
- Does moving the deferred unlock into an `if` block release the lock at the closing brace?No. `defer` is per function, not per block, so a `defer mu.Unlock()` inside an `if` still runs at function return. The only way to get block scope is to make the block a function: an immediately-invoked `func() { ... }()` or a small named method whose body is the critical section.
- What happens if you both `Unlock()` early and leave the `defer` in place?The deferred call unlocks an already-unlocked mutex, which panics at runtime with "sync: unlock of unlocked mutex". `sync.Mutex` has no ownership tracking, so it cannot forgive the double unlock. When you switch to an explicit unlock, delete the `defer` in the same edit.
saying these in an interview costs you the question
- Thinking a deferred unlock releases at the end of the enclosing block
- Believing defer itself is what makes the code slow
- Leaving an explicit Unlock and the deferred one in the same function
- Holding the lock across file, network or channel operations because defer is at the top
- Removing defer everywhere instead of shrinking the guarded work