Recommended resources
The freely available resources this lesson will direct you to.
Hello. Last lesson, you used a mutex to protect a counter while several goroutines accessed it. The test also waited for those goroutines to finish, using one completion signal from each. This lesson replaces that manual counting with sync.WaitGroup and, more importantly, establishes when a task must be registered so that Wait cannot return too early. Allow about 35–40 minutes, including the code and test runs.
Completion is a separate question from safe access
The mutex answers, “May these goroutines access the counter at the same time?” A WaitGroup answers, “Have all the tasks I registered finished?” You often need both. Waiting until concurrent writes are over does not make those writes safe while they happen; locking the writes does not tell the caller when they are over.
A WaitGroup maintains a task counter. Its zero value is ready to use. Add(1) registers one task, Done() marks one task complete, and Wait() blocks until the counter reaches zero. If the counter is already zero when Wait() runs, it returns immediately—even if you intended to start goroutines later.
In Steve Hook’s WaitGroups - Part 1 (Concurrency in Go #2), watch the unreliable fixed-sleep example, then the basic Add/Done/Wait implementation. The last segment makes the zero-counter behavior concrete.
WaitGroups - Part 1 (Concurrency in Go #2)
Watch WaitGroups - Part 1 (Concurrency in Go #2) by Steve Hook to see why a pause of fixed duration cannot establish that work is complete, and how a WaitGroup supplies that condition.
Start with the sleep example: notice that changing the sleep duration changes which tasks finish, without giving the caller a reliable completion signal. Then watch the basic pattern, focusing on registration before launching tasks and defer wg.Done() inside them. Finally, zero counter shows why an unregistered task does not hold up Wait.
Wait establishes a completion point, not a task order. Goroutines can finish in any order; the caller simply proceeds after all registered tasks have reported completion.
Replace the completion channels in the counter test
Keep the Counter type, its mutex-protected methods, and the imports from the previous lesson. Replace only TestConcurrentCount with this version:
func TestConcurrentCount(t *testing.T) {
var counter Counter
var wg sync.WaitGroup
start := make(chan struct{})
// Register two writers and one reader before starting any of them.
wg.Add(3)
for worker := 0; worker < 2; worker++ {
go func() {
defer wg.Done()
<-start
for i := 0; i < 10000; i++ {
counter.Inc()
}
}()
}
go func() {
defer wg.Done()
<-start
for i := 0; i < 10000; i++ {
_ = counter.Value()
}
}()
close(start)
wg.Wait()
const want = 20000
if got := counter.Value(); got != want {
t.Fatalf("counter = %d; want %d", got, want)
}
}
There are exactly three registered tasks and exactly three goroutines that call Done. Placing defer wg.Done() at the beginning of each goroutine also covers an ordinary early return from its work. Calling wg.Add(3) before launching them ensures the count cannot still be zero when the test reaches wg.Wait().
The start channel still serves its old purpose: it releases the goroutines together so their work can overlap. The WaitGroup serves a different purpose: it tells the test when all three have finished. Only then does the test check the final value. The counter’s mutex remains necessary throughout the overlapping work.
Run the focused test:
go test -race -run '^TestConcurrentCount$' -count=10
Expect passing runs without a data-race warning. This checks the paths exercised by the test; it does not make the WaitGroup a substitute for the mutex.
The ordering rule that prevents an early return
The official sync package documentation states the important boundary precisely: a positive Add made while the count is zero must happen before Wait. Registering each task immediately before its go statement is an easy way to enforce that rule when the number of tasks is determined in a loop. Registering the entire known count up front, as in the test, is another.
Read the Go sync package’s WaitGroup documentation for the exact Add and Wait contract, including the rule for reusing a WaitGroup.
Under type WaitGroup, read the overview. Under *func (WaitGroup) Add, read the Add rules, paying particular attention to positive additions when the count is zero and to reuse after a prior wait. The nearby *func (WaitGroup) Done entry defines the matching decrement. Then find *func (WaitGroup) Wait and read its definition.
Consider the tempting alternative of putting Add inside a newly launched goroutine:
var wg sync.WaitGroup
go func() {
wg.Add(1) // Too late: the caller may already be waiting.
defer wg.Done()
doWork()
}()
wg.Wait()
After the go statement, the caller and worker run independently. The caller could reach wg.Wait() while the count is still zero, return, and move on before doWork() even starts. This is a lifecycle error: all accesses to the WaitGroup might be synchronized internally, yet the caller has failed to register the work it means to await.
The section titled “Call the sync.WaitGroup.Add Method at Wrong Places” in Go 101 shows this mistake in a loop and then moves each Add to the launching goroutine.
Common Concurrent Programming Mistakes
Read Go 101’s WaitGroup example to compare a program that starts tasks before registering them with its corrected version.
In Call the sync.WaitGroup.Add Method at Wrong Places, begin with the explanation of the initially zero counter. Read the failing example, including the code between those paragraphs: identify which goroutine calls Add and why Wait can miss that call. Continue through the correction and the short code snippet immediately below it. Stop before Use Channels as Futures/Promises Improperly.
For a variable number of tasks, the robust pattern is therefore to call wg.Add(1) in the launching goroutine’s loop, immediately before each go statement. Once the loop has registered and started its tasks, that goroutine calls wg.Wait().
Keep registration and completion balanced
A WaitGroup’s count must describe the work that will actually report completion. If you register three tasks but only two call Done, Wait keeps blocking. If a task calls Done too many times, the count becomes negative and the program panics. defer wg.Done() reduces the risk of forgetting it on a normal return, but it cannot finish a goroutine that remains blocked indefinitely.
Two other boundaries matter as code grows:
- Do not copy a WaitGroup after first use. If a helper needs it, pass
*sync.WaitGroup, not async.WaitGroupvalue. Otherwise, the helper and caller may be changing different counters. - Do not start a new, independent batch before the previous
Waithas returned. A WaitGroup can be reused, but only after that earlier wait is complete. The simplest design gives one launching goroutine clear responsibility for registering a batch before waiting for it.
Recent Go versions also provide WaitGroup.Go, which combines launching and registering a task. The explicit Add/Done form used here remains useful for understanding the ordering rule and for code targeting older versions.
Finally, Wait() does not cancel work or impose a deadline. If a registered goroutine never finishes, waiting longer does not solve the underlying problem.
Takeaways
Use a WaitGroup to await registered completion, and a mutex to protect shared mutable state while work is in progress. Register tasks before launching them—or, for a known count, register them all up front—then have each task call Done exactly once. Never rely on an Add inside a newly launched goroutine to catch a Wait that may have already run.
Next, you’ll use context.Context to propagate deadlines and cancellation, addressing work that should stop rather than merely be waited on.