Go — go vet copylocks: Mutex Copied by Value
Understand the copylocks vet warning when structs containing sync.Mutex are copied, see why it corrupts locking, and pass pointers or embed the mutex explicitly.
Symptoms
- go vet reports: assignment copies lock value (struct containing sync.Mutex) or main passes lock by value
- Mutexes appear to be held by nobody yet Unlock panics, or mutual exclusion silently fails under concurrency
- The problem appears after adding a mutex field to a struct that is already passed by value or returned by value
Possible causes
- A struct with an embedded or named sync.Mutex field is assigned, passed by value, or returned by value
- A method with a value receiver on a struct containing a mutex copies the entire struct including the lock
- A map or slice of lock-structs is populated by copying values in
- A range loop variable copies the lock-struct, and methods called on it lock the copy
Diagnosis steps
Run vet and read the line
go vet ./... 2>&1 | head -10Interpretation
The vet message names the copy site — assignment, function argument, or value receiver — which is the exact fix location.
Locate all value receivers
grep -Rn "func (\w* [A-Z]\w*) " src/ | head -20Interpretation
Value receivers on structs that contain (or later gain) a mutex are latent copylocks violations; pointer receivers never copy.
Verify with the race detector
go test -race ./counter/ -count=20Interpretation
Locking a copied mutex provides zero mutual exclusion, so -race often demonstrates the data race the copy introduced.
Root cause
A sync.Mutex must not be copied after first use — its internal state encodes whether it is locked and who holds it. Copying the containing struct copies the lock state into a fresh mutex, so two goroutines can each lock their own copy and enter the critical section simultaneously, and Unlock can fire on a mutex held by a different copy.
Fix
Use pointer receivers on structs containing mutexes, pass and store pointers to those structs, and run go vet in CI so the class is caught before merge. When embedding, remember the mutex travels with the struct value in every copy.
Risk note
Changing receivers to pointers alters interface satisfaction and nil-usage semantics. Review callers for nil receivers as part of the change.
Verify the fix
After converting to pointer semantics, run vet clean, the race-enabled suite repeated, and a concurrency stress test asserting mutual exclusion actually holds.
Symptom walkthrough
The tooling tells you before production does:
$ go vet ./...
./src/counter.go:31:9: assignment copies lock value
to c2: counter.Counter contains sync.Mutex
The runtime symptoms are stranger than the warning: mutual exclusion silently stops working (two goroutines enter the “protected” section), or Unlock panics on a mutex nobody appears to hold. Both follow from one fact — the mutex state was duplicated:
c2 := c1 // copies the mutex mid-use: two locks, zero exclusion
c2.mu.Lock() // locks the COPY, not the original
c1.mu.Lock() // also succeeds: different mutex entirely
The classic trap is adding a mutex to a struct that was already passed by value everywhere. The compile still succeeds; vet is the guard that fires.
Fix in practice
Pointer receivers for any struct holding a lock:
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() { // pointer receiver: no copy, real exclusion
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
Pass and store pointers:
func aggregate(counters []*Counter) int // not []Counter
Range copies are sneaky — bind by pointer or index:
for _, c := range regs { // c is a COPY if regs is []Counter
c.Inc() // locks the copy
}
for i := range regs { // fix: operate on the element itself
regs[i].Inc()
}
And keep the guard armed in CI: go vet ./… on every pull request, because the class reappears the moment someone adds a lock field to a value-passed struct.