Skip to content
beginner Phase 8 · Goroutines and Shared-State Concurrency

Coordinating with sync.WaitGroup

Wait for a collection of goroutines to finish without sleeping or polling.

35m
0 problems
Topic Progress 0%

Sleep Is Not Synchronization

The handler from the previous topic fired three lookups and slept 400 ms, hoping they would finish. On CI it returns empty strings; on a good day it wastes 100 ms per request; and nobody can explain what the number 400 has to do with the work. The replacement is a counter: sync.WaitGroup tracks how many goroutines are outstanding, and Wait blocks until the counter reaches zero.

func (s *Server) overview(r *http.Request) (Profile, Prefs, Quota, time.Duration) {
	start := time.Now()
	var (
		wg              sync.WaitGroup
		profile Profile
		prefs   Prefs
		quota   Quota
	)

	wg.Add(3) // register the three tasks BEFORE starting any of them
	go func() { defer wg.Done(); profile = s.fetchProfile(r.Context()) }()
	go func() { defer wg.Done(); prefs = s.fetchPrefs(r.Context()) }()
	go func() { defer wg.Done(); quota = s.fetchQuota(r.Context()) }()

	wg.Wait() // blocks until all three have called Done
	return profile, prefs, quota, time.Since(start)
}

The three calls to the API are simple, and each has one rule behind it:

  • Add(n) before the goroutines start. The counter must already reflect the work before any completion can be reported.
  • Done() in a defer inside the goroutine. Defer runs on every return path, so an early error return cannot strand the counter. Done() is just Add(-1); the zero value is usable immediately.
  • Wait() in the goroutine that owns the count. It parks the caller until the counter hits zero. It does not cancel anything, collect results, or report errors — it only answers "are all of them done yet?"

Why the results are safe here is worth stating precisely: each goroutine writes a different variable, so no two write the same memory. Contrast that with the tempting var results []Result followed by results = append(results, r) inside goroutines — three appends to one slice header race with each other and can corrupt the slice entirely. The fix is to size the slice first and let goroutines write disjoint slots:

results := make([]Result, len(urls)) // allocate once
wg.Add(len(urls))
for i, u := range urls {
	go func(i int, u string) {
		defer wg.Done()
		results[i] = fetch(u) // each goroutine owns index i
	}(i, u)
}
wg.Wait()

Writing distinct elements of a slice concurrently is safe — the shared part is the backing array's structure, and nobody mutates the header. With Wait returning after the last Done, every write is visible to the reader, a guarantee the memory model attaches to WaitGroup itself: Done happens-before the corresponding Wait returns.

Two Bugs: Wait Returns Too Early, or Never

WaitGroups fail in exactly two directions, and both come from a miscounted counter.

Wait returns while goroutines still run. The cause is almost always Add inside the goroutine:

var wg sync.WaitGroup
for _, u := range urls {
	go func(u string) {
		wg.Add(1) // TOO LATE — main may reach Wait before this runs
		defer wg.Done()
		fetch(u)
	}(u)
}
wg.Wait() // may return while the counter is still 0

Timing decides the damage: if Wait executes before any goroutine starts, the counter is zero and it returns immediately — the caller then reads half-written results. The race is real but quiet, which is why it passes review. Rule: Add belongs in the spawning goroutine, before the go statement, never in the spawned one.

Wait never returns. The counter never reaches zero because some Done was skipped — a goroutine returned on an error path without defer, or Add(2) was paired with one Done, or Wait was called on a copy of a WaitGroup that nobody shares:

wg.Add(2)
go func() { defer wg.Done(); workA() }()
go func() {
	if err := workB(); err != nil {
		return // BUG: no Done — defer wg.Done() was missing
	}
	wg.Done()
}()
wg.Wait() // hangs forever if workB errors

The hang shows up as a stuck test with a goroutine dump pointing at Wait. Two habits eliminate the class: defer wg.Done() as the first line of every tracked goroutine, and never copying a WaitGroup — pass the pointer (go f(&wg) captures by closure anyway) since a copy is a different counter.

Reuse has one documented rule. After Wait returns you may start a second batch with fresh Add calls, but new Adds must not overlap a pending Wait on a zero counter:

If a WaitGroup is reused to wait for several independent sets of events, new Add calls must happen after all previous Wait calls have returned.

A WaitGroup answers one question — how many are done? It cannot tell you what happened. When results, errors, or an unknown number of tasks enter the picture, you have outgrown the counter, and the surrounding design (bounded fan-out, error collection) is covered in Worker Pools and Parallel Work with errgroup.

Known Count or Open Stream: Choosing the Coordination Shape

WaitGroup fits when you can write the number of tasks on one line before starting: Add(len(urls)). Several adjacent shapes deserve a different tool, and recognizing them keeps the counter from becoming a catch-all.

The count is discovered, not known. A crawler that finds links as it walks has no n to register. Work the count up as tasks spawn — each worker registers the children it discovers before it returns — and the WaitGroup still converges, because Add while the counter is nonzero is fine. When discovery and completion interleave arbitrarily, a channel-based worker pattern usually reads better than a growing counter.

You need the errors. A WaitGroup collects nothing: each goroutine's error evaporates unless you stash it somewhere, and stashing from multiple goroutines is its own race. The two established answers are a results channel sized to the task count, drained after Wait:

errs := make(chan error, len(urls))
wg.Add(len(urls))
for i, u := range urls {
	go func(u string) {
		defer wg.Done()
		if err := fetch(u); err != nil {
			errs <- err // buffered — never blocks
		}
	}(u)
}
wg.Wait()
close(errs)
for err := range errs {
	log.Printf("fetch failed: %v", err)
}

