By default, Go copies values when you pass them around. But sometimes, that can be undesirable. For example, if you accidentally copy a mutex and multiple goroutines work on separate instances of the lock, they won’t be properly synchronized. In those cases, passing a pointer to the lock avoids the copy and works as expected.
Take this example, passing a sync.WaitGroup
by value will break thing in subtle ways:
func f(wg sync.WaitGroup) {
// ... do something with the waitgroup
}
func main() {
var wg sync.WaitGroup
f(wg) // oops! wg is getting copied here!
}
sync.WaitGroup
lets you wait for multiple goroutines to finish some work. Under the hood,
it’s a struct with methods like Add
, Done
, and Wait
to sync concurrently running
goroutines.
That snippet compiles fine but leads to buggy behavior because we’re copying the lock
instead of referencing it in the f
function.
Luckily, go vet
catches it. If you run vet on that code, you’ll get a warning like this:
f passes lock by value: sync.WaitGroup contains sync.noCopy
call of f copies lock value: sync.WaitGroup contains sync.noCopy
This means we’re passing wg
by value when we should be passing a reference. Here’s the
fix:
func f(wg *sync.WaitGroup) { // pass by reference
// ... do something with the waitgroup
}
func main() {
var wg sync.WaitGroup
f(&wg) // pass a pointer to wg
}
Since this kind of incorrect copy doesn’t throw a compile-time error, if you skip go vet
,
you might never catch it. Another reason to always vet your code.
I was curious how the Go toolchain enforces this. The clue is in the vet warning:
call of f copies lock value: sync.WaitGroup contains sync.noCopy
So the sync.noCopy
struct inside sync.WaitGroup
is doing something to alert go vet
when you pass it by value.
Looking at the implementation of sync.WaitGroup
1, you’ll see:
type WaitGroup struct {
noCopy noCopy
state atomic.Uint64
sema uint32
}
Then I traced the definition of noCopy
in sync/cond.go
2:
// noCopy may be added to structs which must not be copied
// after the first use.
// Note that it must not be embedded, due to the Lock and Unlock methods.
type noCopy struct{}
// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
Just having those no-op Lock
and Unlock
methods on noCopy
is enough. This implements
the Locker
3 interface. Then if you put that struct inside another one, go vet
will
flag cases where you try to copy the outer struct.
Also, note the comment: don’t embed noCopy
. Include it explicitly. Embedding would
expose Lock
and Unlock
on the outer struct, which you probably don’t want.
Interestingly, if you define noCopy
as anything other than a struct and implement the
Locker
interface, vet ignores that. I tested this on Go 1.24:
type noCopy int // this is valid but vet doesn't get triggered
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
This doesn’t trigger vet. It only works when noCopy
is a struct.
The copylocks
checker is part of go vet
. It looks for value copies of any type that
contains fields with Lock
and Unlock
methods. Doesn’t matter what those methods do, just
having them is enough.
When vet runs, it walks the AST and applies the copylocks
check on assignments, function
calls, return values, struct literals, range loops, channel sends, basically anywhere values
can get copied. If it sees you copying a struct with something like noCopy
, it yells. You
can see the implementation of the check here4.
You’ll see this in other parts of the sync package too. sync.Mutex
uses the same trick:
type Mutex struct {
_ noCopy
mu isync.Mutex
}
Same with sync.Once
:
type Once struct {
done uint32
m Mutex
noCopy noCopy
}
Here’s a complete example of abusing copylocks
to prevent copying our own struct.
type Svc struct{ _ noCopy }
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
// Use this
func main() {
var svc Svc
s := svc // go vet will complain about this copy op
}
Running go vet
on this gives:
assignment copies lock value to s: play.Svc contains play.noCopy
call of fmt.Println copies lock value: play.Svc contains play.noCopy
Recent posts
- Go 1.24's "tool" directive
- Capturing console output in Go tests
- Deferred teardown closure in Go testing
- Three flavors of sorting Go slices
- Nil comparisons and Go interface
- Stacked middleware vs embedded delegation in Go
- Why does Go's io.Reader have such a weird signature?
- Go slice gotchas
- The domain knowledge dilemma
- Hierarchical rate limiting with Redis sorted sets