Skip to content
academia.sh

Lesson 12 / 14

Wait Groups

A counter waits for five workers to finish: Add raises the counter to five before they start, each worker guarantees its own Done with defer, and Wait returns once the counter reaches zero — the termination class is waited-for-arrived again. If the counter never reaches zero, Wait never returns, and this leak is measured with a boolean; when Done is called one time too many, the counter drops below zero and the wait group panics, the panic is caught with recover, and only panic=true is written. Limiting measurement: which of the five workers finishes first is never measured, only that all of them finish — the results are collected and sorted before being printed.

Published Updated

In this lesson

  • Runs the Add, Done, and Wait triple's counter contract with five workers and shows that Wait's termination class is waited-for-arrived
  • Measures, by checking without blocking, that a counter that never reaches zero blocks Wait forever, and records the leak as a boolean
  • Measures with recover, without printing the panic text, that an extra Done call driving the counter below zero results in a panic
Contents

The previous lesson measured a single goroutine waiting on a single lock. But the side protecting shared state is itself often not a single goroutine but a group: several goroutines splitting a piece of work into shares and running it in parallel, and a main goroutine waiting for all of them to finish. A lock does not set up this kind of waiting — a lock only limits how many goroutines can touch a resource at the same time, it does not count how many goroutines have finished. The tool this lesson measures, the one that does this counting, is sync.WaitGroup, a wait group.

It is possible to imitate a wait group with a channel — each worker sends an empty value on a channel once it finishes, and main reads exactly as many values from that channel as there are workers. The previous topic’s Worker Pools and Fan-Out and Fan-In lessons genuinely did this: the result channel itself both carried the value and counted how many results had arrived. A wait group separates these two jobs: a separate counter for counting how many workers finished, and, for carrying a value (as in this lesson), a separate channel or no channel at all. This separation needs less machinery when the workers produce no value at all — when knowing that a job finished is enough on its own.

The Counter Contract

A wait group is not a slice, a channel, or a list — it is a type that holds a single integer counter inside it. Its contract rests on three calls: Add(n) raises the counter by n, Done() lowers it by one, and Wait() blocks until the counter reaches zero. Using these three calls in the right order is a rule: Add must always be called before the goroutine it concerns starts, Done must run on every exit path of every goroutine — which is why it is written with defer, just like Unlock in the previous lesson — and Wait never returns before the counter reaches zero.

Calling Add before the goroutine starts is not a matter of style. If Add were instead called inside the goroutine itself, on its first line, main could reach group.Wait() before that goroutine ever ran its own Add at all — in that case, since the counter is still zero, Wait could return immediately, without ever waiting for a job that had not even started. This is a class of bug that is hard to notice by reading the source, because which order things run in depends on goroutine scheduling, and this lesson does not deliberately run such a setup — because the result could vary from run to run, it would fall under this course’s determinism constraint. The rule can instead be read straight from the source: the group.Add(1) line in the example sits before the go keyword, on every step of the loop.

// main.go — counter contract, a leak in a counter that never zeroes, a counter dropping below zero
package main

import (
	"fmt"
	"runtime"
	"sort"
	"sync"
)

func main() {
	fmt.Println("-- counter contract: Add once, Done on every path, Wait returns at zero --")
	const workerCount = 5
	var group sync.WaitGroup
	completed := make(chan string, workerCount)
	weights := []int{3, 1, 4, 1, 5}
	for i := 0; i < workerCount; i++ {
		group.Add(1)
		go func(index, weight int) {
			defer group.Done()
			sum := 0
			for j := 0; j < weight*1000; j++ {
				sum += j
			}
			completed <- fmt.Sprintf("job-%d (weight=%d, sum=%d)", index, weight, sum)
		}(i, weights[i])
	}
	group.Wait()
	fmt.Println("Wait returned: stopped, waited-for-arrived")

	close(completed)
	var reports []string
	for r := range completed {
		reports = append(reports, r)
	}
	sort.Strings(reports)
	for _, r := range reports {
		fmt.Println(r)
	}

	fmt.Println()
	fmt.Println("-- a counter that never reaches zero: Add is called, Done never is --")
	baseline := runtime.NumGoroutine()
	var leakingGroup sync.WaitGroup
	leakingGroup.Add(1)
	done := make(chan struct{})
	go func() {
		leakingGroup.Wait()
		close(done)
	}()
	select {
	case <-done:
		fmt.Println("Wait returned: this was not expected")
	default:
		fmt.Println("did Wait return: no, the counter never reached zero")
	}
	fmt.Println("is there a goroutine left that never stopped:", runtime.NumGoroutine() > baseline)

	fmt.Println()
	fmt.Println("-- the counter dropping below zero: panic --")
	panicOccurred := runAndRecover(func() {
		var overGroup sync.WaitGroup
		overGroup.Add(1)
		overGroup.Done()
		overGroup.Done()
	})
	fmt.Println("panic:", panicOccurred)
}