…or errgroup, which is WaitGroup plus error collection plus cancellation in one value.

You want progress, not just completion. Watching "12 of 40 done" wants a channel of results you can consume as they arrive; a counter only answers at the end.

The tasks must not all run at once. WaitGroup has no notion of a limit. Spawning 50,000 goroutines that each open a connection exhausts memory and file descriptors long before Wait matters — bound the fan-out with a pool of workers where the WaitGroup counts workers, not tasks:

workers := 8
wg.Add(workers)
for i := 0; i < workers; i++ {
	go func() {
		defer wg.Done()
		for u := range jobs { fetch(u) }
	}()
}
// producer closes(jobs); wg.Wait() then means all workers drained

The decision line: known count of independent tasks, only completion needed → WaitGroup. Unknown count, errors to gather, results to stream, or a limit to enforce → channels (or errgroup) — with the WaitGroup demoted to counting workers. Either way the pattern holds: register before spawning, defer Done inside, wait in the owner. What you are waiting for is unsynchronized shared state — the moment any of it is written by two goroutines, you need The Race Detector to see the damage.

Quiz

1. Wait returns immediately and the test reads empty results, though goroutines clearly run. What is the most likely cause?

Question 1 options

2. A fan-out helper hangs forever at wg.Wait() when one fetch fails. What is the defect?

Question 2 options

3. N goroutines each run `results = append(results, r)` on a shared slice. Why is this wrong, and what is the correct shape?

Question 3 options

4. You reuse one WaitGroup for batch A then batch B. When may the second Add call happen?

Question 4 options

Flashcards

Question

What is the correct ordering of Add, Done, and Wait in a fan-out?

Answer

Add(n) in the spawning goroutine before each go statement, Done() in a defer as the first statement inside each goroutine, and Wait() in the owner of the count. Add inside the spawned goroutine races with Wait; Done without defer leaks on early returns.

Question

What does sync.WaitGroup guarantee about memory visibility?

Answer

A Done call happens-before the corresponding Wait returns. That edge is why data written by the goroutines (each to its own slot or protected state) is safe to read after Wait — without it, reading results would be a race.

Question

Why can a WaitGroup never tell you whether the work succeeded?

Answer

It is only a counter of outstanding tasks — no results, no errors, no ordering of completions. To learn outcomes you collect them elsewhere: a buffered error channel drained after Wait, or errgroup, which combines counting, error capture, and cancellation.

Question

When should the WaitGroup count workers instead of tasks?

Answer

When task volume is unbounded — thousands of jobs each spawning a goroutine exhaust memory and file descriptors. Start a fixed number of worker goroutines (the WaitGroup's count), have them pull from a jobs channel, and close jobs when production ends; Wait then means everything drained.

Question

Why does `time.Sleep` fail where WaitGroup.Wait succeeds?

Answer

Sleep waits an amount of time with no relationship to the work — too short on a loaded machine (truncated results), too long on a fast one (wasted latency), and nondeterministic in between. Wait is an event tied to completion: it returns exactly when the counter hits zero, on any machine.

Revision Notes

Key Takeaways

  • 1. Add belongs before the go statement, Done belongs in a defer inside it — that ordering is the entire contract
  • 2. WaitGroup establishes a happens-before edge: Done before Wait returns, which is why post-Wait reads are safe
  • 3. Pre-size result slices so goroutines own disjoint slots; concurrent append races on the slice header
  • 4. A WaitGroup counts, it does not collect — errors and results need a channel or errgroup alongside
  • 5. Sleep-based waiting is a guess; Wait is an event tied to actual completion

Interview Tips

  • • "Walk me through a correct fan-out" — Add(len) before spawning, defer Done first, write to index i, Wait, then read; mention the happens-before edge Wait provides
  • • "What's the difference between WaitGroup and errgroup?" — errgroup adds error collection and context cancellation; if asked, say you'd use plain WaitGroup only when completion alone matters
  • • "Your test hangs at Wait — how do you debug?" — dump goroutines (SIGQUIT), find the goroutine parked in Wait, then trace which task never called Done; usually an error path without defer
  • • "How would you limit concurrency?" — count workers not tasks: fixed N goroutines pulling from a jobs channel, close(jobs) at the end, Wait for the workers

Cheat Sheet

Coordinating with sync.WaitGroup

The contract: Add(n) before spawning → defer Done() first line inside each goroutine → Wait() in the owner. Done() is Add(-1); the zero value is ready to use.

Standard fan-out template:

results := make([]Result, len(jobs))
var wg sync.WaitGroup
wg.Add(len(jobs))
for i, j := range jobs {
	go func(i int, j Job) {
		defer wg.Done()
		results[i] = run(j)
	}(i, j)
}
wg.Wait()

Failure diagnosis:

Symptom Cause
Wait returns instantly, results empty Add inside the goroutine (races with Wait)
Wait hangs forever missing Done on an error path, Add(2)/one Done, or waiting on a copy
corrupted shared results appending from goroutines instead of writing pre-sized slots
second batch misbehaves Add called while a previous Wait is still pending

Choose the tool: known count + completion only → WaitGroup. Errors to gather → errgroup or buffered channel. Unknown/streaming count, progress needed, or a concurrency limit → channels; count workers, not tasks.

Continue Learning

Before You Start

  • No prerequisites listed.

Next Topics

Related Reading