skip to content

Why does `defer mu.Unlock()` at the top of a Go method hold the mutex longer than the data needs?

level: juniorimportance: must knowfreq 62%

answer

  1. defer is scoped to what, exactly
  2. the lock outlives the map write
  3. everything after it rides inside
  4. an inner func gives you block scope

basics

~20 s

A 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 s

In 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 lines
go
func (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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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