// runAndRecover runs the given function and reports whether it panicked; the stack trace is not printed.
func runAndRecover(fn func()) (panicOccurred bool) {
	defer func() {
		if recover() != nil {
			panicOccurred = true
		}
	}()
	fn()
	return false
}
-- counter contract: Add once, Done on every path, Wait returns at zero --
Wait returned: stopped, waited-for-arrived
job-0 (weight=3, sum=4498500)
job-1 (weight=1, sum=499500)
job-2 (weight=4, sum=7998000)
job-3 (weight=1, sum=499500)
job-4 (weight=5, sum=12497500)

-- a counter that never reaches zero: Add is called, Done never is --
did Wait return: no, the counter never reached zero
is there a goroutine left that never stopped: true

-- the counter dropping below zero: panic --
panic: true

SS4. Five workers are started with five weights (3, 1, 4, 1, 5) — one repeats twice — and each computes its own sum and writes it to a channel. group.Wait() returns only after all five have called their own Done — this is one more instance of the termination class treated since the course’s first lesson: main itself waits like a goroutine, and stops once what it is waiting for — the counter reaching zero — arrives. The wait group here offers a different contract than the lock: while a lock protects a single resource, a wait group counts how many jobs have finished. The shared definition’s third claim supports this directly: the five “waited-for-arrived” lines were each set up with a separate mechanism (an unbuffered receive, a closed channel, a lock, a wait group, a select), and the dependency did not change even though the mechanism did. The previous lesson used the lock, this lesson uses the wait group — both are members of the same family, the family of “stopping depending on another goroutine’s step.”

The weights themselves were also chosen to differ on purpose: the weight 1 repeats twice (job-1 and job-3), while the remaining three (3, 4, 5) all differ. This shows that the wait group does not distinguish between workers in any way — the counter only tracks how many Done calls happened, it does not know which goroutine did which job. job-1 and job-3 produce the same sum (499500) with the same weight, and there is no difference at all between these two workers as far as the wait group is concerned; the distinction is carried only by the job- prefixes in the output, not by the wait group itself.

SS5. The five workers finish at five different times — the one with a higher weight runs a longer loop — but this lesson never measures which one finishes first. The reports written to the channel are read from completed into a slice, then sorted and printed with sort.Strings; the order of arrival from the channel is never printed, because that order depends on goroutine scheduling and can vary from run to run. The only thing a wait group guarantees is the moment the counter reaches zero; which Done call arrives first is not a guarantee. This is the lesson’s limiting measurement: that all five workers finished is measured, which one finished first cannot be — had it been measured, this lesson’s output would also vary from run to run, and verification would report a mismatch.

The group variable is a local variable of main, and every goroutine accesses it through a closure, with no pointer at all — because Go’s closures capture the surrounding variables by their own address, group.Add, group.Done, and group.Wait always touch the same counter. A wait group is a struct, and the pass-by-value rule measured in the Go Fundamentals course applies here as well: if a copy of group were taken and passed to a function by value, that copy would have its own counter, independent of main‘s own counter — the contract would break silently. The inspector’s job (the one measured in the Go Command Family lesson) is exactly to catch patterns like this; this lesson does not deliberately set up and run such a copy, because, like the single line falling outside the lock’s scope in the previous lesson, it would be a class of bug that is hard to notice by reading the source.

The lock and the wait group split two separate responsibilities here. The previous lesson’s ProtectedCounter took on preventing more than one goroutine from writing to the same value at the same time; whoever protects that value, it never answers the question “did everyone finish?” The completed channel and group in this lesson do the opposite: they count which workers finished their job, but never get involved with any memory the workers share among themselves — the five workers here already write to no shared variable at all, each computing its own local sum and sending it on the channel. In a real program these two tools are seen together: several goroutines protect a shared value with a lock and each calls Done once its job is finished, and main waits for all of them with Wait, only reading the protected value once that happens. The lock decides what gets protected, the wait group decides when it is ready to be read; neither question stands in for the other.

A Counter That Never Reaches Zero: The Leak

SS6. The second setup leaves the contract half done on purpose: leakingGroup.Add(1) is called, but the matching Done is never called in any goroutine. Because the counter will never reach zero again, the goroutine calling leakingGroup.Wait() stays blocked forever — this is the fourth termination class itself: never stopped. To check this blocking without waiting for it, we wrap Wait inside a separate goroutine and a closing channel; the default branch of the select shows, instantly and with certainty, that no value will ever arrive from that channel — because the counter can mathematically never reach zero. This is not a guess about timing: which branch the select picks does not change on any run, because Wait returning depends on the counter’s value, not on timing, and that value never changes.

