sync.Mutex Value Receiver Trap: The Lock That Guards a Copy2026-06-03
This counter is supposed to be safe for concurrent use. A thousand goroutines each call Increment, and we expect to see 1000 at the end. We get 0. The mutex is taken, the addition runs, no data race is reported by -race. What went wrong?
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
c := SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); c.Increment() }()
}
wg.Wait()
fmt.Println("Count:", c.Value()) // prints 0
}
Look at the receivers: func (c SafeCounter) Increment(). That's a value receiver, not a pointer receiver. Every call to c.Increment() copies the entire SafeCounter struct — including the embedded sync.Mutex — onto the goroutine's stack. Each goroutine then locks its own private copy of the mutex, increments its own private copy of count, and discards both when the method returns. The original c.count in main is never touched.
Worse: copying a sync.Mutex is itself a bug. A mutex is a pair of state words; duplicating one mid-lifecycle can produce a copy that thinks it's locked when nothing holds it, or vice versa. Even when the copy starts in the zero state (as here), you've severed every caller from every other caller — the mutex protects nothing meaningful because no two goroutines share one.
The race detector won't catch it, because there is no race: every increment happens on a distinct memory location. go vet does catch it, emitting Increment passes lock by value: SafeCounter contains sync.Mutex. Most CI pipelines run vet by default — but the warning is easy to miss if you're skimming output, and the program compiles and runs without complaint.
Use pointer receivers for any method on a type containing a mutex (or any type whose identity matters):
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
Now c.Increment() passes the address of c, every goroutine locks the same mutex, and the increment is visible across the program. The output becomes 1000, as intended.
The deeper lesson is that Go's choice between value and pointer receivers is not stylistic — it's a correctness boundary. The rule of thumb most teams adopt:
sync.Mutex, sync.RWMutex, sync.Once, sync.WaitGroup, or atomic.*, use pointer receivers everywhere, including methods that don't touch the lock. Mixing receiver kinds invites accidental copies through interface satisfaction.Run go vet ./... in CI and fail the build on its output. It catches this, the copylocks analyzer is on by default, and the diagnostic is unambiguous. Trusting "go build passed" is not enough.
sync.Mutex silently copies the lock per call, so every goroutine ends up serializing against its own private mutex — always use pointer receivers for types with synchronization primitives, and make go vet failures break your build.
