skip to content

Why does go vet's copylocks check flag a value receiver on a struct holding a sync.Mutex?

level: seniorimportance: should knowfreq 45%

answer

  1. a mutex is a struct with state
  2. value receiver means a copy per call
  3. the callee locks something private
  4. copied while held, and it stays held
  5. green tests, exclusion gone

basics

~20 s

Because the call copies the struct, and the copy carries its own mutex. Every caller then locks a lock nobody else can see, so mutual exclusion is silently gone. Nothing panics, nothing fails to compile, and the tests still pass.

solid answer

~50 s

A `sync.Mutex` is an ordinary struct holding lock state, so copying it copies that state. Given `type Counter struct { mu sync.Mutex; n int }` and a method `func (c Counter) Inc()`, every call gets a fresh copy of the whole struct: `c.mu.Lock()` locks a mutex no other goroutine can reach, exclusion is gone, and the increment is written to a copy that is thrown away. Copying a mutex while it is held is worse — the copy believes it is locked, so the next `Lock` on it blocks forever. That is why `copylocks` reports any copy of a value whose type contains a `sync.Locker`: value receivers, passing by value, plain assignment, ranging over a slice of such structs, and copying a `sync.WaitGroup`. The fix is a pointer receiver throughout. It matters because this defect ships green: only real concurrency exposes it, and only if `-race` observes the racing path.

code

go · 18 lines
go
type Counter struct {
	mu sync.Mutex
	n  int
}

// Every call copies Counter, so this locks a private mutex
// and increments a field that is discarded on return.
func (c Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *Counter) IncFixed() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

go deeper

for a junior

Know that a value receiver gives the method a copy of the struct, and that a sync.Mutex field is copied along with it. Recognise 'passes lock by value' as go vet warning about exactly that.

for a middle

Explain what the copy costs: the callee locks a mutex no other caller can see, the mutation lands on a discarded copy, and a mutex copied while held stays held forever. The fix is a pointer receiver throughout.

for a senior

Show the diagnostic path. This defect ships with green tests, so treat a copylocks line in go vet ./... as a real bug report, and be able to say precisely what -race would and would not have told you about it.

for a principal

Decide how a repository with many occasional contributors catches this class of bug with tools rather than with reviewer attention, and be honest about the limits: a static check sees copies in source, not exclusion that was designed away.

