skip to content

Why is copying a Go struct that contains a sync.Mutex a bug?

level: middleimportance: should knowfreq 46%

answer

  1. a lock is an address, not a value
  2. the struct copy duplicates the lock's state
  3. two locks exclude nobody
  4. value receivers and range loops copy silently
  5. a static analyzer in the standard toolchain names it

basics

~20 s

The mutex is copied as plain data, lock state included, so the copy is a second independent lock. Goroutines holding different copies enter the same critical section together. go vet's copylocks check reports it at build time.

solid answer

~50 s

A `sync.Mutex` is a small struct of ordinary fields, so copying the value that contains it copies the lock's state rather than sharing the lock. From then on there are two locks: a goroutine locking the original and one locking the copy exclude nobody, and any state the copy guards drifts from the original. Copying a mutex that is currently held is worse still, because the copy starts life in a locked state that nothing will ever unlock. The copy happens in ordinary-looking code: a value receiver, passing the struct as an argument, ranging over a `[]T`, or storing the value in a map. `go vet` runs the copylocks analyzer and reports `passes lock by value: T contains sync.Mutex`. The fix is to use `*T` everywhere - pointer receivers, pointer fields, pointer elements - so the lock is never duplicated.

code

go · 19 lines
go
type Limiter struct {
	mu    sync.Mutex
	quota int
}

func (l Limiter) Take() bool { // value receiver: l is a copy
	l.mu.Lock()
	defer l.mu.Unlock()
	l.quota--
	return l.quota >= 0
}

// Fix: pointer receiver, so every call locks the same mutex.
func (l *Limiter) TakeFixed() bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	l.quota--
	return l.quota >= 0
}

go deeper

for a junior

Recall that a struct holding a sync.Mutex must be used through a pointer, and that methods on such a type take a pointer receiver.

for a middle

Explain that the mutex is ordinary memory whose state gets duplicated, so two copies are two locks, and name the everyday operations - value receivers, arguments, range loops - that copy without looking like it.

for a senior

Describe how you would catch this on a real codebase: go vet in CI as a hard failure, what a copied-while-held lock looks like in a goroutine profile, and how you shape the exported API so callers cannot copy the type.

for a principal

Set the standard: which analyzers block a merge, and whether shared mutable state guarded by a lock is the right design for the component at all compared with confining the state to one owner.

## What a mutex actually is `sync.Mutex` is a plain struct of a couple of integer fields tracking whether the lock is held and who is waiting. It has no identity beyond its address: `Lock` and `Unlock` are declared with pointer receivers and operate on the memory at that address. Mutual exclusion therefore comes from two goroutines calling `Lock` on **the same memory**, not from them holding "the same mutex value". That is why copying is fatal. Copy a struct that embeds a mutex and you have two separate pieces of memory with the same bit pattern. Two goroutines that lock different copies both succeed immediately, and both run the critical section. ```go type Limiter struct { mu sync.Mutex quota int } func (l Limiter) Take() bool { // value receiver: l is a fresh copy l.mu.Lock() defer l.mu.Unlock() l.quota-- return l.quota >= 0 } ``` Every call locks a brand-new copy of the mutex, so the locking is theatre - and the decrement is lost as well, because `l.quota` belongs to the copy that is discarded when `Take` returns. The bug has two independent halves and only the second one shows up in a single-goroutine test. ## The second failure: copying a held lock If the mutex is locked at the moment of the copy, the copy is created in the locked state. Nothing holds it, so nothing will ever unlock it. The first goroutine to call `Lock` on that copy blocks forever, and the goroutine profile shows it parked in `sync.(*Mutex).Lock` with no owner anywhere. This is a real hazard when a `Clone` method or a getter returns a struct that contains a lock while the lock is held. ## Where the copy sneaks in The copy is rarely written as `b := a`. The usual sources are: - **a value receiver** on a method of a lock-bearing type - **passing the struct by value** to a function, or returning it - **ranging over `[]T`**, where the loop variable is a copy of each element - **storing the value in a map or a slice**, and reading it back out (map values are not addressable, so you cannot even lock one in place) - **putting the value into an interface**, which copies it - **an embedded lock-bearing struct** copied along with its outer struct, one level up The same rule covers everything else in `sync` and `sync/atomic`: `sync.WaitGroup`, `sync.Once`, `sync.RWMutex`, `sync.Cond`, `sync.Pool`, `atomic.Int64`, `atomic.Pointer[T]`. `strings.Builder` goes further and panics at run time with an explicit "copied by value" message if you write to a copy of a non-zero Builder. ## Finding it `go vet` runs the copylocks analyzer over your packages and reports lines such as: ``` ./limiter.go:12:6: Take passes lock by value: main.Limiter contains sync.Mutex ./limiter.go:31:7: assignment copies lock value to b: main.Limiter contains sync.Mutex ``` It is a static check, it costs nothing, and `go test` runs a subset of vet automatically, so a copied lock in a package with tests is normally caught the first time anyone runs the tests. Wire `go vet ./...` into CI and treat a copylocks report as a build failure rather than a warning - the analyzer has essentially no false positives, because copying a lock is never what you meant. The race detector (`go build -race`) will often also flag the resulting data race, but only if the racing paths actually execute during the run. Vet catches the bug without needing to reproduce it, which is why it is the first line of defence. ## Designing so it cannot happen - Use the type through a pointer: pointer receivers, `*T` in fields and containers, constructors returning `*T`. A `*T` copy copies the address, so every copy shares one lock. - Do not export a lock-bearing struct as something callers naturally copy. If callers only ever see `*Client`, the mistake is hard to make. - If a type must not be copied, embedding a zero-size `noCopy` field with `Lock`/`Unlock` methods is the standard-library trick that makes vet complain even when there is no real mutex present. - Keep the lock unexported and next to the fields it guards, with a comment naming them, so a reader can see at a glance what copying would break. An interviewer asking this is checking one specific thing: do you understand that a lock is an address, not a value?

  • Which other standard library types raise the same problem when copied?
    Everything in `sync` - `RWMutex`, `WaitGroup`, `Once`, `Cond`, `Pool` - and the typed atomics such as `atomic.Int64` and `atomic.Pointer[T]`. `strings.Builder` is stricter still: writing to a copy of a non-zero Builder panics at run time rather than failing silently.
  • What happens if the mutex is locked at the moment the struct is copied?
    The copy is born locked, with no owner and nothing that will ever unlock it. The first goroutine to lock that copy blocks forever; the goroutine profile shows it parked in the mutex's Lock with no corresponding holder. It looks like a deadlock with a missing half.
  • How do you make the compiler or tooling stop this on a type of your own?
    Use the type only through `*T`, and embed a zero-size `noCopy` field carrying `Lock` and `Unlock` methods. The methods are never called; their presence makes `go vet`'s copylocks analyzer report any copy of the type, even one containing no real lock.

A mutex is the one key hanging by the door, not the idea of a key. Photocopying the door does not give you a shared key; it gives two rooms that both look locked and neither is.

saying these in an interview costs you the question

  • Thinks a copied mutex still refers to the same lock
  • Believes a value receiver is fine as long as the method locks
  • Says only concurrent access is broken, missing the lost write to the copy
  • Expects the race detector to find it without exercising the path
  • Copies a lock-bearing struct out of a slice or map while iterating