skip to content

Why does passing a sync.WaitGroup by value into a helper function break the join?

level: middleimportance: should knowfreq 55%

answer

  1. Go copies arguments, always
  2. the callee decrements something else
  3. every method here has a pointer receiver
  4. vet has a name for this: copylocks

basics

~10 s

Go passes arguments by copy, so a helper taking sync.WaitGroup gets its own counter. Done decrements the copy, the caller's counter never falls, and the caller's Wait blocks forever. Pass *sync.WaitGroup instead.

solid answer

~50 s

Go has value semantics: `func runUnit(wg sync.WaitGroup)` copies the whole struct, counter and internal semaphore state included. The helper's `wg.Done()` then decrements a copy that nobody waits on, while the caller's counter stays where `Add` left it, so `wg.Wait()` never returns and the migration run hangs until the test timeout kills it. The fix is to share one group: take `*sync.WaitGroup` and call `go runUnit(&wg, m)`, or close over the local variable in a func literal. It is easy to miss because all WaitGroup methods have pointer receivers and Go auto-takes the address of an addressable variable, so `wg.Done()` compiles identically on a value and on a copy. `go vet`'s copylocks analyzer catches it — WaitGroup embeds a noCopy marker precisely so that copying it is reported. The same rule applies to any struct that embeds one: don't copy the struct either.

code

go · 9 lines
go
// BUG: runUnit takes its own copy of the WaitGroup.
func runUnit(wg sync.WaitGroup, m Migration) {
	defer wg.Done() // decrements the copy
	apply(m)
}

wg.Add(1)
go runUnit(wg, m)
wg.Wait() // blocks forever: the caller's counter is still 1

go deeper

for a junior

Remember one rule: share a WaitGroup by pointer, never by value. If a function signature says sync.WaitGroup rather than *sync.WaitGroup, that is the bug.

for a middle

Explain the mechanism: the argument is copied, the copy's counter is decremented, the original stays put, and Wait blocks forever with no panic. Mention that the pointer-receiver methods still compile on the copy because parameters are addressable.

for a senior

Treat it as a review-time catch rather than a debugging exercise: know that go vet's copylocks check reports it, that go test runs vet by default, and that any struct embedding the group needs pointer receivers.

for a principal

Decide the convention up front — helpers should not receive the group at all, they should return and let the launching code own the join — so the copy question stops arising across the codebase instead of being caught case by case.

## The bug ```go // BUG: runUnit receives its own copy of the group. func runUnit(wg sync.WaitGroup, m Migration) { defer wg.Done() apply(m) } wg.Add(1) go runUnit(wg, m) wg.Wait() // never returns ``` Nothing here is a race, a deadlock between locks, or a scheduling subtlety. It is Go's plainest rule applied to a type that cannot survive it: **arguments are copied**. `runUnit` gets a bit-for-bit copy of the `WaitGroup` at the moment of the call, with the counter reading 1. Its deferred `Done` takes *that copy* to zero. The caller's group still reads 1, so `Wait` blocks forever. Note that no panic occurs. The copy's counter goes 1 → 0, which is perfectly legal, so nothing complains at runtime. The only symptom is a hang. ## Why it is so easy to write Every `WaitGroup` method is declared on the pointer: `func (wg *WaitGroup) Add(delta int)`, `Done()`, `Wait()`, and `Go(f func())` since Go 1.25. There is no value-receiver method at all. So how does `wg.Done()` compile when `wg` is a `sync.WaitGroup` value? Because Go automatically takes the address of an **addressable** value for a pointer-receiver call: `wg.Done()` is shorthand for `(&wg).Done()`. A parameter is a local variable and therefore addressable, so the copy inside `runUnit` compiles exactly like the real thing. The type system gives you no warning; the code looks identical to the correct version everywhere except the signature. ## What `go vet` does about it The standard library plants a tripwire for this. `sync.WaitGroup` embeds an unexported `noCopy` field whose only purpose is to have `Lock`/`Unlock` methods, which makes it look like a lock to the `copylocks` analyzer in `go vet`. Any copy — passing by value, returning by value, assigning, ranging over a slice of structs that contain one — is reported as copying a lock value. `go vet` runs as part of `go test` by default, so this bug is normally caught the first time the package's tests are run, provided nobody has silenced the check. That is worth knowing as a review habit: if you see `sync.WaitGroup` (or `sync.Mutex`) in a parameter list or a struct being returned by value, vet already disagrees with the code. ## The fixes, in order of preference **Close over it.** Inside the same function, a func literal captures the variable itself, not a copy: ```go wg.Add(1) go func() { defer wg.Done() apply(m) }() ``` **Take a pointer.** When the work genuinely belongs in a named helper: ```go func runUnit(wg *sync.WaitGroup, m Migration) { defer wg.Done() apply(m) } wg.Add(1) go runUnit(&wg, m) ``` **Let the callee not know about the group at all.** Often the cleanest shape: the helper returns, and only the launching code touches the group. With Go 1.25, `wg.Go(func() { runUnit(m) })` keeps the group entirely in the launcher and the helper stays a plain function — which is also far easier to unit-test. ## The same rule for structs A runner type that embeds the group inherits the constraint: ```go type Runner struct { wg sync.WaitGroup } ``` Methods on `Runner` must have pointer receivers — `func (r *Runner) Apply()` — because a value receiver copies the whole struct, group included, on every call. Likewise `Runner` must not be copied, returned by value, or stored in a slice that gets reallocated by value semantics after goroutines have been counted. This is the general Go rule that a type containing a synchronisation primitive is not copyable after first use, and it is why such types are almost always passed as pointers. ## Distinguishing it from the neighbouring failure A copied group hangs `Wait` because a `Done` landed somewhere nobody is watching. That looks identical from the outside to a `Done` that never ran at all. The tell is in the code, not the symptom: check the signature of every function that calls `Done`, and check whether any struct holding the group is passed or received by value. If both are pointers, the missing `Done` is a control-flow problem instead.

  • If the methods are all on *WaitGroup, why does wg.Done() even compile inside a function that took the group by value?
    Because Go automatically takes the address of an addressable value for a pointer-receiver call: `wg.Done()` means `(&wg).Done()`. A parameter is a local variable, so it is addressable and the call compiles. The address taken is the address of the *copy*, which is exactly why the code looks correct and behaves wrongly.
  • Which go vet check reports this, and how does the standard library make it detectable?
    The `copylocks` analyzer. `sync.WaitGroup` embeds an unexported `noCopy` field that has `Lock` and `Unlock` methods, so vet treats the whole struct as a lock and reports any copy — a value parameter, a value return, an assignment, or a value-receiver method on a struct that contains one. `go vet` runs by default as part of `go test`.
  • What does this imply for a struct type that holds a WaitGroup as a field?
    Its methods need pointer receivers, and the struct must not be copied once it is in use, because a copy duplicates the counter. That is the standard Go rule for any type containing a synchronisation primitive: after first use it is non-copyable, so it is passed around as a pointer and usually created by a constructor returning `*T`.

saying these in an interview costs you the question

  • Says Go passes structs by reference so the copy is harmless
  • Blames the scheduler or a race rather than value semantics
  • Expects a panic or a compile error rather than a silent hang
  • Thinks pointer receivers alone prevent the group from being copied
  • Gives a value receiver to a method on a struct containing a WaitGroup