## The mechanism `sync.Mutex` is not a handle or a reference. It is a small struct with fields holding the lock's state, and Go copies structs by value — on assignment, on passing an argument, on returning, and on calling a method with a value receiver. Copy a struct that contains a mutex and you now have two mutexes, each with its own independent state. Consider: ```go type Counter struct { mu sync.Mutex n int } func (c Counter) Inc() { // value receiver c.mu.Lock() defer c.mu.Unlock() c.n++ } ``` Two separate things are broken here, and a good answer names both: 1. **Exclusion is gone.** Each call to `Inc` receives its own copy of `Counter`, so `c.mu` is a private mutex belonging to that call. Two goroutines calling `Inc` concurrently both acquire their own copies without contention. The lock is decorative. 2. **The mutation is lost.** `c.n++` increments a field of the copy, which is discarded when the method returns. The original counter never changes. And there is a third failure mode when the copy is made at the wrong moment: if a mutex is copied **while it is held**, the copy's state says "locked" and nobody will ever unlock it, so a later `Lock` on the copy blocks forever. That one shows up as a goroutine wedged in `sync.(*Mutex).Lock` in a stack dump, with no obvious owner. ## What copylocks looks for The `copylocks` analyzer, part of the suite shipped with `go vet`, reports any operation that copies a value whose type contains a `sync.Locker` — that is, something with pointer-receiver `Lock`/`Unlock` methods, which covers `sync.Mutex`, `sync.RWMutex`, and by extension `sync.WaitGroup`, `sync.Cond` and structs that embed any of them. The shapes it catches: - a method with a value receiver whose type contains a lock (`Inc passes lock by value: Counter contains sync.Mutex`); - passing such a struct to a function by value, or returning it by value; - assigning one such value to another, including `x := *ptr`; - `for _, c := range counters` — the loop variable is a copy of each element; - appending such a value to a slice, or storing it in a map, both of which copy. The fix in each case is to use `*Counter`: a pointer receiver, pointer parameters, a `[]*Counter`. A pointer copy is just an address, and everyone shares the one lock. ## Why the tests are green This is the part that makes it a senior question. Copying a mutex is legal Go. It compiles, it runs, and a table-driven unit test that calls `Inc` a hundred times sequentially passes with a correct-looking answer only if the test reads the counter through the same broken path — and if it reads it correctly, the failure looks like an off-by-everything bug rather than a locking bug. Nothing in the language or the runtime objects to a copied lock. `go test` will not object either, because `copylocks` is **not** in the curated vet subset `go test` runs before your tests; you only see it from a full `go vet ./...`. The race detector is a partial safety net at best. `go test -race` reports races that actually happen during the run: if the test exercises the concurrent path and the interleaving occurs, you get a report; if the concurrency only exists in production, you get silence. `-race` is a dynamic check, so a clean run proves nothing about paths that were not executed. `copylocks` is static, so it fires whether or not the code is ever run concurrently — which is precisely why reading `go vet ./...` output line by line finds things a green test suite does not. ## Its neighbours in the suite Two more shipped checks belong to the same family of "real bugs a test run passes over": - **`lostcancel`** — the `cancel` function returned by `context.WithCancel`, `WithTimeout` or `WithDeadline` is not called on some path. The derived context and its timer stay alive until the parent finishes, which on a long-lived parent is a leak that grows with traffic. The idiom `ctx, cancel := context.WithTimeout(...); defer cancel()` exists to satisfy exactly this. - **`unusedresult`** — the result of a pure call such as `fmt.Sprintf` or `errors.New` is discarded. `errors.New("boom")` on a line by itself does nothing at all; somebody meant to return it. ## The practical posture In a repository with dozens of drive-by contributors and no shared house style beyond the tools, these checks are how a lost lock gets caught by a machine instead of by a reviewer who happened to be paying attention. Treat a `copylocks` line as a defect report, not as lint noise: unlike a style rule, it is describing code whose concurrency guarantee is already gone.

  • What other silent bugs does the shipped vet suite catch that a green test run would not?
    Two in the same family. `lostcancel` reports a `context.CancelFunc` from `context.WithCancel` or `WithTimeout` that is not called on some path, leaving the derived context and its timer alive until the parent finishes. `unusedresult` reports a discarded result from a pure call such as `fmt.Sprintf` or `errors.New` — a statement that does literally nothing. Neither makes a test fail.
  • Would the race detector have caught the copied mutex?
    Only sometimes, and only indirectly. `go test -race` instruments the binary and reports races that actually occur during the run, so it fires if your test drives the concurrent path with the right interleaving. It says nothing about paths the test never exercised, and it costs significant memory and CPU. `copylocks` is static: it reports the copy whether or not the code ever runs concurrently.
  • Are there places where a pointer receiver is not enough to keep the lock shared?
    Yes — wherever the value itself is stored somewhere that copies it. Putting a `Counter` (rather than a `*Counter`) into a map or appending it to a slice copies the struct and its mutex, and a map element is not addressable, so you cannot even call a pointer-receiver method on it in place. Store `*Counter` and the pointer is what gets copied around.

It is like handing every visitor their own copy of the room booking sheet: each one sees the room as free, marks it as theirs, and they all walk in together.

saying these in an interview costs you the question

  • Thinks copying an unlocked mutex is harmless
  • Believes a copied mutex is a compile error
  • Claims the race detector always catches it
  • Says a value receiver is fine if the method only reads
  • Thinks copylocks only inspects function arguments
  • Assumes go test would have reported it