A WaitGroup counts goroutines¶
Level: 101 · anyone who has written wg.Add(1)
One line: A sync.WaitGroup is a counter that Wait watches for zero, so Add has to run before the go statement. Inside the goroutine, Add can run after Wait has already returned. Since Go 1.25, go vet reports the simplest form of that mistake, but not every form.
Main does not wait introduced wg.Go, which counts a goroutine and starts it in one call. This page is about the counter underneath, and the ways to get it wrong when you call Add and Done yourself.
Verified output of a_waitgroup_counts_goroutines_go.go — regenerated by tools/run_examples.py, never hand-typed.
Add inside the goroutine: Wait returned, 0 of 3 tasks finished
(3 finished after that, with nobody waiting)
Add before the go statement: Wait returned, 3 of 3 tasks finished
wg.Go: Wait returned, 3 of 3 tasks finished
Reading the output¶
The program runs the same three tasks three ways. Each task first waits at a gate, a channel that stands in for the scheduler: nothing obliges the scheduler to run a new goroutine before the next line of main.
Add inside the goroutine. Wait found the counter at zero and returned at once, with 0 of 3 tasks finished. All three goroutines existed, but none had reached its Add, so from the counter's point of view there was nothing to wait for. Once the gate opened, all three ran and finished, with nothing waiting for them. In a real program main would have moved on by then, or ended the program and the tasks with it. The Add documentation ↗ states the rule: an Add with a positive delta while the counter is zero must happen before Wait, which in practice means before the statement that creates the goroutine.
Add before the go statement. Now the counter is 3 before any goroutine exists. However late the goroutines start, Wait waits for three Done calls, and returned with all three tasks finished.
wg.Go. Go does the Add itself and then starts the goroutine, so there is no misplaced Add to write. It arrived in Go 1.25 ↗. Its documentation adds one rule of its own: the function you pass must not panic.
a_waitgroup_counts_goroutines_go.go
a_waitgroup_counts_goroutines_go.go in full — pasted here by tools/run_examples.py from the file CI runs.
// A sync.WaitGroup is a counter, and Wait returns as soon as it reads zero. So
// Add has to run before the goroutine starts: an Add inside the goroutine can
// come after Wait has already looked.
//
// Each task waits at a gate before doing anything. The gate stands in for the
// scheduler, which may leave a new goroutine unstarted for as long as it likes;
// here it is simply told to.
//
// go build a_waitgroup_counts_goroutines_go.go && ./a_waitgroup_counts_goroutines_go
package main
import (
"fmt"
"sync"
"sync/atomic"
)
const tasks = 3
func main() {
// Wrong: Add inside the goroutine.
{
var wg sync.WaitGroup
var finished atomic.Int64
gate := make(chan struct{})
exited := make(chan struct{})
for range tasks {
go func() {
defer func() { exited <- struct{}{} }()
<-gate
wg.Add(1) // too late: main may already be past Wait
defer wg.Done()
finished.Add(1)
}()
}
wg.Wait()
fmt.Printf("Add inside the goroutine: Wait returned, %d of %d tasks finished\n", finished.Load(), tasks)
close(gate)
for range tasks {
<-exited
}
fmt.Printf(" (%d finished after that, with nobody waiting)\n", finished.Load())
}
// Right: Add before the go statement. Same gate, opened from elsewhere.
{
var wg sync.WaitGroup
var finished atomic.Int64
gate := make(chan struct{})
for range tasks {
wg.Add(1)
go func() {
defer wg.Done()
<-gate
finished.Add(1)
}()
}
go close(gate)
wg.Wait()
fmt.Printf("Add before the go statement: Wait returned, %d of %d tasks finished\n", finished.Load(), tasks)
}
// Go 1.25: wg.Go does the Add itself, before it starts the goroutine.
{
var wg sync.WaitGroup
var finished atomic.Int64
gate := make(chan struct{})
for range tasks {
wg.Go(func() {
<-gate
finished.Add(1)
})
}
go close(gate)
wg.Wait()
fmt.Printf("wg.Go: Wait returned, %d of %d tasks finished\n", finished.Load(), tasks)
}
}
go vet catches one shape of the mistake¶
Go 1.25 added a waitgroup analyzer ↗ to go vet. Its documentation describes it as detecting simple misuses of WaitGroup. The script below builds and vets two loops that make the mistake. In the first, wg.Add(1) is the goroutine's first statement, the shape the analyzer's documentation shows. In the second, a Println comes before it:
Verified output of a_waitgroup_counts_goroutines_vet_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go mod init example
$ grep -n 'wg.Add' main.go
12: wg.Add(1)
22: wg.Add(1)
$ go build .
exit status 0
$ go vet .
# example
# [example]
./main.go:12:10: WaitGroup.Add called from inside new goroutine
exit status 1
The compiler accepts the program. go vet rejects it, but it reports only one of the two mistakes: line 12, where Add opens the goroutine. The Add on line 22 is just as wrong, and vet said nothing about it. So a clean vet run does not prove every Add is in the right place. go build does not run vet: the build step above succeeded. go test runs only a subset of vet's checks, which go help test lists, and waitgroup is not in it. So run go vet yourself, or in CI.
a_waitgroup_counts_goroutines_vet_sh.sh
a_waitgroup_counts_goroutines_vet_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# go vet's waitgroup analyzer, new in Go 1.25, on the mistake this lesson is
# about: wg.Add called inside the goroutine it is meant to count. Two loops make
# it: in the first, Add opens the goroutine; in the second, a Println comes first.
set -u
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
export GOTOOLCHAIN=local
cat >"$dir/main.go" <<'GO'
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for _, url := range []string{"/a", "/b", "/c"} {
go func() {
wg.Add(1)
defer wg.Done()
fmt.Println("fetched", url)
}()
}
wg.Wait()
for _, url := range []string{"/d", "/e", "/f"} {
go func() {
fmt.Println("fetching", url)
wg.Add(1)
defer wg.Done()
fmt.Println("fetched", url)
}()
}
wg.Wait()
}
GO
say() { printf '$ %s\n' "$*"; }
cd "$dir"
say 'go mod init example'
go mod init example >/dev/null 2>&1
say "grep -n 'wg.Add' main.go"
grep -n 'wg.Add' main.go
say 'go build .'
go build -o /dev/null . 2>&1
echo "exit status $?"
say 'go vet .'
go vet . 2>&1
echo "exit status $?"
One Done for every Add¶
The counter can also go wrong in the other two directions: more Done calls than Add, or fewer. This script builds one program for each and keeps the first line of stderr and the exit status:
Verified output of a_waitgroup_counts_goroutines_miscount_sh.sh — regenerated by tools/run_examples.py, never hand-typed.
$ go build extra_done.go && ./extra_done
panic: sync: negative WaitGroup counter
exit status 2
$ go build missing_done.go && ./missing_done
fatal error: all goroutines are asleep - deadlock!
exit status 2
One Done too many drives the counter below zero, and the Add documentation says a negative counter panics. Done is Add(-1), so the panic comes from the extra Done.
The script keeps both Done calls on main's own goroutine, with no Wait in progress, so exactly one goroutine can notice the mistake. The usual shape of this bug is different: the two Done calls happen in a goroutine while main is blocked in Wait. Then how the program ends becomes a race. demo/extra_done_tally.sh runs that version many times and counts each distinct ending. Here are two tallies, which ran at the same time and competed for the same CPUs:
go version go1.25.5 darwin/amd64
1900 exit status 2: panic: sync: negative WaitGroup counter
99 exit status 2: panic: sync: WaitGroup is reused before previous Wait has returned
1 exit status 0:
go version go1.25.14 linux/amd64
1999 exit status 2: panic: sync: negative WaitGroup counter
1 exit status 2: panic: sync: WaitGroup is reused before previous Wait has returned
Most runs reported the negative counter. Some ended with a different panic message about the same mistake. One Mac run exited with status 0 and printed nothing, so the extra Done went unreported. That is why this version is a demo and not an example: an answer key can't hold an ending that changes. To count on your own machine, run bash demo/extra_done_tally.sh from this folder for 200 runs, or bash demo/extra_done_tally.sh 2000. A rare ending may not appear in a short run.
One Done too few leaves the counter at 1 forever, so Wait never returns. Here main was the only goroutine left, and the runtime noticed that nothing could ever wake it. That is the same fatal error All goroutines are asleep shows for channels. A program with other goroutines still running, such as a server with open connections, gets no error at all: it just hangs at Wait. (Not machine-checked here.)
a_waitgroup_counts_goroutines_miscount_sh.sh
a_waitgroup_counts_goroutines_miscount_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.
#!/usr/bin/env bash
# The counter's other two rules, broken: one Done too many, and one too few.
# Both programs end with exit status 2. Only the first line of stderr is kept;
# the goroutine trace after it names file paths and goroutine numbers.
#
# The extra Done happens on main's own goroutine, with no Wait in progress, so
# exactly one goroutine can notice it. Move it into a goroutine while main waits
# and the ending varies from run to run: demo/extra_done_tally.sh counts them.
set -u
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
export GOTOOLCHAIN=local
cat >"$dir/extra_done.go" <<'GO'
package main
import "sync"
// fetch calls Done itself, and so does its caller: two Dones for one Add.
func fetch(wg *sync.WaitGroup) {
defer wg.Done()
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
fetch(&wg)
wg.Done()
wg.Wait()
}
GO
cat >"$dir/missing_done.go" <<'GO'
package main
import "sync"
func main() {
var wg sync.WaitGroup
wg.Add(2) // two tasks counted...
go func() {
defer wg.Done()
}() // ...one started
wg.Wait()
}
GO
say() { printf '$ %s\n' "$*"; }
cd "$dir"
for name in extra_done missing_done; do
say "go build $name.go && ./$name"
go build -trimpath -o "$name" "$name.go"
"./$name" 2>"$name.err"
status=$?
head -n 1 "$name.err"
echo "exit status $status"
done
What to do¶
- Use
wg.Goon Go 1.25 or later. TheAddandDonedocs both say callers should prefer it. - When you do write
Add, write it in the parent, beforego, and makedefer wg.Done()the goroutine's first line, so that every way out of it counts down exactly once. - Run
go vet, but don't count on it for this rule. Itswaitgroupcheck caught only one of the two misplacedAddcalls above. Itscopylockscheck reports aWaitGrouppassed by value. The documentation says aWaitGroupmust not be copied after first use, so pass a*sync.WaitGroup. - Don't reuse a
WaitGroupuntilWaithas returned. NewAddcalls for a second batch must come after every earlierWaithas returned.
In other languages¶
- The Concurrency library's Who waits when main returns? ↗ runs the question in six languages, and its Go section uses
wg.Go. The biggest difference is Rust'sthread::scope, which waits for every thread started inside it: there is no counter to get wrong, because the scope is the counter. Java and Python wait for their ordinary threads without being asked. - The Concurrency library's chapter 04 Waiting for each other (planned) covers semaphores and barriers in six languages. The
WaitGroupdocumentation calls it a counting semaphore.
Sources¶
sync.WaitGroup↗:Add,Done,GoandWait, and the rules for each.- Go 1.25 release notes ↗:
WaitGroup.Go, and the newwaitgroupvet analyzer. - The
waitgroupanalyzer ↗ andcmd/vet↗. - Katherine Cox-Buday, Concurrency in Go, chapter 3 "Go's Concurrency Building Blocks", the
WaitGroupsection. Jon Bodner, Learning Go, 2nd ed., chapter 12 "Concurrency in Go". James Cutajar, Learn Concurrent Programming with Go, chapter 6 "Synchronizing with waitgroups and barriers".