SS7. The runtime.NumGoroutine() call is compared against the baseline count and produces only a true/false, not a separate count of how many goroutines are left. This is another instance of the shared definition’s second claim: not stopping is not turned into any error or warning by the Go runtime, the rest of the program keeps running normally — only that one goroutine never finishes. This silence belongs to the same family as the channel leaks measured in the Goroutines lesson: neither the wait group nor a channel produces an error on its own when the other side fails to honor its contract.

A wait group has no cancellation mechanism of its own — there is no way to stop a Wait call from the outside; it waits until the counter reaches zero. The ctx.Done() pattern measured in the Timeouts and Deadlines lesson fills this gap, but the wait group itself offers no such exit; this is why, in a real program, a Wait call carrying the risk of an infinite wait is usually wrapped, together with a timeout, through a separate goroutine and channel — exactly the way it is done with the done channel in this lesson. This lesson used that wrapping only to check for the leak; setting up a real timeout and cutting the wait short was the subject of the Timeouts and Deadlines lesson, and it is not repeated here.

The Counter Dropping Below Zero: Panic

SS8. The third setup models the opposite mistake: Add(1) is called once, but Done() is called twice. The wait group does not silently ignore this — the moment the counter drops negative, it panics, because a negative counter breaks the library’s own internal consistency, and Go’s standard library chooses to report this immediately rather than suppress it. runAndRecover, continuing the pattern of previous lessons, catches the panic with recover and writes only panic=true/panic=false; the panic text itself is never printed anywhere, because that text carries a stack trace.

Not printing the panic text is not only a concern about the stack trace. The text the standard library gives this panic can change depending on the version that compiled the source — there is no guarantee it stays word-for-word the same, because this text is not part of a contract the language specifies, it is a detail of the library’s internal implementation. panic=true/panic=false instead measures the one thing the language does guarantee: whether a panic happens when the counter drops negative, or not. Measuring a second send to a closed channel the same way in the Closing and Ranging lesson is a recurring habit of this course: the panic itself is measured, not the panic’s text.

These two faulty setups build an interesting contrast: the counter never reaching zero stays silent, while the counter reaching zero one too many times is reported loudly. This difference comes from the wait group’s own design, not from a judgment about which error matters more. The counter can drop below zero because the wait group checks the counter on every Done call and catches a negative value immediately; the counter never reaching zero, on the other hand, is not something the wait group can check — the library has no way of knowing how many Add calls went unanswered, because “going unanswered” is a wait that can last forever, not an error. A panic can be caught because it is an instant event; a leak cannot be caught because it is not an event but an unending state — it can only be checked from the outside.

These three setups test the wait group’s contract from three directions: Wait stops when the counter reaches zero correctly, it never stops when the counter never reaches zero, and it panics when the counter reaches zero one too many times. All three violations of the contract come from the same root — the number of Add calls has to match the number of Done calls, and it is the runtime, not the compiler, that checks this match. In the previous lesson the lock’s contract was about what had to stay under the lock; here the contract is about how many Add calls correspond to how many Done calls. Both are rules the compiler never sees at all: someone reading a source can only tell whether the Add and Done calls are equal in number by watching the source run, not by reading it.

Summary

  • sync.WaitGroup is a counter that works with the Add, Done, and Wait triple; Wait blocks until the counter reaches zero, and the termination class is waited-for-arrived.
  • The order in which five workers finish is never measured, only that all of them finish; the results are collected from a channel, sorted, and printed.
  • If the counter never reaches zero, Wait blocks forever; this was checked without blocking, through select’s default branch, and the leak was measured only as a boolean.
  • An extra Done call that drives the counter below zero makes the wait group panic; the panic was caught with recover, and only panic=true was written.
  • The lock’s contract was about what needed protecting; the wait group’s contract is about how many Add calls correspond to how many Done calls — both are rules the compiler does not check, visible only by running the source.

Next Step

A wait group counts a number, but on its own it protects no value and limits no operation to a single run. Across two lessons, the lock was the only tool for protecting shared state; the next lesson shows that this monopoly is not accurate, and measures two separate primitives for shared state: once, which guarantees that a body runs exactly once no matter how many goroutines call it, and atomic operations, which protect a single variable without using a lock. Both primitives come with their own narrow contract — one protects only a one-time setup, the other only a single variable — and where this narrowness is enough, and where it falls short, is the next lesson’s own limiting measurement.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close