Chapter 18: Common Bugs and Code Review

Here is a package. It compiles. go vet is clean, staticcheck is clean, and go test -race -count=5 reports ok.

Terminal
$ $ go vet ./...
$ $ staticcheck ./...
$ $ go test -race -count=5 ./...
$ ok corebackend.dev/go-concurrency/ch18 1.316s

Three empty outputs and an ok. Every automated check this book has taught you — seventeen chapters of them — agrees the code is fine.

It contains three bugs. A quota that hands out more than its limit. A fan-out that leaves goroutines parked forever whenever it returns. A poller that accepts a context.Context and never mentions it again.

None is exotic. Each is a shape you have already met: §11.3.3's check-then-act, §2.4's abandoned sender, §13.5's ignored cancellation. You have been told about all three, in chapters that measured them. And the toolchain, run correctly, on the actual code, says nothing at all.

Here is one of them in full.

service_18.go
// Illustrative snippet — not a complete program
// Take consumes one unit of quota. It reports whether it got one.
//
// The invariant: across any number of concurrent callers, Take
// returns true at most `quota` times.
func (s *Service) Take() bool {
    s.mu.Lock()
    available := s.quota > 0
    s.mu.Unlock()

    if !available {
        return false
    }

    s.mu.Lock()
    s.quota--
    s.mu.Unlock()
    return true
}

Every access to s.quota is under the mutex. There is no data race in that function and -race is right to say so. Eight goroutines calling Take against a quota of one still take it more than once, because available is computed in one critical section and acted on in another, and the world moves in between.

Measured a quota of one, eight concurrent callers, 200 rounds, five runs under -race.
Terminal
granted more than once in 5, 6, 7, 9, 9 of 200 rounds
DATA RACE reports 0

That gap is the subject of this chapter.

It is worth being precise about what the gap is not. It is not that the tools are bad — §18.2 measures exactly what they catch, and the answer is “what one function can prove about itself, reliably, for free.” It is not that you were taught badly: the book carries 488 catalogued mistakes and all three of those bugs are in there. The gap is that recognising a bug in a chapter that is about that bug is a different skill from finding it in a diff that is about something else.

FOUR LAYERS, AND THE COST RUNS BACKWARDS

Five detection layers stacked by cost. The runtime’s own map check is free and always on, and covers one bug class probabilistically. go vet and staticcheck are free on every commit and decide a fixed set of shapes inside one function. The goroutineleak profile, new in Go 1.27, runs on request and reports blocked-and-unreachable goroutines on a snapshot you ask for. go test -race costs 1.02x to 26x and catches every data race on a path a test executed. A person reading the diff is the expensive layer and catches everything else -- protocol, lifetime and invariant. Each layer’s misses land on the next one down, and the last layer has nothing under it.

That inversion is the chapter. It is not a complaint about the tools; it is what makes the last layer worth eighteen chapters of build-up.

What you’ll learn
  • Which analyzers go vet ships for concurrency, and the measured fact that go test runs one of the seven — including the two that found the most
  • The one-line change that fixes that today, in both its forms, and the trade between them
  • Why the short default is a correct engineering decision rather than an oversight
  • Why -race is a third kind of tool, and reports nothing at all on a package with no tests
  • A reverse index: symptom first, mechanism second, grouped by where you see the symptom — a dashboard, a log, a test suite, a diff
  • The three reasons a bug is invisible to every tool — a protocol, a lifetime, an invariant — as a taxonomy that predicts rather than lists
  • The misclassification family: four chapters' hardest bugs, which are one bug wearing four costumes
  • Seven bugs that live between chapters, each needing two mechanisms, so no single chapter could own them — measured here for the first time
  • A review procedure built on Chapter 2's Four Questions, and the harder half: when to stop
What we’re not covering
  • Teaching each bug’s mechanism. Chapters 2 through 17 did that, 488 times. This chapter indexes and points; it re-derives nothing. Where an entry carries a measurement, it is because nothing in the book had measured it before
  • The deadlock apparatus — symptom table, prevention checklist, incident workflow. Chapter 10's Quick Reference has all of it, in depth, for one failure mode
  • What a clean -race run is worth, and testing technique generally — §16.3 priced it and this chapter cites it
  • Diagnosis after capture: goroutine dumps at scale, pprof, the execution tracer, delve — Chapter 19. The line is worth stating plainly: Chapter 18 is before it runs and you are reading source; Chapter 19 is after it runs and you are reading a process. This chapter stops at recognising the shape and naming the test that would have caught it
  • Profiling and performance — Chapter 20
  • Linter configuration as a subject. §18.2 measures what the tools do and do not run, not how to configure a bundle
Building toward

Chapter 16 asked what a passing test proves and answered “one interleaving”. Chapter 17 asked what a limiter enforces and answered “whatever its algorithm permits, which may not be the number in your config”. Both found the same shape: a system reporting success at every layer that has a dashboard. This chapter is that shape generalised, and the layer being asked to catch it is you.

Prerequisites

Everything, lightly — this chapter points at all seventeen predecessors. Four are load-bearing rather than merely referenced. §2.1's Four Questions, which §18.7 promotes into a review frame. §8.2's split between a data race and a race condition, which decides which layer can help at all. §16.3.4's finding that -race cannot see a race condition, which §18.4 pays with a number. And §17.6.5's misclassified failure, which §18.5 turns into a family.

Which Go are we on?

Every figure was produced on go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16, with staticcheck 2026.1 (v0.7.0), golangci-lint 2.10.1, golang.org/x/sync v0.22.0 and golang.org/x/time v0.15.0.

Two kinds of figure appear below and they are marked differently. Tool output is deterministic — the same command on the same source produces the same lines, and those are quoted verbatim. Anything with a scheduler in it is reported as a range across repeated runs, with the run count attached, per Chapter 17's rule. Three version-dependent facts matter here specifically. defaultVetFlags is a source excerpt and moves between releases, so §18.2 tells you how to re-check it against the toolchain you actually ship — and it did move in Go 1.27, which added stdversion to the automatic set and rewrote the excerpt; §18.2.2 quotes the 1.27 form. go vet's analyzer set grows — waitgroup is recent — so §18.2.1's table is a snapshot. Go 1.27 also made the runtime's goroutineleak profile generally available, which changes one row of §18.4.6's table and nothing in §18.1's numbers, because no command this chapter runs collects it. And the go directive in your go.mod decides which bugs your code is able to have, which §18.2.9 measures directly.

18.1 Why You Ship Bugs You Already Know

The opening transcript is easy to dismiss as a trick — a package built to embarrass the tools. It was not, and this section is about showing that the result generalises.

18.1.1 The Book Is Already a Catalogue

The obvious shape for a chapter called “Common Bugs” is a list of bugs. That shape is unavailable, because the list already exists and it is seventeen chapters long.

Measured counted from the shipped HTML of chapters 1 through 17 — 471 .mistake-item entries across 61 Common Mistakes components, plus 17 written as <h4>Mistake N: prose entries in the two chapters that predate the component, for 488 catalogued mistakes. Every one carries a symptom and a fix, and every one sits beside the chapter that measured its mechanism.

A hundred more would not have helped. Why not is the thing worth understanding before reading further.

18.1.2 The Corpus, and the Rule That Chose It

A detection ratio without its corpus is not a measurement — it is a number you cannot disagree with, and the number moves a great deal depending on what went in the jar.

So here is the jar, and here is the rule, stated before the bugs were chosen: one bug per chapter, taken from what that chapter teaches. Sixteen chapters, sixteen bugs, identified by the chapter they came from. If the book’s own material survives the toolchain, the result is not a trick.

The corpus:
#
B02
B03
B04
B05
B06
B07
B08
B09
B10
B11
B12
B13
B14
B15
B16
B17

The rule is not neutral — it over-represents the bugs a teaching book finds worth teaching. It is, however, stated in advance, which is the part that matters. A reader can disagree with the rule; they cannot be misled by it.

The package ships at labs/go-concurrency/code/ch18/corpus/, so every number in the next subsection is one you can reproduce rather than one you have to take.

18.1.3 What Each Layer Caught

Four commands over that package. The tool outputs are deterministic and quoted verbatim.

Measured go vet ./... reports three.
Terminal
corpus.go:86:16: B09Read passes lock by value: corpus.B09Counter
                  contains sync.Mutex
corpus.go:134:7: the cancel function returned by
                  context.WithCancel should be called, not
                  discarded, to avoid a context leak
corpus.go:146:2: misuse of unbuffered os.Signal channel as
                  argument to signal.Notify
Measured staticcheck ./... reports two, and only one of them is a bug go vet already had.
Terminal
corpus.go:41:3: should not have an empty default case in a
                  for+select loop; the loop will spin (SA5004)
corpus.go:146:16: the channel used with signal.Notify should be
                  buffered (SA1017)
Derived three and two with one in common is a union of four of sixteen from the two static tools — B04, B09, B13 and B15. Running only one of them costs you a bug either way, and a different bug in each direction. Measured go test -race ./... reports one more, the map write in B08, on every run of five. The test file drives four of the others — the leaked goroutine, the unjoined panic-capable goroutine, the discarded cancel, and the Once that captures a context — and -race executed every one of them and said nothing, correctly, because none of them is a data race. That makes five of sixteen caught between all three tools, and eleven caught by nothing.

Then there is a fourth layer, and it is the one nobody lists because it has no command of its own.

Go 1.27 adds one more runtime layer, and it sits between the free one and -race: the goroutineleak profile. Ask the runtime — pprof.Lookup("goroutineleak"), or /debug/pprof/goroutineleak — and it reports every goroutine blocked on a channel, mutex or Cond that the garbage collector can prove no runnable goroutine will ever reach. On this corpus that is B07 and the exercise's FetchAll; it says nothing about a goroutine asleep in time.Sleep, and nothing at all unless something collects it, which none of the three commands above does. It is the first tool that reads a lifetime fact — reachability — rather than syntax or memory, and Chapter 19 (§19.3.6) shows it in use.

Measured go test ./... with no flags, four batches of twenty-five runs — fatal in 4, 4, 4 and 5 of 25, so roughly one run in six.
Terminal
$ $ go test ./...
$ ok corpus 0.533s
$ $ go test ./...
$ fatal error: concurrent map writes
$ goroutine 30 [running]:
$ internal/runtime/maps.fatal({0x52dedc9?, 0x0?})
$ /usr/local/go/src/runtime/panic.go:1181 +0x18

That is not the race detector. It is the runtime’s own map-write check, compiled into every Go binary, on by default, with no flag to enable it and none to turn it off. It covers exactly one bug class, and it covers it probabilistically — it fires when two writes actually collide, not when the code is capable of colliding.

Which means the rate is a property of the program rather than of the bug, and it is worth seeing what it moves with. Here is B08, in full, because the next table is only checkable if you can see what was varied.

b08_181.go
// Illustrative snippet — not a complete program
// B08 (ch8): unsynchronised map write.
func B08() map[int]int {
	m := map[int]int{}
	var wg sync.WaitGroup
	for i := 0; i < 8; i++ {
		wg.Add(1)
		go func(i int) { defer wg.Done(); m[i] = i }(i)
	}
	wg.Wait()
	return m
}
Measured that loop bound changed and nothing else, twenty runs of go test ./... at each.
Writing goroutines
2
4
8
16
32

Two writers never collided in twenty runs. Sixteen collided in nineteen of twenty.

But read that as a fact about this program rather than about two goroutines, because the same two-writer race says something different elsewhere. Measured: the identical map write, at two goroutines, in a standalone binary that does nothing else at all — 20 of 20 runs died. Same bug, same writer count, opposite verdict, and the only difference is what else the process was doing.

That is the whole character of the layer, and it is a sharper version of “probabilistic” than a percentage would be. The check needs two writes to land in the same instant, so its coverage tracks collision opportunity — how much other work the runtime is juggling, how many cores it has, what else is scheduled. It is free, it asks nothing of you, and it is least reliable in exactly the place you would want to depend on it: a small unit test with two goroutines and nothing else running. It catches B08 sometimes. It has nothing to say about the other fifteen, ever.

WHAT THE CORPUS SURVIVED

A bar chart of the sixteen-bug corpus. go test with no flags catches zero to one, go test -race one, staticcheck two, go vet three; the union of all four is five, and eleven bugs are caught by nothing at all. The go test row is the runtime’s own map check, and it is the only row that is a probability rather than a number.

Report the corpus, not the ratio.

Three earlier probes of the same kind, with corpora chosen differently, gave go vet four, five and six findings and staticcheck one, three and four. Each was correct about its own jar and none of them generalises. Any article quoting “static analysis catches N% of concurrency bugs” without publishing its corpus is quoting a property of its own examples. What survives every corpus anyone assembles is the shape: a minority is caught, the caught ones are the mechanical ones, and the misses concentrate in the bugs that take a process down. Quote the shape. If you quote a ratio, print the corpus beside it, which is why this section leads with one.

18.1.4 The Split Is Not Random

Look at what the tools caught and what they did not, and the boundary is sharp.

Every one of the four is a local, syntactic property of one expression. B09Read takes a B09Counter by value and that type has a sync.Mutex in it — decidable from the signature. WithCancel's second result is assigned to _. An unbuffered channel reaches signal.Notify. A for loop wraps a select with an empty default. You can see each of them in one function, knowing nothing about what the program is for. Each is wrong in every program, which is precisely why a tool can decide it: the analyzer cannot be wrong about your design, because it does not know your design.

Every one of the eleven requires knowing something the source does not say:

This is not a tooling gap that will close.

It is tempting to read “eleven of sixteen” as a roadmap for better linters. It is not. A tool that knew who was supposed to close a channel would need you to have written that down — at which point you have a type, a comment, or a review, which are the three answers this chapter is actually about. The missing information is missing from the program, not from the analyzer.

18.1.5 Why Knowing Does Not Help

You know all sixteen. You met them in chapters that were about them, and you got them right in the exercises.

The reason that does not transfer is that a chapter hands you the frame for free. Reading §11.3.3 you already know the subject is check-then-act, so two critical sections stand out immediately. Reading a diff titled “add per-tenant quota” you are checking whether the quota is per tenant.

WHAT A CHAPTER GIVES YOU THAT A DIFF DOES NOT

A chapter hands you the frame for free: its title says the section is about check-then-act before you have read a line. A diff hands you a frame that is accurate and points somewhere else -- “add per-tenant quota accounting”. You find what you are looking for, and the diff’s own title decides what that is.

That is not carelessness. It is what attention is: a limited resource pointed somewhere, and the diff’s title points it at the feature. §18.7 is about pointing it somewhere else on purpose.

There is a second mechanism stacked on the first, and it is why experience alone does not fix this.

Concurrency bugs are usually in code that is not the point of the change. A diff that adds per-tenant quota changes the quota logic — and touches, in passing, a goroutine that already existed, a channel somebody else owns, an error path that now has one more branch. The concurrency lives in the plumbing the change flows through, and the plumbing is exactly what a reviewer skims because it barely changed.

The cold open’s bugs have that shape. Nobody set out to write a check-then-act quota; they set out to write a quota, and two critical sections are what “make it thread-safe” produced. Nobody set out to leak goroutines; they set out to return the first result, and the leak is what “take the first answer” produced in a function that had senders still running.

Naming the failures separately helps, because each has a different remedy.

Recognition fails in three ways, and each has a different remedy. Only the middle one is a knowledge problem — which is the awkward part, because it is the only one seventeen chapters of teaching can address.

You did not see the code. The bug is in a file the review never opened, or in the four lines everyone scrolled past because they were “just moving a call”. The remedy is §18.7's rule about what to read first, which is not the diff.

You saw it and it looked right. The common one, and §18.4's subject. Check-then-act looks especially right — every access locked, symmetrical, obviously careful. The remedy is a question you ask rather than a pattern you spot.

You saw it, knew it was wrong, and it was not your file. The social failure, and the one no chapter can fix. §18.7.10 comes back to it anyway, because a review process that reliably produces correct-and-ignored comments is not working.

The pattern worth carrying.

Concurrency bugs are side effects of changes that were about something else. That is why §18.7 reads the diff in a deliberately wrong order — the sites where these bugs live are the sites the diff is not about, and reading in diff order guarantees you reach them last, with the least attention left.

18.1.6 What 488 Entries Are Actually For

None of this makes the catalogue a mistake. It makes it a reference rather than a defence, and the distinction is practical.

A reference is what you reach for once you know the mechanism. When §18.3 narrows an incident to “queueing, probably §17.5.1”, chapter 17's Common Mistakes rows are exactly what you want next — dense, specific, and organised by the thing you now know you have. That is the reading direction they were written for.

What they cannot do is stop you writing the bug, because at the moment of writing you do not know which of the 488 applies. That is not a defect in the entries; it is §18.1.5's frame problem again. A catalogue answers “what goes wrong with X”, and the author of a bug does not yet know they are doing X.

Two directions, two artefacts.

Mechanism → failure modes is every chapter you have already read; use it once you know the mechanism. Symptom → mechanism is §18.3; use it when all you have is a graph. The 488 entries serve the first direction and are useless for the second, which is the entire reason §18.3 exists.

18.1.7 Common Mistakes

Reading a green CI as “no concurrency bugs”
Problem

Confidence proportional to nothing

Fix

Measured, five of sixteen; know which five (§18.2)

Quoting a “tools catch N%” figure
Problem

The number is a property of somebody’s corpus

Fix

Publish the corpus or drop the number

Expecting better linters to close the gap
Problem

Waiting for a tool that cannot exist

Fix

The missing information is missing from the program

Adding staticcheck and considering review handled
Problem

The expensive bugs are the ones both tools miss

Fix

The layers are not interchangeable

Treating the catalogue as the deliverable
Problem

488 entries exist and did not prevent the cold open

Fix

The deliverable is a procedure (§18.7)

Reviewing a diff on the diff’s own terms
Problem

You check the feature; the bug is in the plumbing

Fix

Goroutine sites first, feature last

Reading a clean -race run as proof
Problem

Race conditions with no data race ship

Fix

§16.3.4, and §18.4's invariant category

Summary: Why You Ship Bugs You Already Know

The book already holds 488 catalogued mistakes, so this chapter cannot be another list. Measured against a sixteen-bug corpus chosen by a rule fixed in advance — one bug per chapter, from what that chapter teaches — go vet found three, staticcheck found two of which one was already vet’s, -race found one more, and eleven were caught by nothing at all. go test with no flags found nothing you can rely on — it killed the process on the map write in one to five runs of twenty, which is the Go runtime’s own map check rather than any tool you chose to run.

The split is not arbitrary. The four the static tools caught are local syntactic properties of one expression, wrong in every program. The eleven that were missed each require a fact the source never states: who closes this, who waits for this, whose context this is, whether the invariant survives the gap. No analyzer will supply those, because the information is absent from the program rather than from the tool.

And knowing the bugs does not transfer, for two reasons that compound. A chapter hands you the frame for free and a diff hands you a frame that is true and points elsewhere; and concurrency bugs live in the plumbing a change flows through rather than in the change itself.

Key Takeaways

  • Measured, the book already holds 488 catalogued mistakes across 61 components — this chapter indexes rather than adds
  • Measured, on a sixteen-bug corpus: go test 0, go vet 3, staticcheck 2, union 4, -race 1 more, nothing 12
  • Publish the corpus with any such ratio; three other probes gave three other numbers and one shape
  • The caught four are local and syntactic; the missed eleven are properties of a protocol, a lifetime or an invariant
  • Better tools will not close that gap — the information is missing from the source, not from the analyzer
  • A chapter gives you the frame for free; a diff gives you a frame that is accurate and points at the feature
  • Concurrency bugs are side effects of changes that were about something else
Section 18.1 — in one line

The tools catch the bugs that are wrong in every program; you are responsible for the ones that are only wrong in yours.

Self-Check Questions: Why You Ship Bugs You Already Know

Your CI runs go vet, staticcheck and go test -race, all green. What proportion of concurrency bugs does that rule out?

On this chapter’s corpus, five of sixteen — four from the two linters plus the map write from -race. The other eleven include every bug that depends on a protocol, a lifetime or an invariant.

Green CI is a floor rather than a ceiling, and §18.2 shows the floor is lower than it looks: unless somebody added a step, go test runs one of go vet's seven concurrency analyzers, and two of the three findings above would never have appeared.

The honest framing to carry into a planning meeting is that the CI above costs seconds, finds real bugs, has no false positives worth the name, and changes nothing at all about the other eleven.

Your team adds a rule: every PR touching concurrency gets two reviewers. Six months later the bug rate is unchanged. What is the most likely reason?

Two reviewers reading in diff order find what one reviewer reading in diff order finds, because the limit is not the quantity of attention but where it points.

§18.1.5 is the argument. The diff’s title is an accurate description of the feature, and accuracy is exactly the problem — it aims both reviewers at the lines the change is about, and the concurrency bug is in the plumbing the change flows through. Doubling the readers doubles the coverage of the feature.

What moves the number is a different reading order, which is §18.7.2 and costs about twenty seconds on a normal diff. A second reviewer following the same order as the first is genuinely useful; a second reviewer following no order is a second opinion about the feature.

Why is “we need a linter that catches goroutine leaks” the wrong response to §18.1.3?

Because a leak is only a leak relative to an intent the source never states.

A goroutine that runs until the process exits is a leak in a request handler and correct in a background flusher, and the two are byte-identical code. An analyzer would have to be told which one this is — and a declared intent is a type, a comment, or a review, which are the three answers §18.4.6 arrives at.

There is a real version of the request, and it is narrower: a test can catch a leak, because a test knows the goroutine was supposed to be gone by the time the test ended. That is §16.5's goleak, and it works precisely because the test supplies the intent the source does not. Go 1.27's goroutineleak profile is the dynamic version of that narrower request: it does not need the intent, because a goroutine blocked on something nothing can ever reach is a leak in every program.

18.2 What the Toolchain Actually Runs

§18.1 measured what go vet and staticcheck find when you run them, and that go test ./... found nothing at all. This section is about the gap between those two sentences, which is wider than almost anyone expects and closes in one line.

18.2.1 The Seven Analyzers

go vet is not one check. It is a bundle, and go tool vet help lists what is in it. Seven of the thirty-five are about concurrency.

Analyzer
atomic
copylocks
loopclosure
lostcancel
sigchanyzer
testinggoroutine
waitgroup

Read that list against the chapters. copylocks is Chapter 9. lostcancel is Chapter 13. sigchanyzer is Chapter 15's cold open, which said at the time that go vet catches it. testinggoroutine is §16.2.6. waitgroup is §2.3. Five of the seven check for bugs this book taught you, which is a genuinely good sign about the analyzer set.

The problem is not the set. It is when the set runs.

18.2.2 go test Runs One of Them

go test runs vet before compiling, as a convenience. It runs a subset, and the subset is a literal in the Go source.

Measured, from $GOROOT/src/cmd/go/internal/test/test.go:

default_vet_182.go
// Illustrative snippet — not a complete program
var defaultVetFlags = []string{
    // TODO(rsc): Decide which tests are enabled by default.
    // See golang.org/issue/18085.
    // "-appends",
    // "-asmdecl",
    // "-assign",
    "-atomic",
    "-bools",
    "-buildtag",
    // "-cgocall",
    // "-composites",
    // "-copylocks",
    // "-defers",
    "-directive",
    "-errorsas",
    // "-framepointer",
    // "-hostport",
    // "-httpresponse",
    "-ifaceassert",
    // "-loopclosure",
    // "-lostcancel",
    "-nilfunc",
    "-printf",
    // "-shift",
    // "-sigchanyzer",
    "-slog",
    // "-stdmethods",
    "-stdversion",
    "-stringintconv",
    // "-structtag",
    // "-testinggoroutine",
    "-tests",
    // "-timeformat",
    // "-unmarshal",
    // "-unreachable",
    // "-unsafeptr",
    // "-unusedresult",
    // "-waitgroup",
}

Read the commented-out lines rather than the enabled ones. -copylocks and -lostcancel are commented out — two of the three analyzers that found anything in §18.1.3. So is -loopclosure (Go 1.27 spells it that way; -rangeloops is still accepted as an alias), and so are -sigchanyzer, -testinggoroutine and -waitgroup: every one of the seven is named, and six of the seven are switched off. The one line Go 1.27 added is -stdversion, which flags a standard-library symbol newer than your go.mod's go directive — a fact about the source, not a heuristic, which is exactly the kind of check the automatic set is allowed to hold.

WHAT go test RUNS, AND WHAT IT DOES NOT

Of go vet's seven concurrency analyzers, go test runs one. atomic is run. copylocks, loopclosure, lostcancel, sigchanyzer, testinggoroutine and waitgroup are all present in defaultVetFlags and all commented out. One of seven, and it is the one that found nothing in the corpus.

Here is the whole thing in three commands, on one package.

Measured same source, same machine, same minute.
Terminal
$ $ go test ./...
$ ok corpus 0.533s
$ $ go vet ./...
$ corpus.go:86:16: B09Read passes lock by value: corpus.B09Counter
$ contains sync.Mutex
$ corpus.go:134:7: the cancel function returned by
$ context.WithCancel should be called, not discarded
$ corpus.go:146:2: misuse of unbuffered os.Signal channel as argument
$ to signal.Notify
$ $ go test -vet=all ./...
# [corpus]
$ ./corpus.go:86:16: B09Read passes lock by value: corpus.B09Counter
$ contains sync.Mutex
$ ./corpus.go:134:7: the cancel function returned by
$ context.WithCancel should be called, not discarded
$ ./corpus.go:146:2: misuse of unbuffered os.Signal channel as argument
$ to signal.Notify
$ FAIL corpus [build failed]

Three verdicts on one package. The silent one is the command your CI runs on every commit, and a team whose pipeline is go test -race ./... — a perfectly respectable pipeline, and the one this book has been recommending — has never run copylocks on their codebase.

Why the short default is the right decision.

A vet finding during go test does not warn — it fails the build before a single test runs, which is why the transcript above ends in FAIL [build failed] rather than a test failure. That makes the automatic set a very expensive place for a heuristic. copylocks can flag a deliberate copy; lostcancel can flag a cancel that is stored on a struct and called elsewhere. If either were on by default, one false positive would block an unrelated test run in somebody else’s package, and the pressure would immediately be to turn vet off entirely — a far worse outcome than the current one. The conservatism is good engineering. It simply is not a coverage decision, and reading it as one is how a team ends up believing six analyzers are running when none of them is. Go 1.27 is the proof by example: the one check added to the automatic set was stdversion — a symbol is either newer than your go directive or it is not — while the heuristics stayed commented out.

18.2.3 The One-Line Remedy, and What to Put in CI

This is the shortest fix in the book, and it has two forms.

Terminal
$ $ go vet ./... # a separate step
$ $ go test -vet=all ./... # folded into the test run

Choose deliberately. -vet=all is one flag and gives the strongest guarantee: nobody can run the tests without the analyzers. The cost is that a vet finding now blocks your tests, which for a heuristic analyzer is occasionally the wrong trade on a Friday afternoon. A separate go vet ./... step reports independently, gives a cleaner failure message, and can be made non-blocking while a team works through a backlog. Teams that have been bitten pick the first. Either is enormously better than the default, and the default is what you have unless somebody chose.

One thing the default now does on its own, since Go 1.27: stdversion is in the automatic set, so a module whose go directive is older than a standard-library symbol it uses fails go test even without -vet=all. Measured, on go1.27.1: a go 1.25 module calling errors.AsType stops at errors.AsType requires go1.26 or later (module is go1.25) and FAIL [build failed], before a single test runs. The transcript above and the remedy are unchanged; this is the one finding the default set will hand you unasked.

The full recommendation is four lines, and it is worth stating concretely because “run the linters” is the kind of advice that never gets implemented.

Terminal
$ go vet ./... # the full default analyzer set
$ staticcheck ./... # the SA series, which barely overlaps
$ go test -race ./... # §16.3.5 priced this
$ go test ./... # the rest of the tree, without -race

Three notes on the shape.

Vet and staticcheck belong before the tests, not after. They are fast — seconds on a large module — and a vet finding usually means the test run would have been meaningless anyway. Putting them first also gives a cleaner failure: copylocks at a line number beats a test failure three layers down.

-race does not belong on every package. §16.3.5 measured the cost between 1.02× for code that shares nothing and about 26× for code that synchronises constantly — ordered inversely to how much real work each operation does, so it is most expensive exactly where it is most valuable. Racing the packages that own concurrency on every push and the whole tree nightly is the shape §16.3.6 argued for, and it is still right.

None of this catches the eleven. That is the honest framing to hold onto when proposing the change. The CI above costs almost nothing, has no false positives worth the name on this corpus, and leaves every bug in §18.4 exactly where it was. The failure mode of a linting initiative is a team that adopts one and concludes the concurrency problem is handled.

18.2.4 What staticcheck Adds

The two tools are usually presented as alternatives. On concurrency they are not: their sets barely intersect.

On §18.1's corpus, staticcheck found SA5004 — the spinning for+select loop — where go vet was entirely silent, and SA1017, which was sigchanyzer's bug from the other side. One new, one shared, out of three vet findings. That is the overlap in miniature, and it points both ways.

The corpus was one bug per chapter rather than one bug per checker, so it under-represents what staticcheck can do. Here are two more checks that fire where go vet says nothing.

Measured a second package, both tools.
Terminal
$ $ go vet ./...
$ $ staticcheck ./...
$ t.go:25:2: deferring Lock right after having locked already;
$ did you mean to defer Unlock? (SA2003)
$ t.go:31:2: empty critical section (SA2001)

SA2003 is the one to notice. mu.Lock() followed by defer mu.Lock() is one character from defer mu.Unlock(), it produces a self-deadlock on the next call (§10.3), and it is invisible when reading quickly because the two lines look symmetrical. go vet has nothing to say about it.

Concurrency checks worth knowing:
Check
copylocks
lostcancel
waitgroup
atomic
loopclosure
testinggoroutine
SA1017
SA2000
SA2001
SA2002
SA2003
SA5004
SA6002

Two of the vet entries are worth a closer look, because what they fire on is narrower than the names suggest and the difference decides what a clean run means.

copylocks works through noCopy, so it catches a sync.Once or a sync.WaitGroup embedded three levels down, and it names the chain: Copy passes lock by value: Init contains sync.Once contains sync.noCopy. What it cannot catch is a lock copied through an interface or a reflect call, because the value’s type is not statically known there. That is rare, and worth knowing only as the boundary: copylocks is a type check, so it sees exactly as much as the types do.

lostcancel is genuinely good at the _ case. It is much weaker once the cancel is stored in a struct field or handed to another function, because then “is it called?” stops being a local question — the same boundary as everything else in §18.1.4.

A check whose rule the language retired.

SA1015 exists because, before Go 1.23, an unstopped time.Ticker could never be collected. §17.2.3 measured that this is no longer true, and Go 1.27 closed the last door on the old rule by removing the asynctimerchan opt-out. Measured: on go1.26.1, SA1015 did not fire on for range time.Tick(d) in either the loop or the single-select form, and neither did go vet. The check has kept up. The general lesson is worth more than the specific one: a linter encodes a rule that was true when it was written, and rules expire. When a check fires on something a recent release changed, verify against the standard library’s documentation rather than against the linter.

Most teams do not run the two tools directly; they run an aggregator, and it is worth knowing what that changes.

Measured golangci-lint 2.10.1, no configuration file, on §18.1's corpus.
Terminal
$ $ golangci-lint run ./...
$ corpus.go:86:16: copylocks: B09Read passes lock by value (govet)
$ corpus.go:134:7: lostcancel: the cancel function returned by
$ context.WithCancel should be discarded (govet)
$ corpus.go:146:2: sigchanyzer: misuse of unbuffered os.Signal
$ channel as argument to signal.Notify (govet)
$ corpus.go:41:3: SA5004: should not have an empty default case in
$ a for+select loop (staticcheck)
$ 4 issues:
$ * govet: 3
$ * staticcheck: 1

The encouraging half: a bundled runner enables govet with its full analyzer set, not go test's subset, and tags each finding with the analyzer that produced it, which is more than go vet itself tells you. If you already run one of these, §18.2.3's remedy may already be in place.

The other half is the ceiling, and it has two parts. Four findings out of sixteen bugs — the same four, because the bundle changes who invokes the analyzers and not what an analyzer can know. And the staticcheck set it ran was narrower than staticcheck ./...: run directly, staticcheck reported SA1017 as well.

Check rather than assume, and the check is cheap.

An aggregator’s enabled set is per-repository configuration and can be narrowed by a .golangci.yml somebody added two years ago. The reliable way to know what your pipeline catches is not to read the config. It is to plant a bug and watch CI go red. Copy B09 — a struct with a sync.Mutex, passed by value — into any package, push it, and see what happens. That is a five-minute experiment that settles a question teams argue about for months.

What go fix Adds

One command in the toolchain is not a reporter at all, and it belongs in a different part of the pipeline from everything above. Go 1.26 rebuilt go fix as the home of the modernizers — rewriters, built on the same analysis framework as go vet, that update a codebase to current idioms and library APIs. Go 1.27 adds four (atomictypes, embedlit, slicesbackward, unsafefuncs), removes fmtappendf, and renames waitgroup to waitgroupgo. Two of them mechanically apply this book's own recommendations: waitgroupgo rewrites the wg.Add(1) / go / defer wg.Done() triple into wg.Go (§2.3), and atomictypes rewrites atomic.AddInt64(&x, 1) and its declaration into the typed atomic.Int64 API (§11.5). A third, errorsastype, moves errors.As to Go 1.26's generic errors.AsType.

Three things about it are easy to get wrong. First, the rename is a go fix rename, not a go vet one: vet's analyzer is still called waitgroup, defaultVetFlags still spells it -waitgroup, and §18.2.1's row keeps its name — the modernizer was renamed precisely so the two would stop colliding. Second, the count in §18.2.1 is unchanged: measured, go tool vet help on go1.27.1 lists thirty-five analyzers and the same seven concurrency ones. Third, the modernizer suite deliberately has no bloop, because a benchmark rewrite can skew results — so go fix will not migrate b.N to b.Loop, and a go fix ./... line in CI must not be read as having done so. Run it the way you would read a colleague's diff: it produces edits, not findings, and the edits still want a reviewer.

18.2.5 -race Is a Third Tool, and It Needs a Test

The obvious objection to all of that is that Go ships a third tool, and it is the one everybody actually trusts. -race is not a linter. It is a runtime detector, and that difference decides what it can and cannot do for you.

Measured §18.1's corpus with its test file removed.
Terminal
$ $ go test -race ./...
$ ? corpus [no test files]

Zero findings, and an exit code of zero to go with them. go vet still reports its three on that same file and staticcheck its two, because they read the source. The bugs have not gone anywhere — a runtime detector with nothing to run has nothing to say, and the way it says so looks exactly like success.

Measured the same corpus with its tests, which drive five of the sixteen bugs.
Terminal
$ $ go test -race ./... 2>&1 | grep -c 'WARNING: DATA RACE'
$ 1

One finding, on the map write. -race executed the other four. It watched a goroutine leak, it watched a cancel go undiscarded, it watched an unjoined goroutine run, and it watched a sync.Once capture a caller’s context and hand the resulting error to a second caller who had no deadline at all — and it said nothing about any of them, correctly, because none of them is a data race.

What separates the four layers is not how much they catch but what each one reads. go vet and staticcheck read the source, so a compile is all they need. -race reads an execution, so it needs a test that reaches the bug and then needs the window to open on that run. The runtime’s map check reads a collision, so it needs the window to open and asks nothing of you at all.

So -race has a coverage story with two multipliers where the static tools have one. A static tool’s reach is the shapes someone wrote a check for. -race's reach is the code your tests execute, times the probability the window opens on that run.

Derived §16.1.1 measured that second factor at 197 in 200 for a real race with a narrow window — three runs in two hundred where a fully covered, fully instrumented bug reports green. Re-running once after a change you believe is the fix therefore confirms it wrongly about one time in seventy.
A clean -race run is evidence about one class of bug, in the paths your tests happen to cover.

It is the strongest single signal in the toolchain and it is worth every millisecond it costs. It is not evidence that the code is correct, and it is not evidence that the code is free of race conditions. §18.4 is about the classes it does not model — and §18.4.4 is a bug that becomes findable only under -race while the detector stays silent from start to finish.

18.2.6 The Blind Spot Both Tools Share

§16.2.6 measured that go vet's testinggoroutine analyzer misses t.Fatal inside wg.Go. The obvious next question is whether the other tool covers the gap.

Measured four shapes of one bug, one file, both tools.
Shape
go func() { t.Fatal(...) }()
go helper(t)
wg.Go(func() { t.Fatal(...) })
g.Go(func() error { t.Fatal(...); ... })

Two independently written analysis engines, the same blind spot, on the two idioms this book has recommended since Chapter 14. Both look for a go statement; wg.Go and errgroup.Go start goroutines by calling a method, and a method call is not a go statement. The gap will widen rather than close: Go 1.27's waitgroupgo modernizer moves code from the shape the analyzers check to the shape they do not.

Note the second row, because it corrects something worth correcting: both tools trace into the helper. Vet reports go helper(t) with the note *“(helper calls (\testing.T).Fatal)”, and staticcheck names the t.Fatal inside it rather than the go statement. The claim to keep is the narrow one — they follow a direct call from a go statement, and they do not follow a function value handed to wg.Go.

That two independent engines agree on what they trace and on what they miss is worth more than either gap alone: it means the omission is a property of the problem, not a bug report you should file.

18.2.7 Your go.mod Decides Which Bugs You Can Have

loopclosure deserves a subsection because it is the one place the toolchain is doing something subtler than it gets credit for.

Loop-variable capture was the most famous bug in Go for a decade. Go 1.22 gave each iteration its own variable and the bug stopped existing — for code whose go.mod declares 1.22 or later. Older modules keep the old semantics, so the bug is still real there. loopclosure handles both.

Measured the same file twice, with nothing changed but the go directive.
Terminal
$ $ grep ^go go.mod
$ go 1.21
$ $ go vet ./...
$ a.go:13:44: loop variable u captured by func literal
$ $ go run .
$ c c c
$ $ grep ^go go.mod
$ go 1.26
$ $ go vet ./...
$ $ go run .
$ a c b

The analyzer is correct in both directions and so is the program: at go 1.21 all three goroutines see the last value; at go 1.26 each sees its own, in whatever order the scheduler picks.

Now change the shape, keeping the bug. Instead of a go statement, append the closures to a slice and call them afterwards.

print_all_182.go
// Illustrative snippet — not a complete program
func printAll() {
    var fs []func()
    for _, u := range []string{"a", "b", "c"} {
        fs = append(fs, func() { fmt.Print(u, " ") })
    }
    for _, f := range fs {
        f()
    }
}
Measured the same two go.mod versions again, on the slice form rather than the go form.
Terminal
go 1.21 vet: (silent) output: c c c
go 1.26 vet: (silent) output: a b c

At go 1.21 that program has the identical bug — same wrong output — and vet says nothing, because loopclosure checks go and defer statements in a loop body and this is neither.

The pattern, and it is this section’s real finding.

§18.2.6 and §18.2.7 are the same shape twice. A tool is written against the idiom the bug was famous in — a go statement — and stays silent on an equivalent shape. wg.Go instead of go func. A slice of closures instead of go func. Neither gap is a defect; both are the boundary of what a syntactic check can promise. A vet finding says this specific shape, at this location. It never says this class of bug, anywhere.

The other half is about your go.mod. Which mistakes your code is able to make is decided by one line, and the toolchain already knows it. A team on go 1.21 and a team on go 1.26 are reviewing different languages, and a review checklist copied between them is wrong in one direction or the other. Go 1.27 made the toolchain say so out loud: go test now runs stdversion by default, so a file that uses a standard-library symbol newer than its module's go directive no longer compiles under go test at all — the go line has become a build-time contract, not just a semantics switch. It is also why §18.3's index and §18.4's categories are worth more than a list: a list expires with the language, and a question about a contract does not.

18.2.8 When the Tool Is Wrong

Turn on six analyzers that were not running and some of them will report code you meant. That is not a reason to turn them off again, and it is worth deciding in advance how you will handle it, because the decision made under deadline pressure is always “disable the linter”.

Three honest responses, in descending order of preference.

Change the code. More often than people expect, the analyzer is describing a real smell even when it is not describing a real bug. copylocks fires on a value receiver for a type containing a sync.Mutex — and a type containing a mutex almost always wants a pointer receiver anyway, so the finding is a design note delivered by a robot.

Suppress it, at the line, with a reason. Every tool supports this and the form matters:

client_182.go
// Illustrative snippet — not a complete program
func (c *Client) start(ctx context.Context) {
    // The analyzer cannot see that cancel is stored on c and
    // called in c.Close. Verified: TestClientClosesCancel
    // covers it.
    //nolint:govet // lostcancel: cancel is called in Close
    ctx, c.cancel = context.WithCancel(ctx)
    go c.loop(ctx)
}

A suppression with a reason and a named test is documentation. A bare //nolint teaches a future reader nothing and is indistinguishable from the ones somebody added to make the build pass.

Disable the check repository-wide — and write down when it comes back. Sometimes a check does not fit a codebase, and one line of configuration is honest where four hundred suppressions are not. What makes it honest is the second half: a comment saying what would have to change for it to be re-enabled. Without that, a disabled check is indistinguishable from a check nobody knows about.

The suppression that hides the bug you were looking for.

//nolint and its cousins are unreviewed by definition — the tool stops looking, and a reviewer’s eye slides over a comment that reads like boilerplate. §18.5's family is exactly what hides there, because “this error is fine” is what every misclassification looks like from the inside. A suppression on a line involving a context, an error being classified, or a close deserves the same reading as the code it is suppressing.

18.2.9 Common Mistakes

Assuming go test runs go vet
Problem

Six of seven concurrency analyzers never ran

Fix

Measured; go vet ./..., or -vet=all

Reading the short default as an oversight
Problem

You argue with the Go team instead of adding a step

Fix

A vet finding fails the build, so the set cannot hold heuristics

Running one linter and calling it covered
Problem

The other tool’s set barely overlaps

Fix

Run both; measured, they shared one finding of five

Reading a green -race run as “no concurrency bugs”
Problem

The other classes are untouched

Fix

Measured, 1 of 16 with tests and 0 with none

Reading a clean vet as “no bugs of that class”
Problem

wg.Go and slice-captured closures pass silently

Fix

A finding is about a shape at a location, not a class

Trusting a linter rule older than the language
Problem

time.Tick advice Go 1.23 retired

Fix

Measured, SA1015 no longer fires; check the docs

Copying a review checklist between repositories
Problem

The go directives differ, so the bug surfaces differ

Fix

Check go.mod first (§18.2.7)

Adding a bare //nolint to quiet a finding
Problem

An unreviewed line, forever

Fix

A reason and a named test, or change the code

Summary: What the Toolchain Actually Runs

go vet ships seven concurrency analyzers and go test runs one of them. Measured from defaultVetFlags in the Go 1.27 source: -copylocks, -lostcancel, -loopclosure, -sigchanyzer, -testinggoroutine and -waitgroup are all present and all commented out; atomic is the only concurrency analyzer switched on, and the one line 1.27 added is -stdversion. The consequence is three verdicts on one package — go test says ok, go vet reports three bugs, go test -vet=all reports all three and fails the build.

The conservatism is correct rather than a bug: a vet finding fails the build before any test runs, so the automatic set must not contain heuristics. That makes it a good engineering decision and a poor coverage decision, and the fix is one line in either of two forms.

staticcheck is a complement rather than an alternative — measured, it found SA5004 where vet was silent, and SA2001 and SA2003 fire on a package vet passes clean. -race is a third kind of thing entirely: measured, it reports nothing at all on a package with no test files, and one of sixteen with tests, having executed four of the others and correctly said nothing.

And both static tools share a blind spot with the same cause, twice over. Measured, each flags t.Fatal in a go statement, each traces through a helper, and neither flags wg.Go or errgroup.Go. loopclosure shows the same shape on a different bug: version-aware and correct about the version, and at go 1.21 reporting the go form of loop capture while missing the slice form, though both programs print c c c. A tool checks the shapes somebody wrote a check for.

Key Takeaways

  • Measured, of vet’s seven concurrency analyzers go test runs only atomic, and it found nothing in the corpus
  • Measured, one package: go test says ok, go vet reports three, go test -vet=all fails the build
  • The short default is correct — a vet finding blocks the build, so the automatic set cannot hold heuristics
  • Measured, staticcheck found SA5004 where vet was silent, and SA2001/SA2003 fire on a package vet passes
  • Measured, golangci-lint with no config runs govet's full set — and a narrower staticcheck set than running it directly
  • Measured, -race reports nothing on a package with no tests and one of sixteen with them; its reach is your coverage times the odds the window opens
  • Measured, both tools trace into a helper and both miss wg.Go and errgroup.Go
  • Measured, loopclosure is go.mod-version aware and shape-limited — a list of bugs expires with the language, a question does not
Section 18.2 — in one line

Seven analyzers ship, one runs, the line that fixes it is shorter than this sentence, and none of it touches the eleven.

Self-Check Questions: What the Toolchain Actually Runs

Your CI runs go build, go test -race ./... and golangci-lint. A copylocks bug reaches production. Which step should have caught it, and why did it not?

go test is the step that looks like it should have, and it is the one that structurally cannot: -copylocks is commented out of defaultVetFlags, so the vet pass go test performs never runs that analyzer.

The lint step may or may not have. Measured, golangci-lint with no configuration file does enable govet with its full analyzer set, and did report copylocks on this chapter’s corpus. But that enabled set is per-repository configuration rather than a default you can assume, and a .golangci.yml somebody added two years ago can narrow it.

The reliable answer is to stop depending on inference. Add go vet ./... as its own step, or switch the test step to go test -vet=all ./..., and then verify the way §18.2.4 suggests: plant the bug, push it, and watch CI go red. “The linter probably covers that” is a claim, and it takes five minutes to turn into a fact.

Why is it good that defaultVetFlags is short, given everything this section measured?

Because a vet finding during go test does not print a warning — it fails the build before a single test runs.

That makes the automatic set an extremely expensive place for a heuristic. copylocks can flag a deliberate copy; lostcancel can flag a cancel that is stored on a struct and called from Close. If either were on by default, one false positive would block an unrelated test run in somebody else’s package, and the pressure would immediately be to turn vet off entirely — which is a much worse outcome than the current one.

The conservative default is the right engineering decision. It simply is not a coverage decision, and reading it as one is how a team ends up believing six analyzers are running when none of them is.

A colleague on go 1.21 reviews your go 1.26 code and asks you to add u := u inside a loop. Who is right?

You are, for this repository, and they are right for theirs — which is §18.2.7's point.

Since Go 1.22, gated on the go directive in go.mod rather than on the installed toolchain, each iteration gets a fresh variable. Measured, the identical file prints c c c at go 1.21 and a b c at go 1.26, and go vet reports the capture in the first case and says nothing in the second. Adding u := u to the go 1.26 code is harmless and misleading: it implies a hazard the language removed, and Chapter 16 lists warning readers about it as a mistake in its own right.

The reviewable question is not “is the capture there” but “what does this module’s go directive say” — and that is worth asking once per repository rather than once per loop.

A reviewer says “the linters are green, so I only reviewed the logic.” What has been missed, structurally?

Everything in the two categories the linters cannot see, plus everything in the shapes they were not written for — and the second half is the one that surprises people.

The first half is §18.4's subject: protocol, lifetime and invariant are not in the syntax, so no amount of green says anything about them. Measured, that was eleven of sixteen.

The second half is §18.2.6's finding. Green does not even mean “none of the bugs the tools know about”. It means “none of the bugs the tools know about, in the shapes they check” — so a codebase that uses wg.Go, the idiom this book recommends, gets less coverage from the same green run than one that does not.

The structural point is that greenness is not a property of your code’s quality. It is the intersection of your code’s shapes with the shapes somebody wrote checks for, and a reviewer reasoning from green to “the mechanical bugs are handled” is making an inference the tools never offered.

18.3 The Reverse Index

This is the section to bookmark, and it is the one thing seventeen chapters could not give you.

18.3.1 Chapters Index by Mechanism; Incidents Arrive as Symptoms

A chapter is an index by mechanism. Chapter 10 is called Deadlocks and everything in it is a deadlock; that is what makes it teachable. It is also what makes it useless at 3 a.m., because at 3 a.m. you do not have a mechanism. You have a graph that is the wrong shape, and finding the mechanism is the entire problem.

So this section inverts the book. Rows read symptom → candidate mechanisms → where it was measured, and they are grouped by where you see the symptom, because that is what you have in front of you. Nothing here explains anything; every row points at the chapter that already did.

HOW TO ENTER THE BOOK

A chapter starts with a mechanism -- here is what a deadlock is -- and teaches you to recognise it next time. An incident starts with a symptom -- p99 tripled at 14:20 -- and what you need is candidates, ranked, with evidence. Section 18.3 is the arrow pointing the other way.

This is chapter 10's apparatus at book scale, and the difference is the whole point.

Chapter 10 already ships a Quick Reference with a Symptom → Cause table, a Deadlock Type Summary, Emergency Commands, a Goroutine State Reference, a Prevention Strategy Matrix, an Incident Response Workflow and a Code Review Checklist. If your symptom is “everything stopped”, go there — it is deeper than this section on that one failure mode, and it has the commands. Chapter 10 indexes one failure mode in depth; this indexes seventeen chapters' worth by symptom. You reach for this one first, precisely because you do not yet know it is a deadlock. The same is true of §18.7.11 against chapter 10's checklist.

18.3.2 Symptoms You See on a Dashboard

Symptom
Goroutine count climbs and never falls
Goroutine count climbs only under errors
Memory climbs with goroutine count flat
Memory climbs until the process is OOM-killed
p99 climbs while p50 stays flat
p50 and p99 both climb, CPU flat
Throughput plateaus below the configured limit
Throughput rises while completed useful work falls
One slow client makes every client slow
Utilisation low while the service reports overload
Error rate spikes and the downstream reports none
Latency spikes after a deploy, for one cooldown
CPU at 100% with throughput flat or falling
CPU flat and low while latency is high
Adding workers reduces throughput

18.3.3 Symptoms You See in a Log or a Crash

Symptom
fatal error: concurrent map writes
panic: send on closed channel
panic: close of closed channel
fatal error: all goroutines are asleep
Requests time out and the process looks healthy
The process exits and no handler ran
A 200 OK with a truncated body
Exit code 66 from a binary that “worked”
panic: sync: WaitGroup is reused
Shutdown always takes exactly the grace period
The same error forever, and a restart fixes it
Everything reports success and the outcome is wrong

18.3.4 Symptoms You See in Your Own Test Suite

Symptom
Passes locally, fails in CI
A test fails and names a different test
A test hangs and prints nothing until the timeout
A test passes 197 times in 200
Fails only under -race
Fails only at -cpu 1
Subtests see a torn-down fixture
A leak check is green locally and red on CI
A synctest bubble hangs instead of reporting
Green under -race, wrong in production

18.3.5 Symptoms You See in a Diff

The fourth entry point, and the one this chapter is building toward. These are not failures yet — they are shapes that predict a category, which is exactly what makes them reviewable.

What you see
go with nothing waiting in view
close in a function that also receives
Two Lock/Unlock pairs in one function
A ctx parameter unused in the body
A channel capacity that is a literal 1
err != nil handed to a policy component
A ctx stored in a struct field or a closure
Any call between Lock and Unlock
A second go added to a function that already had one
A bound inside a bound

Every row is visible without running anything, which is the only reason review is worth doing at all. And every row is a prediction rather than a verdict — each shape is correct in some programs, which is exactly why the third column is a question.

This table is §18.7 in compressed form.

If you take one thing from this chapter into a review, take this rather than the catalogue. Ten shapes, ten questions, all answerable by the author in a sentence. §18.7 turns it into an order to read things in; this is the lookup.

18.3.6 A Worked Narrowing

An alert fires: p99 on the checkout endpoint has gone from 80 ms to 2.1 s. p50 is unchanged at 40 ms. CPU is flat. The dependency’s dashboard shows normal error rate and normal latency.

Four facts, and the index turns them into two candidates.

p99 up, p50 flat is the queueing row: most requests are fine and a subset is waiting. Candidates are §17.5.1 — capacity spent as a latency budget — and §17.4.3's head-of-line blocking behind a large acquire.

CPU flat confirms it and adds one. The machine is not doing work; it is the “everything is parked” row, which points at the same two plus §17.3.2's limiter queue.

The dependency is healthy removes a whole branch. This is not a slow downstream. It is our own admission control.

Seventeen chapters narrowed to three sections, from four numbers, in about a minute. The next step is not in this chapter — it is Chapter 19's, and it is to look at where the parked goroutines are parked.

What the index did not do is tell you the answer. It told you which three sections to re-read and which twelve chapters to stop thinking about, which at 3 a.m. is most of the value available.

The fact that removes a branch is worth as much as the one that adds a candidate.

“The dependency is healthy” felt like an absence of information and did most of the work. When you use these tables, note the rows your symptoms do not match: a goroutine count that is flat rules out five leak shapes at once, and ruling out is faster than ruling in.

18.3.7 Using It Without Fooling Yourself

Three rules, because an index like this is easy to misuse.

A candidate is a hypothesis, not a diagnosis — so read the whole row before testing any of it. Every row lists more than one mechanism deliberately, ordered by how often each turns out to be the cause rather than by severity, and a row with four candidates is telling you the symptom is genuinely ambiguous. The failure mode of symptom-driven debugging is latching onto the first plausible entry, which is usually the one you debugged most recently. Confirming which candidate it actually is belongs to Chapter 19, and this section hands over there rather than guessing.

Symptoms compose, and the first one you fix hides the rest. A service can have a leak and a misclassifying breaker, and the breaker’s refusals will mask the leak’s latency until the breaker is fixed. When a symptom half-improves after a change, that is evidence of a second mechanism rather than of a bad fix. Re-enter the index with the residual symptom rather than the original one.

Then go to the chapter, not to the code. Every candidate here has a section that measured it, and that section tells you what the mechanism looks like in source, which is what you need to search for. Reading your own code for a bug you have not characterised is how afternoons disappear.

The index is only as good as your instrumentation.

Every row starts with something you can see. If you do not export goroutine count, queue depth, and the split between p50 and p99, then the first four rows of §18.3.2 are unavailable and the index degrades to guessing. That is the practical argument for §17.7.5's four numbers, arriving one chapter later as a debugging cost rather than an operational one.

18.3.8 Extending It for Your Own System

These tables index this book. The version worth having indexes your service, and it is built the way chapter 10's table was: from incidents you already had.

The procedure is three lines per incident, written at the end of the postmortem while the details are still true.

Two properties separate a table people use from one they do not.

It is indexed by the first symptom, not the root cause. The temptation after a postmortem is to file it under the mechanism, because that is what you now understand. Nobody searches by mechanism at 3 a.m.; they search by the graph in front of them, and an index filed under the answer can only be searched by somebody who already has it.

It contains your own systems' symptoms. “Queue depth on the ingest topic climbs while consumer lag is flat” is worth ten generic rows, because it names things your team can see on a dashboard they already have open.

18.3.9 Common Mistakes

Indexing your notes by mechanism only
Problem

Useless when all you have is a symptom

Fix

Keep a reverse index; this is one

Diagnosing from the mechanism you know best
Problem

Every incident looks like a mutex problem

Fix

Start from the symptom and let the row narrow it

Latching onto the first candidate
Problem

The most recently debugged bug wins

Fix

Read the whole row first

Fixing one mechanism and declaring victory
Problem

The symptom half-improves and returns

Fix

Compounding failures mask each other

Treating a flat CPU graph as “not concurrency”
Problem

The bug is blocking, not computing

Fix

§18.3.2's “everything is parked” row

Treating a goroutine curve as proof of a leak
Problem

Flat goroutines with climbing memory is a different bug

Fix

§18.3.2's third row

Reaching for the index with no metrics
Problem

Every row needs an observable

Fix

§17.7.5's four numbers

Using this instead of chapter 10 for a wedged process
Problem

Less depth for the one case that has depth available

Fix

ch10's Quick Reference, then here

Summary: The Reverse Index

Chapters index by mechanism because that is how you learn a mechanism. Incidents arrive as symptoms, which is the opposite direction, and no single chapter can invert the book because no chapter sees the others.

This section is that inversion, grouped by where you see the symptom: a dashboard, a log or a crash, your own test suite, and a diff. The fourth group is the one this chapter is building toward — ten shapes that predict a category, each with the question that settles it.

Nothing here re-derives anything, which is what keeps it usable: a reverse index that explains is a chapter, and a chapter is what you already have. It is not Chapter 10's table either. That goes deeper on one failure mode and should be your first stop for a wedged process; this is shallower across seventeen chapters. And two habits make it a procedure rather than a lookup: read the whole candidate row before testing any of it, and go to the chapter before you go to your own code.

Key Takeaways

  • A chapter starts with a mechanism; an incident starts with a symptom, and finding the mechanism is the whole problem
  • Rows are grouped by where you see the symptom, and every row points rather than explains
  • Goroutines and memory are two curves; which one moves narrows the mechanism sharply
  • p99 alone means queueing; p50 and p99 together with flat CPU means contention
  • A clean downstream dashboard next to your own error spike is near-conclusive for §17.6.5
  • More than one candidate per row is deliberate: the index narrows the search, Chapter 19 closes it
  • The fact that rules a branch out is worth as much as the one that adds a candidate
  • A half-improvement after a fix is evidence of a second mechanism, not of a bad fix
  • A symptom you cannot observe is a row you cannot use, which is what §17.7.5's four numbers are for
Section 18.3 — in one line

Seventeen chapters answer “what does this mechanism do”; this answers “what could this symptom be”.

Self-Check Questions: The Reverse Index

Goroutine count is flat, memory is climbing steadily, and latency is unchanged. Which rows can you rule out, and what does that leave?

Flat goroutine count rules out the entire first row of §18.3.2 — every leak shape, because a leaked goroutine is itself a goroutine and would show up in the count. Unchanged latency rules out the queueing row, because capacity spent as latency shows up as p99 moving while p50 does not.

What is left is memory growth with a constant number of goroutines: §18.3.2's third row, something that is retaining rather than spawning. A buffered channel accumulating entries nobody drains (§5.3), a keyed map with no eviction (§17.7.2), a slice retained by a subslice. The keyed-limiter map is the one to check first if the key space is chosen by callers, because §17.7.2 measured that an IP-keyed map is bounded by the internet.

The reason this matters more than it looks is that the two hypotheses have different fixes and the leak fix does not help. Adding a cancel to a system whose problem is an unevicted map produces no change and considerable confusion — and the natural next step, “the fix did not work, so it must be a subtler leak”, sends you further in the wrong direction.

Note also how much work the absence of two symptoms did. Ruling out is faster than confirming, which is why the numbers are worth exporting before you need them.

Your service reports a spike in errors and the downstream’s dashboard is clean. What does the index suggest, and why is the clean dashboard the strongest clue?

Caller cancellation counted as a downstream failure — §17.6.5 — and the clean dashboard is what makes it near-conclusive rather than one candidate among several.

The logic is elimination. If the downstream were failing, the downstream would know: it serves the request, it returns the status, it counts it. A disagreement between your error rate and their error rate means the errors are being generated on your side of the boundary, and there are only a few ways that happens. The commonest by a distance is a predicate that treats ctx.Err() as evidence about the callee.

The corroborating signal is timing. §17.6.5's failure arrives in a burst tied to something that cancels many contexts at once — a deploy, a client-side timeout change, a mobile network event — and then persists for exactly one breaker cooldown after the cause has stopped. A downstream fault does not have that shape.

The check is one line: log the classification decision, and see whether the errors you are counting have a non-nil ctx.Err() at the moment you count them.

You fix the misclassifying breaker from §17.6.5 and the error rate improves but does not return to baseline. What should you conclude?

That there is a second mechanism, and that the breaker was hiding it.

A breaker that opens against a healthy downstream refuses everything behind it — which means every other failure mode downstream of the breaker was invisible while it was open. Fixing the classification lets real traffic through again, and real traffic finds the real bug.

A half-improvement is therefore evidence rather than disappointment. The wrong conclusion — that the classification fix was partial or incorrect — sends you back to code that is now right. Re-enter the index with the residual symptom.

Why does this section refuse to explain any of the mechanisms it lists?

Because an index that explains is a chapter, and the book already has seventeen of them.

The practical reason is length, and it is not a small effect. There are roughly fifty candidate mechanisms across these tables. A paragraph each — less than any of them got where they were taught — is several thousand words, and the result would be strictly worse than what it duplicates: shorter than Chapter 13's treatment of context cancellation and therefore less useful to anyone who actually has that bug.

The deeper reason is that the two documents have different jobs. This one has to be scannable under pressure, by somebody who is not in a reading mood, looking for a shape that matches what their graph is doing. Prose defeats that. A row naming four candidates and four section numbers can be read in five seconds and acted on.

That is also why the entries are terse to the point of being cryptic if you have not read the chapter. They are memory aids, not explanations, and their audience is somebody who has read the book once and needs to find their way back to the right page.

18.4 What Only a Reader Catches

Eleven of sixteen. This section is about why, and the answer is more useful than a list of eleven bugs, because it is a taxonomy that applies to code this chapter never mentions.

18.4.1 Three Reasons a Bug Is Invisible

A tool reasons about your program. It does not reason about your intent. Every bug the tools missed violates something you meant and never wrote down, and there are three kinds of thing you can mean.

WHY NO TOOL SEES IT

The three reasons a concurrency bug is invisible to every tool. A protocol rule — who closes, who sends, who may call twice — lives in two files while the analyzer reads one. A lifetime rule — who waits, whose context, what happens on the early return — spans a function boundary the tool cannot cross. An invariant — one grant per unit, the balance never negative — is a fact about your domain and appears nowhere in the types. A tool proves things about memory and syntax, and none of the three is a fact about either.

The categories are predictive rather than descriptive. Given an unfamiliar piece of concurrent code, deciding which of the three applies tells you what question to ask next — and the question, not the bug, is what §18.7 turns into a procedure.

18.4.2 A Property of a Protocol

A protocol is a rule shared between two pieces of code and held by neither. The canonical one is Chapter 3's, and Chapter 3's exercise is named after it: Who Closes?

A channel’s type says what flows through it. It says nothing about who may close it, how many senders there are, or whether a second close is possible. That information exists — the program does not work without it — but it lives in the programmer’s head, in a comment if you are lucky, and in the review if you are disciplined.

produce_184.go
// Illustrative snippet — not a complete program
// producer.go -- legal, and correct in isolation
func produce(out chan<- int) {
    defer close(out) // the sender closes. Correct.
    for i := range 10 {
        out <- i
    }
}
consume_184.go
// Illustrative snippet — not a complete program
// consumer.go -- also legal, also correct in isolation
func consume(in chan int) {
    for v := range in {
        if v > 5 {
            break // done early
        }
    }
    close(in) // "tidying up". Not correct.
}

No tool can see it, because the rule is in neither file. The sender’s file contains a close. The receiver’s file contains a close. Both are legal Go; the illegality is that there are two of them, in files the analyzer examines separately, and nothing in the type chan int records who owns the closing.

Notice which symptom you get. If consume closes first, the producer’s next send panics with send on closed channel. If the producer finishes first, consume's close panics with close of closed channel. If the producer happens to be between sends when the receiver breaks out, nothing happens at all and the code ships. One rule, three outcomes, chosen by the scheduler — which is §16.1's opening problem arriving in production code rather than in a test.

The review question: who else touches this, and what does each of them assume? If answering it requires opening a second file, you have found a protocol, and the rule is worth a comment at the point where it is easiest to break rather than where it is defined.

Other protocols the book has already taught:
Protocol
Channel closing
Directional types
Multi-sender fan-in
sync.Once
Release pairing
Reservation

Here is the change that breaks one, and it is a five-line addition to a function nobody otherwise touched.

collect_184.go
// Illustrative snippet — not a complete program
func Collect(ids []string) <-chan Result {
    out := make(chan Result)
    go func() {
        defer close(out)
        for _, id := range ids {
            out <- fetch(id)
        }
    }()

    // Everything below is what the diff added. Nothing above
    // it was touched.
    go func() {
        for r := range retries() {
            out <- r // a second sender
        }
    }()

    return out
}

The close is untouched — still deferred, still on the goroutine that used to be the only sender. It now fires while the second goroutine is still running, and the next out <- r panics; or, if the timing goes the other way, the second sender blocks forever after the receiver has stopped.

A reviewer looking at the close sees nothing wrong, because nothing about the close changed.

The close is not where the bug is.

Almost every ownership bug is found by looking at the senders, not at the close. The close was correct when it was written; something later added a second path to the same channel. So when a diff adds a go that sends, the question is not “is this close right” but “is this close still right” — and the finding is at the lines that were added, a dozen lines away from the code that became wrong.

18.4.3 A Property of a Lifetime

go f() starts a goroutine and records nothing about how it ends. §2.1's Four Questions exist because the language provides no place to state the answers.

The bugs are all one question unanswered:

A tool sees go func() { ... }() and has no way to know whether the absence of a join is a bug or the design. §2.5's fire-and-forget section exists precisely because sometimes it is the design. The one partial exception, since Go 1.27, is after the fact: the goroutineleak profile can prove that a parked goroutine will never wake, which is a lifetime fact — but only once it is parked, and only for parks on a sync primitive.

go vet's waitgroup analyzer marks the edge of what is achievable here, and two cases side by side show exactly where the edge is.

add_inside_184.go
// Illustrative snippet — not a complete program
func addInsideTheGoroutine(wg *sync.WaitGroup) {
    // vet reports this: WaitGroup.Add called from inside new
    // goroutine
    go func() {
        wg.Add(1)
        defer wg.Done()
        work()
    }()
}
done_on_184.go
// Illustrative snippet — not a complete program
func doneOnOnePathOnly(wg *sync.WaitGroup) {
    // vet says nothing about this
    wg.Add(1)
    go func() {
        if !ready() {
            return // wg.Done never runs
        }
        work()
        wg.Done()
    }()
}

The first is a local shape: the Add is textually inside the go func, and that ordering is wrong in every program, so an analyzer can decide it alone. The second is wrong only if ready() can return false — a fact about a function in another file, on a path that may never be taken during analysis. Same package, same WaitGroup, and only one of them is decidable.

The review question: if this function returns right now, what is still running, and who is waiting for it? Ask it at the go statement and again at every return between there and the end.

“On which paths” is where the bugs are.

Path
Happy path
Early return on a validation failure
Error branch
Panic
Context cancelled

Every row but the first is a place where the author’s attention was on something else — the same mechanism §18.1.5 described for reviewers. That is not a coincidence: the paths that skip the join are precisely the paths that are about something other than the goroutine.

One lifetime fact is worth stating because it surprises people who have done everything right. §4.5 established that select chooses uniformly among ready cases, and §13.5 that a cancelled context makes Done() ready forever. Put them in one worker loop and both cases are ready on every iteration after cancellation, so the loop exits after a geometric number of turns rather than at once.

Measured 2,000 trials, a full work channel, cancelled before the loop starts.
Terminal
units of work done AFTER cancel():
  median 0-1 p99 6 max 13 mean 1.0

Whether that matters is a property of the work. If a unit is idempotent and cheap, the loop is correct as written. If it charges a card or sends an email, “we cancelled and it did six more” is a defect that will never reproduce on demand, because it is a coin-flip tail. Where it matters, §13.5.4's shape is the fix: check ctx.Err() unconditionally before the select, so cancellation wins outright rather than competing.

18.4.4 A Property of an Invariant

This is the category §16.3.4 promised this chapter would cover, and the promise was specific: race conditions with no data race are found by tests written against an invariant rather than an access pattern, and by code review. Not by -race.

Here is why, stated as sharply as it goes: the detector verifies that your synchronisation is present. It cannot verify that it is sufficient.

The cold open’s Take is the canonical shape. The invariant is one sentence and it appears nowhere in the code: across any number of concurrent callers, Take returns true at most quota times.

Measured a quota of one, eight concurrent callers, 200 rounds per run.
Terminal
plain build granted twice in 0, 0, 0, 0, 0 of 200
under -race granted twice in 3, 3, 6, 8, 9 of 200
DATA RACE reports, either way 0

Three things in that table are worth sitting with.

The detector reported nothing, across every run, and it was right to: s.quota is read and written only under s.mu, so every pair of accesses is ordered by a real synchronisation edge. A WARNING: DATA RACE here would have been a false positive.

The plain build found nothing either — not once in a thousand rounds, and not at sixty-four callers when the same harness was rerun with more contention. The window between Unlock() and the next Lock() is a few nanoseconds wide, and the mutex itself serialises the callers that would have to hit it.

And under -race the bug appears every run. That is §16.3.2's finding in its most useful form: a race build randomises the scheduler, so it is a partial scheduling fuzzer as well as a detector. -race is the only tool that makes this bug visible, and it does so by perturbing rather than by detecting, and it reports nothing about what it surfaced.

CHECK, THEN ACT

A timeline of two callers overdrawing a quota of one. Caller A locks, reads that the quota is greater than zero, and unlocks. Caller B does the same and also sees one, so both pass the check. Each then locks, decrements and unlocks, taking the quota to zero and then to minus one. Every access was under the mutex, so there is no data race to report, and yet a quota of one was granted twice. The lock protected the variable; nothing protected the decision.

The tell in the source is structural and you can train yourself to see it: two critical sections where there should be one, with a decision carried between them. Unlock followed by an if that depends on what was read under the lock is the signature.

The review question: what is true across callers that is not true of any one of them? Write the sentence down. If it names more than one field, or names one field at two moments in time, one lock per access will not hold it.

The category is broader than check-then-act, and three shapes cover most of it.

Check-then-act, above: read state, decide, write, with the lock released in between.

Lost update: read a value, compute from it, write it back — the same shape with the if implicit. n := c.Load(); c.Store(n+1) is check-then-act without a check, and §11.1.3's Add exists so that you do not write it. §18.6.7 measures the version of this that gets past review because the type’s name says it is safe.

Cross-object invariants: two objects, each internally consistent, with a rule that spans them. §11.3.1 measured this — two atomics, one invariant — and the fix is the same every time: the invariant needs one owner, and the owner needs one critical section.

Other invariants the book has already broken on purpose:
Invariant
The balance never goes negative
Initialisation has succeeded before it is used
One grant per unit of quota
Exactly one probe in half-open
The count equals the number of increments
The one category with no tool at all.

Protocol bugs can be partly typed away (§6.4). Lifetime bugs are caught after the fact by goleak, a synctest bubble, or the goroutineleak profile (§16.5.9). Domain invariants have neither, because the invariant is a fact about your problem rather than about Go, so nothing outside your head knows what it is. This is the category that makes code review structurally necessary rather than merely useful.

18.4.5 Telling Which Category You Are In

The categories are only useful if you can classify unfamiliar code quickly, so here is the decision. It takes about ten seconds.

WHICH CATEGORY IS THIS?

A three-question decision procedure for classifying an unfamiliar piece of concurrent code. If the rule involves code in another file it is a protocol, and the action is to read that other file. If the rule involves a moment in time it is a lifetime, and the action is to read every return between here and the end of the function. If the rule involves a word from your own domain — quota, balance, exactly once, at most N — it is an invariant, and the action is to write the sentence down. If none of the three applies it is probably a tool’s job, so run go vet and move on.

The last branch matters as much as the first three. If a shape is wrong in every program regardless of context — a mutex copied by value, a discarded cancel, an unbuffered signal channel — then it is exactly the kind of thing an analyzer decides, and a reviewer spending attention there is spending it in the one place it is not needed. §18.2's whole argument is that those tools should be running; the corollary is that once they are, you can stop looking for what they find.

Two refinements from practice.

A change can touch two categories, and the fix differs by which you pick. §18.6.5's happy-path buffer is a lifetime bug if you frame it as “these goroutines never exit” and a protocol bug if you frame it as “the sender and the receiver disagree about how many sends there are”. Both framings find it. The lifetime framing produces len(replicas); the protocol framing produces a comment explaining the capacity, which is arguably the better fix because it survives somebody adding a replica.

When you cannot classify it, that is the finding. Code you cannot place in one of the three is usually code whose rule has not been decided yet — and asking the author which rule they meant is a better review comment than any of the specific questions, because it is the question they have not answered themselves.

18.4.6 Making the Invisible Fact Visible

The three categories share a cause: a fact the source does not state. That suggests the constructive move, and it is the one thing in this chapter that reduces the review burden rather than adding to it — write the fact down where the compiler or a test can hold you to it.

Each category has a different best answer.

Protocol → a type. §6.4's argument, arriving with a new justification. A function that takes chan<- T cannot close it, because the compiler says so; a function that takes <-chan T cannot send. Most ownership bugs become compile errors the moment the direction is written down at the boundary. This is the only one of the three where the fact converts cleanly into something mechanical.

Lifetime → a test. There is no type for “this goroutine is joined”. There is a test: §16.5's goleak.VerifyNone, or a synctest bubble whose deadlock panic names the line that started the leak. Neither proves the design; both catch the regression, which is what you actually need once the review has happened one time.

Invariant → an assertion, in a test that varies the schedule. §16.7.4's randomised model check is the general form: run random operations against an obviously-correct sequential reference and compare. The exercise’s gate 2 is the small version — 200 rounds of concurrent Take against a stated limit — and it is the shape to copy, because it asserts the invariant rather than the access pattern.

Category
Protocol
Lifetime
Invariant

Notice what this does to the economics of a review. A finding you fix is worth one bug. A finding you fix and convert into a type or a test is worth every future instance — and the conversion is usually smaller than the discussion about whether the bug was real.

The best review comment asks for the test, not the fix.

“Should out be buffered?” gets one bug fixed. “Is there a test that fails without the buffer?” gets the bug fixed and the next one caught by CI. §18.7.6 lists this as one of the three comments always worth making, and this is why.

18.4.7 Common Mistakes

Reviewing for a list of known bugs
Problem

You find the ones you memorised

Fix

Ask the three questions; they generalise

Trusting -race on check-then-act
Problem

Clean run, wrong answer

Fix

Measured, 3–9 of 200 rounds and zero race reports

Asserting -race failures mean a data race
Problem

It perturbs schedules as well as reporting races

Fix

Measured, 0/200 plain against 3–9/200 under -race

Locking each access and calling it thread-safe
Problem

Two critical sections that had to be one

Fix

Write the invariant as a sentence first

Fixing check-then-act with a bigger lock
Problem

The window moves; it does not close

Fix

Decide and commit in one critical section

Reviewing a channel without opening the other file
Problem

The closing rule lives in neither file alone

Fix

A protocol needs both participants in view

Checking that the happy path joins
Problem

The early return is where it leaks

Fix

“On which paths” is the whole question

Assuming an unjoined goroutine is a bug
Problem

Fire-and-forget is sometimes the design (§2.5)

Fix

The bug is an unanswered question, not an absent join

Reading the close to find an ownership bug
Problem

The close did not change; a sender was added

Fix

Ask whether the close is still right

Summary: What Only a Reader Catches

The eleven bugs no tool caught are invisible for exactly three reasons: they are properties of a protocol (a rule shared between files and held by neither), a lifetime (a relationship across a call boundary the analyzer cannot cross), or a domain invariant (a claim about your problem that appears nowhere in the types).

The invariant category is the sharpest case and the one §16.3.4 sent here. Measured, a quota of one granted twice in 3 to 9 rounds of 200 under a race detector that reported nothing at all — and in zero rounds of a thousand without one, because a race build randomises the scheduler. The tool that could not report the bug is the only tool that surfaced it.

Each category yields a question, and the question is what survives a language change: §18.2.7 measured a bug that one line in go.mod removes from the language, and every checklist naming it is now wrong for half its readers. Then there is a constructive move that most reviews skip — write the missing fact down where a compiler or a test can hold you to it. Protocol becomes a directional type, lifetime becomes a leak test, invariant becomes an assertion under concurrency.

Key Takeaways

  • Tools prove things about syntax and memory; protocols, lifetimes and invariants are none of those
  • A protocol’s rule lives in two files and the analyzer reads one — send-on-closed and receiver-closes are one rule with two symptoms, and which you get is the scheduler’s choice
  • Measured, the check-then-act quota broke in 3–9 rounds of 200 under -race and 0 of 1,000 without it, with zero race reports either way
  • -race makes this class findable and remains unable to diagnose it — §16.3.2's fuzzer doing the useful half of its job
  • Lifetime bugs live on early returns, not the happy path — read the return statements first
  • waitgroup catches Add inside a goroutine because that is local; Done placement depends on a caller it cannot see
  • Almost every ownership bug is found at the senders; the close was right when it was written
  • The deliverable is three questions rather than a list, because a list expires with the language
  • A finding converted into a type, a leak test or an invariant assertion is worth every future instance of it
Section 18.4 — in one line

The tools check what your program says; you check what you meant.

Self-Check Questions: What Only a Reader Catches

A colleague says the Take measurement proves the race detector is unreliable. Correct them.

The detector was entirely reliable and reported exactly what it is defined to report: nothing, because there is nothing. s.quota is read and written only while s.mu is held, so every pair of accesses is ordered by a real synchronisation edge. A WARNING: DATA RACE here would have been a false positive.

The bug is a race condition — §8.2's distinction, and §16.3.4's measurement. The invariant “at most quota grants” is a fact about the program’s meaning, and no analysis of memory ordering can derive it, because the identical memory operations would be perfectly correct in a program whose invariant was different.

Worth adding, because it inverts the complaint entirely: the detector made this bug findable rather than harder to find. Measured, it broke in 3 to 9 rounds of 200 under -race and in zero rounds of a thousand without it, because a race build randomises the scheduler (§16.3.2). The tool that could not report it is the only one that surfaced it.

You are reviewing a package that sends on a channel from three places and closes it in one. What category is this, and what do you need to see before approving it?

A protocol, and you need the fourth file.

Three senders and one closer is not wrong by itself. It is correct when the closer is the last sender, when a WaitGroup orders the close after all three, or when a coordinator provably runs after them. It is wrong when any sender can still be running at the moment of the close — and nothing in the three sending files tells you which situation you are in.

So the reviewable question is §18.4.2's: who else touches this, and what does each of them assume? Concretely, find the code that orders the close against the last send. If you cannot find it, that is the finding — not “this might panic”, but “there is no code here that makes the close safe, and the rule is currently a fact about timing”.

The durable fix is a comment at the close, not at the declaration. The declaration is not where anyone breaks it.

Which of the three categories does adding a second producer to an existing channel touch, and what do you ask?

Protocol, and the question is “is this close still right?” rather than “is this close right?”

With one producer the producer usually closes, and that is correct. With two, neither may — whichever finishes first will close the channel out from under the other, and you get a send on closed channel panic or a permanently parked sender depending on the scheduling. §7.3's fan-in needs a coordinator that closes after both are done.

What makes this worth its own question is where the finding lives. The close line did not change and reads as correct in isolation, so a reviewer who checks the close finds nothing. The finding is at the lines that added the sender, several lines away from the code that became wrong.

You find an ownership bug in review. What is worth doing beyond fixing it?

Convert the fact into a type.

§18.4.6: a parameter declared chan<- T cannot be closed and <-chan T cannot be sent on, so the protocol becomes a compile error rather than something the next reviewer has to notice. A fixed bug is worth one bug; a fixed bug plus the type is worth every future instance, and §6.4 already argued for the same change on API-design grounds.

The general form is the one that changes the economics of reviewing. Protocol facts go into types, lifetime facts go into a leak test, and invariant facts go into an assertion that runs under concurrency. Each conversion is usually smaller than the thread arguing about whether the bug was real, and each one is the difference between finding this bug and not having it again.

18.5 The Misclassification Family

Four chapters end on the same bug wearing four costumes. No one of them could say so, because each saw only its own costume.

18.5.1 One Shape, Four Chapters

Chapter 17 noticed the family and named three of its members: “That is the shape §14.1 named and §15.4 met again at the process boundary, arriving here a third time.” It could not do more than notice, because a chapter about rate limiting cannot spend its length on chapters 14 and 15. This section finishes the job.

The shape is one sentence: the system decides what a thing means, decides wrong, and every layer that has a dashboard reports success.

Where it appears
Error handling
Graceful shutdown
Circuit breaking
Rate limiting
Load shedding

Read the middle column downward. Every row is a value filed under the wrong heading — and in every case the wrong heading is the one that looks like success, or like somebody else’s fault.

§14.1.1 — the go keyword severs the error chain. A goroutine’s return value goes nowhere. The failure mode of concurrent error handling is not a wrong error; it is silence, and silence is indistinguishable from success at every layer above it.

§15.4.4 — a 200 OK with a truncated body. Cancel the base context alongside Shutdown and a handler returns politely on ctx.Done(). net/http computes a Content-Length for what was actually written, or terminates the chunk stream correctly, so the client reads a 200 OK to completion with no error of any kind and stores a truncated result. The sting is that handling cancellation correctly is what makes the truncation invisible — a handler that panics with http.ErrAbortHandler breaks the connection mid-message, and the client at least sees an unexpected EOF, which is true.

§17.3.4 — reservations abandoned without Cancel. Measured there: ten abandoned reservations left the limiter ten tokens in debt and cost the next caller 110 ms against 10 ms. The limiter has recorded ten requests as sent. Nothing was sent.

§17.6.5 — caller impatience recorded as downstream failure. Measured there: against a downstream that never failed once, a predicate of err != nil cost 200 of 200 requests in the second wave — a full outage caused by a healthy dependency and impatient callers.

§17.4.3 belongs here too, in a quieter register. Measured there: capacity 8, six units free, one goroutine queued for eight — and TryAcquire(1) returns false. The service sheds while the resource sits three-quarters idle, and Weighted exposes no waiter count that would let anyone notice.

18.5.2 The Shape

Stated once, so that it is recognisable in a costume this book has not shown you:

A value crosses a boundary, loses the context that gave it meaning, and is interpreted by whatever is on the other side.

An error on your side of a call says “this attempt did not produce a result”. By the time it reaches the breaker it has been read as “the downstream is unhealthy”. Nothing was corrupted; the value simply travelled further than its meaning did.

WHERE THE MEANING IS LOST

On the caller’s side of a call there are three distinguishable facts: our caller went away, we refused this ourselves, and they returned a 503. All three cross the boundary as one bit, err != nil, and are read on the other side as “they failed”. The predicate belongs on the left, while the three facts can still be told apart; downstream of the boundary they are gone and cannot be recovered.

Each member is a classification error rather than a mechanical one. Nothing malfunctioned. A cancelled call was classified as a failure; an abandoned reservation was classified as a request; a severed error was classified as no error. The mechanism did precisely what it was told.

That is why the fix in every one of those chapters is a predicate at the boundary rather than better handling downstream of it. §17.6.5's is the sharpest, and it is worth re-reading for its structure rather than its content: two clauses, in an order that matters, and the second tests ctx rather than err — because the question is “was our caller already gone?” and not “does this error look like a cancellation?”.

It is also a family that will be larger in your codebase next year than it is today, and the reason is structural. Every member lives at a point where a deadline meets a counter. Ten years ago most Go services had neither: no request context, no breaker, no per-tenant limiter, no retry budget. Chapters 13 through 17 are the story of adding them, and each addition creates another place where “this call ended early” has to be classified. §14.1.1's silence needed only goroutines. §15.4.4 needed a deadline. §17.6.5 needed a deadline and a counter that sets policy from it.

18.5.3 Why Monitoring Cannot Find It

The unifying property, and the reason this is a review problem rather than an observability one:

Every member reports success at every layer that has a dashboard.

The truncated 200 OK is a 200 in your metrics and a 200 in the client’s. The breaker that opens against a healthy downstream shows a falling error rate at the dependency — because it stopped sending — and rising refusals at a layer doing exactly what it was configured to do. The abandoned reservation shows a limiter enforcing its configured rate; the rate is simply lower than the number in the config, and no metric compares those two.

You cannot alert on it, because the alert would have to know the right classification, which is the thing that was got wrong.

The one signal that does cross-cut.

There is a single instrument that catches most of the family, and §17.7.5 already asked for it: compare what you promised with what you delivered, at the same layer. Configured rate against achieved rate. Requests accepted against requests completed. Responses sent against bytes the handler intended to write. Each is a subtraction, each sits at zero when things are healthy, and each becomes non-zero for exactly the reason this family exists. None of them is a standard dashboard panel, which is why the family survives.

18.5.4 The Rate-Limiting Group

Three of the five members come from one chapter, and that is not a coincidence: a limiter’s whole job is to classify arrivals, so a limiter is a machine for misfiling things.

Read the three together and Chapter 17's pattern becomes visible. Every one is a boolean or an error that means one thing on the limiter’s side and another on the caller’s. false means “not now” and is read as “not possible”. A reservation means “held” and is read as “spent”. An error means “this attempt stopped” and is read as “they are broken”.

The consequence differs by which direction the misreading runs. §17.6.5's turns a healthy dependency into an outage you will investigate. §17.4.3's sheds load while the resource sits idle, and nothing on a dashboard disagrees because Weighted exposes no waiter count. §17.3.4's is the quietest of the three: the service enforces a rate below the one in its config, most severely on exactly the paths where callers abandon most often, and no metric compares the two numbers.

18.5.5 Finding One in Review

The question is short enough to apply while reading. Here is what it looks like on a line you would otherwise skim.

client_185.go
// Illustrative snippet — not a complete program
func (c *Client) call(
    ctx context.Context, req *Request,
) (*Resp, error) {
    resp, err := c.downstream.Do(req.WithContext(ctx))
    if err != nil {
        c.breaker.Record(err)
        c.metrics.DownstreamErrors.Inc()
        return nil, err
    }
    return resp, nil
}

Nothing here is wrong on its face. The error is recorded, counted and returned. A reviewer reading for the feature sees error handling and moves on.

Apply the question — does this distinguish “our caller gave up” from “the dependency failed”? — and two lines are implicated rather than one. Record(err) is §17.6.5 exactly. And DownstreamErrors.Inc() is the same bug in the metric, which is worse in a specific way: the breaker’s version causes an outage you will investigate, and the metric’s version causes a dashboard that lies during the investigation.

The comment to leave.

“If our caller cancels, ctx.Err() is set and Do returns an error that says nothing about the downstream — should both of these be behind if ctx.Err() == nil?”

The reason to name both lines is that fixing only the breaker leaves the dashboard wrong, and a wrong dashboard is how the next incident gets misdiagnosed. This family propagates: one misclassification at the source shows up in every derived number.

The costume changes and the question does not. A retry policy that retries on any error will retry your own rejections. A cache that caches any result will cache a cancellation. A health check that counts any failure will report an outage caused by its own timeout. Each is this family, and each is found by asking one question at one boundary.

18.5.6 Making the Classification Visible

Review is the primary defence. There are two cheap second lines, and they attack the same thing from different sides: the classification is what to record, and the classification is what to test.

Pull the predicate out of the call site and it stops being an if buried in a method. It becomes a pure function of an outcome, and a pure function can have a test.

classify_185.go
// Illustrative snippet — not a complete program
// Now it is a function, and a function can have a table test.
func classify(ctx context.Context, err error) Outcome {
    switch {
    case err == nil:
        return Success
    case ctx.Err() != nil:
        return CallerGaveUp    // not the downstream's fault
    case errors.Is(err, ErrOverloaded):
        return Shed
    default:
        return DownstreamFailure
    }
}
tests_185.go
// Illustrative snippet — not a complete program
tests := []struct {
    name string
    ctx  context.Context
    err  error
    want Outcome
}{
    {"caller cancelled", cancelled, context.Canceled,
        CallerGaveUp},
    {"deadline while calling", expired,
        context.DeadlineExceeded, CallerGaveUp},
    {"downstream refused", live, ErrOverloaded, Shed},
}

No goroutines, no timing, no flakes, and it runs in microseconds. What makes it worth stating separately is the order of the two artefacts: extracting the predicate is what makes the test possible, and it is also what makes the missing row visible in review. A table has rows, rows can be missing, and a missing row is something a second reader can notice without reconstructing the author’s reasoning. That is the property the if lacks — to review a condition you have to independently derive the set of outcomes it should cover, which is the same work as writing it.

Then record the decision, not just the outcome:

client_185_2.go
// Illustrative snippet — not a complete program
func (c *Client) classify(ctx context.Context, err error) error {
    if ctx.Err() != nil {
        c.metrics.CallerAbandoned.Inc()  // our caller left
        return ctx.Err()
    }
    if err != nil {
        c.metrics.DownstreamErrors.Inc() // the dependency failed
        c.breaker.Record(err)
    }
    return err
}

Two counters instead of one, and the second is now true to its name. The moment CallerAbandoned is a number on a dashboard, §17.6.5's outage becomes a five-second diagnosis rather than an afternoon: the breaker opened, and right beside it is a graph showing that every “failure” was somebody hanging up. The same is true in the log — err=context.Canceled classified=downstream_failure turns a two-day mystery into a search, where recording only err=context.Canceled leaves the decision, which is the actual bug, invisible.

The general rule: when code makes a classification, the classification is the thing to export. Exporting only the outcome throws away exactly the fact you will need, and the family’s defining property is that the outcome looks fine.

This does not replace the review question. It shortens the incident when the question was not asked, which is the realistic case.

18.5.7 Common Mistakes

Record(err) with any non-nil error
Problem

A breaker opens against a healthy downstream

Fix

Measured, 200 of 200 shed (§17.6.5); test ctx.Err()

Classifying on the error’s shape
Problem

A real DeadlineExceeded from the callee gets excluded too

Fix

Ask whose deadline it was, not what the error is

Retrying on any error
Problem

Retries fired for your own rejections

Fix

The classification table decides retry too

An early return that does not give back
Problem

The achieved rate drifts below the configured one

Fix

Measured, 110 ms against 10 ms (§17.3.4)

Reading a refusal as load shedding
Problem

Requests shed while the resource is idle

Fix

Measured, six of eight units free (§17.4.3)

Trusting a green dependency graph
Problem

You search everywhere except the caller

Fix

The family’s defining property is that it reports success

Reading a falling error rate as recovery
Problem

The breaker stopped sending; nothing recovered

Fix

Promised against delivered, at the same layer

Adding a dashboard panel per symptom
Problem

Every panel reports success

Fix

One subtraction beats five gauges (§17.7.5)

Leaving the predicate inline at the call site
Problem

It cannot be tested, and a missing row cannot be seen

Fix

Extract it: one function, one table test

Summary: The Misclassification Family

Five bugs across four chapters are one bug: a value crosses a boundary, loses the context that gave it meaning, and is read by the other side as something it never was. A severed error chain reads as no error. A truncated body ships behind a 200. An abandoned reservation reads as a request that happened — measured at ten tokens of debt and 110 ms against 10 ms. A queued waiter reads as an overloaded service, with six of eight units free. A cancelled caller reads as a failed dependency, measured at 200 of 200 requests shed against a downstream with a zero error rate.

The consistent tell is that every member reports success at every layer that has a dashboard, which is why monitoring is structurally unable to find them and a reviewer is not. The fix always belongs on the caller’s side of the boundary, and it should be a table of cases rather than a condition — a table has rows, and a missing row is something a second reader can see.

Two second lines of defence cost almost nothing. Extract the predicate so it can have a table test that needs no concurrency at all; and export the classification alongside the outcome, so the decision that is the bug stops being invisible.

Key Takeaways

  • Five entries, four chapters, one shape: a value interpreted by code that does not know what it meant
  • The wrong heading is always the one that looks like success, or like somebody else’s fault
  • Each member is a classification error, not a mechanical one — nothing malfunctioned
  • Monitoring cannot catch it, because the status is what monitoring reads and the status is what is wrong
  • The one cross-cutting signal is a subtraction: promised against delivered, at the same layer
  • Test ctx.Err() rather than the error’s shape; whose deadline it was is answerable
  • Ask for the classification table rather than a change to the if; a table has rows a reviewer can find missing
  • A misclassification propagates — fix the breaker and leave the metric, and the dashboard lies during the next incident
  • Every member lives where a deadline meets a counter, so the family grows as a service acquires admission control
Section 18.5 — in one line

An error is a fact about an attempt; everything else you read into it, you added.

Self-Check Questions: The Misclassification Family

Your dependency’s error rate drops to zero at 02:00 and your own 5xx rate rises. Both graphs look like a downstream that recovered while something else broke. What else fits?

A breaker that opened. §17.6.5's failure mode produces exactly those two graphs: the dependency’s error rate falls to zero because you stopped calling it, and your 5xx rate rises because every request is now refused locally. Nothing recovered and nothing else broke.

The graphs cannot distinguish the two stories, which is §18.5.3's point — every layer is reporting what it was configured to report. What separates them is a third number most dashboards do not carry: request volume at the dependency. A recovered downstream still receives traffic; a downstream behind an open breaker receives none. Zero errors on zero requests is not a health signal.

If the breaker did open, the next question is what it counted as a failure. A deploy at 01:58 that shortened a caller-side timeout would do it, and the downstream would have been healthy throughout.

Why is “handle errors properly” not a useful review comment for this family?

Because every member of the family is handling errors — carefully, symmetrically, and in the wrong place.

b.Record(err) handles the error. It checks it for nil, passes it to the component whose job is to react to it, and returns it to the caller. Nothing is dropped and nothing is ignored. The defect is that a single error value carries three distinguishable facts on the caller’s side — the caller went away, we refused this ourselves, they returned 503 — and the code compressed them to one bit before handing them across a boundary.

So the useful comment names the boundary and asks for the cases: “this value is about to be read as evidence about the downstream — which errors are actually about the downstream?” That prompts a table, and the table is the fix. “Handle errors properly” prompts a second look at code that already looks careful, which is how this family survives review in the first place.

A metric named downstream_errors_total is incremented on every non-nil error from a call. What is wrong with the name, and why does it matter more than the count?

The name asserts a classification the code does not perform — many of those errors are your own callers cancelling.

It matters more than the count because the name is what the next engineer trusts during an incident. A dashboard saying the downstream is failing sends the investigation to the wrong team, and it keeps sending them there after somebody has fixed the breaker, because fixing the breaker does not fix the metric. §18.5.5's point is that the misclassification propagates into every derived number: the alert, the SLO, the weekly report, the capacity model.

The fix is the one §18.5.6 describes and it costs one counter. Split the decision — CallerAbandoned and DownstreamErrors — and both names become true. That is a smaller change than any of the discussions it prevents.

A teammate proposes a dashboard panel for each of the five rows in §18.5.1. What would you suggest instead?

One subtraction per boundary, rather than five gauges.

The family’s defining property is that every layer reports success, so a panel showing what a layer reports will show success in all five cases. Five such panels are five confirmations that nothing is wrong.

What breaks the pattern is comparing two numbers that should agree and usually are not compared: configured rate against achieved rate, requests accepted against requests completed, bytes the handler meant to write against bytes sent. Each is a single derived series, each sits at zero when things are healthy, and each becomes non-zero for exactly the reason this family exists. §17.7.5 asked for the raw numbers; this is what they are for.

The honest caveat is that a discrepancy tells you something is misfiled without telling you which thing. That is fine — turning a symptom into candidates is §18.3's job, and one alert that fires is worth more than five that cannot.

18.6 Bugs That Live Between Chapters

Everything so far has pointed at a chapter. This section cannot, and that is its reason for existing.

Each of the seven bugs below needs two mechanisms at once, from two different chapters. A chapter about sync.Once cannot warn you about contexts, because it has not taught contexts yet; a chapter about contexts cannot warn you about sync.Once, because that was six chapters ago and it is not the subject. So these live in the gaps. They are the only material in this chapter that is new rather than indexed, each one is measured here, and each is a bug I have watched a competent engineer write.

One qualification, because six of the seven are honest and the seventh needs a footnote. §18.6.7's compound update is catalogued — §12.2.10 has the row. It is here because the entry is not about the rule, which Chapter 12 states perfectly well, but about why this instance gets past a reader who knows the rule. The other six appear nowhere in the book.

18.6.1 The Captured Context

sync.Once runs its function once. A context.Context belongs to one caller. Put them together and the first caller’s deadline becomes everybody’s, permanently.

client_186_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the closure captures whichever ctx arrived first
func (c *Client) Conn(ctx context.Context) (string, error) {
    c.once.Do(func() {
        select {
        case <-time.After(50 * time.Millisecond): // the dial
            c.v = "connected"
        case <-ctx.Done():
            c.err = ctx.Err()   // the FIRST caller's ctx
        }
    })
    return c.v, c.err
}

Both halves are textbook. §12.1.4 taught that Once guarantees exactly one execution — including exactly one failure. §13.3 taught that a context flows down from the caller who owns it. Neither taught that the second must not be captured by the first.

Measured one caller with a 10 ms deadline, then a hundred callers with no deadline at all.
Terminal
caller 1 (10ms deadline): err=context deadline exceeded
callers 2-101, no deadline: 100/100 failed

The second line is the bug. One impatient caller poisoned the client for the life of the process, and every later caller — with unlimited time available — receives a deadline error belonging to a request that finished long ago.

The “after every deploy” version is the one that reaches production: a cold process makes the first dial slower than usual, so the first caller is the one most likely to time out. The bug is scheduled by your deployment.

The review question: whose lifetime is this, and how long does its effect last? A ctx crossing into a sync.Once, a cache fill, a lazily-built singleton or any memoised value is the shape. Two fixes answer different questions. If initialisation must be retryable, Once is the wrong primitive and §12.1.7 says so — use a mutex and a state field, or singleflight, which forgets. If it genuinely is once per process, give it a lifetime context owned by the object and let callers time out of waiting for it rather than of performing it.

18.6.2 The Reciprocal Wait

errgroup.SetLimit bounds how many goroutines a group may run (§14.4.4). A semaphore bounds how many holders a resource may have (§17.4). Each is a queue, each is correct alone, and a goroutine that holds one while waiting for the other is Chapter 10's hold-and-wait with a modern face.

fan_out_186_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: errgroup limit 2, semaphore capacity 2
func fanOutUnderTwoBounds(ctx context.Context) error {
    g := new(errgroup.Group)
    g.SetLimit(2)
    sem := semaphore.NewWeighted(2)

    for i := 0; i < 2; i++ {
        g.Go(func() error {
            if err := sem.Acquire(ctx, 1); err != nil {
                return err
            }
            defer sem.Release(1)
            g.Go(func() error { return nil }) // no slot left
            return nil
        })
    }
    return g.Wait()
}

The defect is that g.Go blocks when the limit is reached — which the documentation says plainly and which nobody remembers, because in every example the group is filled from outside. Both parents hold a permit; both need a slot in a group whose two slots they occupy.

Measured exactly the listing above, with the limit, the semaphore and the parent count raised together. Twenty attempts per row, g.Wait() given 300 ms, two runs of each row.
Limit and semaphore
2
4
8
16

Read the last column downward, because it is the whole argument about load. The cycle needs every slot held at once, and the more slots there are the less likely it is that every parent reaches its nested g.Go before any of them returns. So raising the limit does not remove the cycle — it converts a bug that always fires into one that fires sometimes, which is the profile of a bug that passes review, passes CI, and waits for the day your traffic supplies the overlap for free.

At SetLimit(64) you need sixty-four concurrent parents, which is precisely the load your tests do not produce and production does.

It is also invisible in a goroutine dump until you know the shape. Every parked goroutine is in errgroup.(*Group).Go, which reads as “waiting for a slot” and looks like healthy backpressure. §10.4 would call it a partial deadlock: the runtime detector stays quiet because the rest of the process is fine.

The review question: does anything inside this bound acquire another bound? The fix is Chapter 10's, unchanged — do not acquire a second bounded resource while holding the first. Spawn children before taking the permit, or give the children their own group, which is also the clearer design because the two limits are different concerns and deserve different numbers.

Any bounded thing is a lock.

SetLimit, a semaphore, a buffered channel used as a bound, a connection pool with a maximum, a worker pool sized to N. Each blocks when full, so each participates in a hold-and-wait cycle exactly as a mutex does. §10.5.3's ordering rule applies to all of them and is almost never applied to any of them, because none of them has Lock in the name.

18.6.3 The Uninterruptible Wait

Chapter 13 propagates deadlines through a call tree. §9.2 protects shared state with a mutex. Deadline propagation stops dead at the mutex, and nothing warns you.

store_186_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the reader's deadline cannot interrupt the lock
func (s *store) slowWrite() {
    s.mu.Lock()
    defer s.mu.Unlock()
    slowDependencyCall() // 500ms, under the lock
}

func (s *store) read(ctx context.Context) (int, error) {
    s.mu.Lock() // sync.Mutex.Lock takes no context
    defer s.mu.Unlock()
    return s.v, nil
}

read accepts a ctx, and its signature therefore promises that a caller can give up. It cannot. sync.Mutex.Lock has no context parameter, no timeout and no failure mode; it returns when it returns.

Measured a writer holding the lock for 500 ms, and a reader with a 50 ms deadline. Three runs, identical.
Terminal
run 1: 50ms deadline; read returned err=<nil> after 480ms
run 2: 50ms deadline; read returned err=<nil> after 480ms
run 3: 50ms deadline; read returned err=<nil> after 480ms

The reader waited nearly ten times its deadline and then reported success. Its own timeout never fired, because there was nothing to fire against: the goroutine was parked in the runtime, not selecting on anything.

The deceptive part is that cancellation appears to work. The writer’s own call is cancellable, so §13's contract is satisfied — for that caller. The queue behind it is not, and the queue is where your p99 is.

This one cannot be measured in a bubble, for Chapter 16's reason.

The first version of that harness ran inside synctest and hung until the test binary’s alarm. §16.4.4 explains it exactly: a goroutine parked on a mutex is blocked but not durably blocked, so the bubble never becomes idle and virtual time never advances. Two chapters colliding again — and this time the collision is in the test rather than in the code.

The review question: can anything under this lock block, and can the blocked thing take a context? Three fixes, in descending order of preference. Do not hold the lock across a slow call — take what you need, unlock, call, re-lock to store; this is right almost always, and it is the exercise’s fifth gate. Use a semaphore of one if you genuinely need a cancellable lock, because semaphore.Acquire does take a context. Bound the critical section so that the wait is short enough for the deadline to be a formality.

This one also survives review for a reason worth naming: the lock and the slow call are usually written months apart. read starts as a map lookup under a mutex, which is correct and obviously so. The fetch arrives later, inside the existing if, because that is where a cache miss is handled — and the diff that adds it is three lines and does not mention locking. That is §18.1.5's mechanism at the level of a codebase rather than a review: the bug is introduced by a change about something else, into code that was correct when it was written.

18.6.4 The Discarded Queue

Chapter 15 shuts a process down gracefully. Chapter 17 puts a limiter in front of it. Between them is a question neither asks: what happens to the callers parked in the limiter when shutdown begins?

Measured twenty callers queued on a semaphore of one, in-flight work holding the permit, and then a graceful shutdown cancels the context they are all blocked on.
Terminal
after cancel: served=0 refused=20 drain took 0s

A drain that took no time at all, reported as clean, having discarded twenty requests the service had already accepted. Both components did exactly what they were told: Acquire(ctx, 1) returns an error when ctx is done, which is its contract and §17.3 relies on it; shutdown cancels the tree, which is its contract and §15.5 relies on it. The failure is in the composition, and it appears at exactly the moment a deploy happens — which is also the moment nobody is looking at per-request outcomes.

§15.5.3 drew the distinction this depends on: in-flight work versus queued work. A limiter’s parked waiters are the second category and they are invisible, because §17.4.3 measured that Weighted exposes no waiter count and rate.Limiter exposes no queue depth.

The review question: what is queued behind this limiter when shutdown starts, and does anything drain it? The fix is a decision rather than a line of code: are these callers accepted, or merely arrived? If accepted, shutdown must drain them, which means the limiter’s context cannot be the one cancellation reaches first. If merely arrived, refusing them is correct — and then the shutdown report should say twenty were refused rather than reporting a clean drain in zero seconds.

A drain that finishes instantly has not drained anything.

It is worth alerting on directly. §15.7.6 gives the report the fields; the failure here is that nothing was watching for the value being suspiciously good.

18.6.5 The Happy-Path Buffer

Chapter 3 teaches channel capacity. Chapter 14 teaches returning on the first result or the first error. Together they produce a leak that Chapter 16 lists in a Common Mistakes row and no chapter measures.

results_186_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the buffer is sized for the answer, not the workers
results := make(chan int, 1) // "one result is all we need"
for _, r := range replicas {
    go func(r Replica) {
        v, err := r()
        if err != nil {
            return
        }
        results <- v
    }(r)
}
select {
case v := <-results:
    return v, nil
case <-ctx.Done():
    return 0, ctx.Err()
}

The capacity was chosen by asking how many results do I need? The right question is how many goroutines did I start? The first sender fills the buffer, the function returns, and nobody receives again.

Measured eight replicas, one result taken, counted 300 ms after the call returned. Three runs, identical.
Terminal
capacity 1 (sized for the answer): 6 goroutines still parked
capacity 8 (sized for the workers): 0 goroutines still parked

Six goroutines blocked forever, in a function that returned successfully and reported no error. go vet, staticcheck and go test -race all report nothing on this code, which is why it is the third bug in this chapter’s exercise.

The general form has nothing to do with the happy path being a select. Any collector with an early return needs a buffer, or the senders it abandons become a leak. §14.3's result-struct pattern and §7.2's fan-in are where it shows up in production code, and §16.2.8 states the same rule for tests, where t.Fatalf is what makes the receiver leave early.

The reason it is easy to miss is that the unbuffered version is correct until somebody adds the early return — which is §18.4.2's pattern in the lifetime category: the line that becomes wrong is not the line that changed.

The review question: if this receive loop returns early, who is still sending? The fix is one expression, make(chan int, len(replicas)), and every send then completes whether anyone is listening or not.

18.6.6 The Cleanup Context

Chapter 13 gives a context to everything downstream. Chapter 14 gives you a group that makes one for you. The interaction is documented, universally forgotten, and it fires on the success path.

run_186_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: ctx is already cancelled by the time cleanup runs
func run(parent context.Context) error {
    g, ctx := errgroup.WithContext(parent)
    g.Go(func() error { return work(ctx) })

    err := g.Wait()
    // Wait has returned. So has ctx.
    if cerr := cleanup(ctx); cerr != nil {
        return errors.Join(err, cerr)
    }
    return err
}

errgroup.WithContext returns a context it cancels when Wait returns — not when a goroutine fails, but whenever the group finishes, however it finishes. The documentation says so in one clause. Every use of that context after g.Wait() is a use of a cancelled context.

Measured the same function on both paths.
Terminal
task failed=false Wait()=<nil> cleanup=context canceled
task failed=true Wait()=task failed cleanup=context canceled

Read the first row. Nothing failed. Every goroutine returned nil, Wait returned nil, and the cleanup that runs afterwards was handed a dead context. Whatever it was going to do — release a lease, delete a temporary object, deregister from discovery — did not happen, and the function returned success.

This is §15.5.6's context.WithoutCancel arriving one chapter early and in a place nobody looks for it. The fix is to be explicit about which lifetime the cleanup belongs to: pass parent, or context.WithoutCancel(parent) if the parent may itself be cancelled, and never the group’s context. The group’s context is for the group’s work, and its lifetime ends where Wait does.

The tell is a ctx used after the thing that owns it has finished.

errgroup.WithContext after Wait. A context.WithTimeout after its defer cancel() has been registered. A request context after the handler returned. In each case the value is still in scope and still non-nil, so nothing complains — it has simply stopped meaning what it meant.

18.6.7 The Compound Update Through a Safe Type

§12.2 teaches sync.Map as a map safe for concurrent use. §11.3 teaches that atomic operations do not compose. Put them together and the type’s name does the damage.

counters_186_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: two atomic operations, one invariant
func (c *Counters) Inc(k string) {
    v, _ := c.m.LoadOrStore(k, 0)
    c.m.Store(k, v.(int)+1)
}

Every operation on c.m is safe. LoadOrStore is atomic, Store is atomic, and there is no data race — -race agrees, correctly. The count is still wrong, because the read and the write are two operations and the world moves in between.

Measured fifty goroutines calling Inc on one key, 100 rounds, three runs.
Terminal
final count wrong in 98-100 of 100 rounds (worst: 2 of 50)
go vet · staticcheck · go test -race all silent

Not occasionally — near enough to every round, in every run, on a plain build and on a race build alike. A counter incremented fifty times finished at 2.

This is §18.4.4's check-then-act, and it earns its own entry because of why it gets past review. The type is called sync.Map, the package is called sync, and both operations are documented as atomic. Every local signal says this code is safe. The unsafe thing is the pair, and nothing about the pair is visible in either line.

The review question: is this one operation, or two operations and an assumption? The same question catches Load then Store on an atomic.Int64 where Add was meant.

This is also the one entry in this section that a chapter does own: §12.2.10 already carries the row, “Load-then-Store to increment — check-then-act race — LoadOrStore an atomic”. It is here anyway, and it is here for a different reason. Chapter 12 tells you the rule. What no chapter can tell you, because it needs Chapter 11's non-composition alongside Chapter 12's type, is why this particular instance survives review when the equivalent bug on a plain map would not: every local signal — the package name, the type name, the documented atomicity of both calls — says the code is safe, and all of them are true.

18.6.8 What These Seven Have in Common

Each is the product of two correct decisions.

WHERE TWO MECHANISMS MEET

Seven bugs, each needing two mechanisms from two different chapters: sync.Once with context gives the captured context; errgroup’s SetLimit with a semaphore gives the reciprocal wait; a mutex with a deadline gives the uninterruptible wait; shutdown with a limiter gives the discarded queue; channel capacity with an early exit gives the happy-path buffer; errgroup with context gives the cleanup context; and sync.Map with non-composition gives the compound update. Every chapter is right about its own half; the pairing is the defect, and nothing owns a pairing.

Every §18.4 review question passes on §18.6.2 individually. The channel protocol is fine; there is no channel. The lifetimes are fine; Wait joins everything. The invariant is fine. It is a resource cycle, and you only see it by noticing that two bounded things are held at once.

That is also the practical signal: look where two subsystems that were designed separately now touch. A limiter added in front of an existing server. A cache wrapped around an existing client. A group limit introduced into an existing pipeline. A retry wrapped around a call. The bug is rarely in either component; it is in the assumption each makes about the other’s blocking behaviour.

What does each side assume about the other’s blocking?

§18.6.1: Once assumes the initialiser is about the program; the caller assumes the context is about the request. §18.6.2: the group assumes tasks return; the task assumes g.Go returns. §18.6.3: the mutex assumes critical sections are short; the reader assumes a deadline reaches every wait. §18.6.4: the drain assumes wg counts work; the limiter assumes waiters are patient. §18.6.6: the group assumes its context is for its work; the caller assumes a context in scope is live.

Each pair is two reasonable assumptions that are individually true and jointly false. That is what a seam bug is, and asking the question at the seam is faster than learning the catalogue.

18.6.9 Finding Your Own

Seven is not the list. It is seven instances of a search you can run yourself, and the search states in a sentence: look for a value from one chapter stored inside a value from another.

Every entry above is that shape. A ctx inside a Once. A permit held across a g.Go. A limiter’s waiters governed by shutdown’s context. A dependency call inside a critical section. A worker’s send governed by a receiver’s early return. A group’s context used after the group. A read and a write through a type whose name promises safety.

That gives three places to look in an unfamiliar codebase, and they are quick.

Every field whose type comes from a different chapter than the struct’s purpose. A context.Context in a struct is the loudest — §18.6.1 and §18.6.6 are both this. A sync.Once next to anything request-scoped. A *rate.Limiter in a type that also has a Shutdown method.

Every place two bounded things are held at once. SetLimit plus a semaphore, a semaphore plus a pool, a pool plus a mutex. §10.5.3's ordering rule applies and is almost never applied, because only one of the four has Lock in its name.

Every defer or cleanup that runs after a lifetime has ended. After Wait, after cancel(), after a handler returns. The value is still in scope, and that is what makes it invisible.

There is one more signal worth trusting. If a bug required you to read two chapters of this book to understand, it is a seam bug — and the fix almost never belongs in either component. It belongs at the boundary, usually as an explicit statement of the assumption that was implicit. §18.6.4's fix is not in the limiter or in the drain; it is the sentence “parked waiters are not in-flight work”, turned into two separate populations.

18.6.10 Common Mistakes

Storing a ctx inside sync.Once
Problem

One caller’s deadline becomes permanent

Fix

Measured, 100 of 100 later callers poisoned; a lifetime context, or singleflight

A bound acquired inside another bound
Problem

Deadlock with no lock in sight

Fix

Measured, 20 of 20 at a bound of 2; treat any bound as a lock

Raising the limit to fix a reciprocal wait
Problem

The bug goes from always to sometimes

Fix

Measured, 20 of 20 at a bound of 2, 7–11 of 20 at 16

A slow call under a mutex
Problem

Callers' deadlines silently exceeded

Fix

Measured, a 50 ms deadline returned nil after 480 ms

Expecting a bubble to test a mutex wait
Problem

The test hangs instead of reporting

Fix

Not durably blocking (§16.4.4) — use real time

Cancelling the limiter’s context at shutdown
Problem

A clean drain in 0 s that discarded a queue

Fix

Measured, served=0 refused=20; decide accepted versus arrived

Sizing a result channel by results needed
Problem

Goroutines parked forever on the early return

Fix

Measured, 6 of 8; size by goroutines started

Using an errgroup's context after Wait
Problem

Cleanup silently skipped, on the success path

Fix

It is cancelled when Wait returns; pass the parent

Read-modify-write through a concurrency-safe type
Problem

Counts are quietly low

Fix

Measured, wrong in 98–100 of 100; one operation, or a mutex

Reviewing each component alone
Problem

Both are correct; the composition is not

Fix

Review the seam, not the parts

Summary: Bugs That Live Between Chapters

Seven bugs, each the product of two correct decisions from two different chapters, so that no chapter could have owned any of them.

Measured in each case. A sync.Once capturing a 10 ms context poisoned 100 of 100 later callers who had no deadline at all. A bound acquired inside another bound deadlocked 20 of 20 times at a bound of 2 and 7 to 11 of 20 at a bound of 16 — raising the limit turned an always-bug into a sometimes-bug and removed nothing. A 50 ms deadline waited 480 ms on a mutex and returned err=<nil>, because sync.Mutex.Lock takes no context. A shutdown that cancelled a limiter’s context refused 20 of 20 queued callers and reported a clean drain in 0 s. A result channel sized for the answer left 6 of 8 goroutines parked in a function that returned success, where sizing it for the workers left none. An errgroup's own context, cancelled by Wait, was handed to a cleanup that quietly did nothing — on the path where nothing failed. And a read-modify-write through sync.Map was wrong in 98–100 of 100 rounds with every tool silent.

Each is correct in both halves, so the review questions are all about the composition: what does this Once capture, what else does this bounded region acquire, what can block under this lock, what is queued behind this limiter at shutdown, how many sends can this channel take, whose lifetime is this context, and is this one operation or two.

Key Takeaways

  • Measured, a Once capturing a 10 ms deadline failed 100 of 100 later callers who had none
  • Measured, a bound inside a bound deadlocks 20 of 20 at a bound of 2 and 7–11 of 20 at a bound of 16 — raising the limit makes it rarer, never absent
  • Measured, a 50 ms deadline waited 480 ms on a mutex and returned nil; sync.Mutex.Lock takes no context
  • Measured, shutdown cancelling a limiter refused 20 of 20 queued callers and reported a 0 s drain
  • Measured, a result channel of capacity 1 left 6 of 8 goroutines parked; capacity len(replicas) left 0
  • Measured, an errgroup's context is cancelled when Wait returns, so a cleanup using it is skipped on the success path
  • Measured, a compound update through sync.Map was wrong in 98–100 of 100 rounds with -race clean
  • Any bounded thing — SetLimit, a semaphore, a pool, a buffered channel — participates in hold-and-wait exactly as a mutex does
  • Look where two separately-designed subsystems now touch, and ask what each assumes about the other’s blocking
Section 18.6 — in one line

The dangerous code is not in the chapter you are reading; it is where two chapters meet.

Self-Check Questions: Bugs That Live Between Chapters

Your service initialises a database pool inside sync.Once and the first request after every deploy sometimes fails permanently for all subsequent traffic. What is happening?

The Once captured the first request’s context, and that request had a deadline.

§12.1.4's guarantee is exactly one execution, and it makes no exception for an execution that failed — exactly once includes exactly one failure. So when the first caller’s deadline expires mid-dial, the error is stored and Do never runs again. Measured, a hundred subsequent callers with no deadline at all received that error.

The “after every deploy” detail is the tell: a cold process makes the first dial slower than usual, so the first caller is the one most likely to time out. The bug is scheduled by your deployment.

Two fixes, answering different questions. If initialisation should be retried, Once is the wrong primitive — a mutex and a state field, or singleflight, which forgets. If it truly is once per process, hand it a lifetime context and let callers time out of waiting rather than of initialising.

Why does §18.6.2's deadlock belong here rather than in Chapter 10?

Because Chapter 10 could not have written it, and would have been wrong to try.

The mechanism is entirely Chapter 10's — hold-and-wait, a cycle, all four conditions present, and the fix is the ordering rule unchanged. But the ingredients are errgroup.SetLimit from §14.4.4 and semaphore.Weighted from §17.4, four and seven chapters after Chapter 10. A deadlock chapter using them would have been teaching two primitives it had not introduced in order to illustrate a third thing.

What this section adds is not a new kind of deadlock. It is the recognition that SetLimit is a lock, and so is a semaphore, and so is a buffered channel used as a bound, and so is a connection pool. None of them has Lock in the name, so Chapter 10's rule does not get applied to them — which is precisely why the bug survives review.

SetLimit(4) deadlocks in production and never in your tests. Why would raising the limit be the wrong response?

Because it moves the threshold rather than removing the cycle.

The cycle is that a slot is held by a parent waiting for a slot, and it needs every slot held at once. Raising the limit raises the number of parents that must overlap for that to happen, so the bug gets rarer rather than absent. Measured across the gradient: 20 of 20 attempts deadlock at a bound of 2, 16 at 4, 12–13 at 8, and 7–11 at 16. That last row is the dangerous one — a bug that fires half the time in a tight loop fires approximately never in a test suite and reliably at peak traffic.

Only a second group breaks the cycle, and it is also the clearer design, because the parents' limit and the children’s limit are different concerns that deserve different numbers.

Your graceful shutdown reports a drain time of 0 s and you are pleased. Should you be?

Only if you can say what was in the queue when it started.

Measured, cancelling a limiter’s context at shutdown produced served=0 refused=20, drain took 0s — a perfect-looking report from a shutdown that discarded twenty accepted requests. A 0 s drain means either that nothing was in flight, or that everything in flight was cancelled at once, and the report cannot tell you which because both produce the same number.

That makes an unusually good drain time an alertable condition rather than a success. §15.5.3 already drew the line this depends on: were those callers accepted, or had they merely arrived? If accepted, the shutdown broke a promise it had made, and the limiter’s context must survive the first cancellation. If merely arrived, refusing them is right — and the report should say twenty were refused rather than reporting a clean drain.

18.7 Reviewing Concurrent Code

Half of this chapter’s title is code review, and everything so far has been building one thing: a procedure short enough that you will actually run it on a Tuesday afternoon on somebody else’s pull request.

18.7.1 The Four Questions, Promoted

Chapter 2 opened with four questions, at §2.1, and framed them as something to answer before writing go.

From §2.1, The Four Questions: A Framework for Every Goroutine.

How does this goroutine exit? How does it communicate results? How are errors handled? What data does it access?

Sixteen chapters later they are the review frame, and the reframing is small: you asked them as an author about code you were about to write, and you ask them as a reviewer about code somebody already wrote. The questions are unchanged. What changed is that Chapter 2 could point three of them at one place each, and now each points at considerably more.

§2.1's question
How does it exit?
How does it communicate?
How are errors handled?
What data does it access?

They also map onto §18.4's three categories, which is not a coincidence: §18.4 derived its categories from what the tools cannot see, and Chapter 2 derived its questions from what go does not record. Both arrived at the same facts. How does it exit is lifetime. How does it communicate is protocol. What data does it access is invariant. How are errors handled is protocol plus §18.5.

Notice that the questions did not change; only the answers got longer. That is §18.4.6's argument arriving as a practice — a question generalises and a list does not.

18.7.2 Read the Lifetime, Not the Diff

This is the single highest-yield change to how most people review concurrent code.

A diff shows you changed lines. Every bug in §18.4 and §18.6 is a relationship between a changed line and an unchanged one — a go statement here and a return twelve lines down, a close in this file and a send in another, a Once in a constructor and a ctx in a request handler, a newly added sender and a close that was correct all along. Reviewing the diff shows you one end of each relationship.

So do not read it in order. Read it in this one.

WHAT TO READ, AND IN WHAT ORDER

Six review steps. First every goroutine creation site the change touches and every one in the functions it calls, asking section 2.1's four questions. Then every return between there and the end of the enclosing function, asking what is still running. Then every channel creation and close -- who owns it, who else sends, why that capacity. Then every lock, asking what is called while it is held. Then every place an outcome becomes a status. Only then, the diff. Steps one to five are five greps and are bounded; step six is not, which is why it goes last.

Each of the first five is a grep. On most diffs all five come back empty and the concurrency review is over in twenty seconds — which is the point. The procedure has to be cheap enough to run on diffs that turn out not to need it, or you will only run it on the ones that look frightening, and the bugs in this chapter do not look frightening.

On the change where it takes twenty minutes, that is the change you wanted to spend twenty minutes on.

18.7.3 The Question at Each Site

§18.4 produced three questions and §18.7.1 has four more. In practice they collapse to a shorter set, because the reviewer’s job is to classify first and ask second.

At a go, wg.Go or g.Go, in order:

  1. Who waits for this? If nothing does, is that deliberate and documented, or is it a leak? §2.5's fire-and-forget is a legitimate answer; “nobody, and nobody thought about it” is not.
  2. If this function returns right now, what is still running? Ask again at every return and every t.Fatal between here and the end.
  3. What does it send, and is there room? Size by goroutines started, not by results wanted (§18.6.5).
  4. Whose context is this, and where does it get stored? A ctx that outlives its call is §18.6.1.
  5. Is this group already being used by something that is itself in the group? (§18.6.2.)

At a make(chan ...) or a close: who owns it, and is the close on the owner’s path? If there is more than one sender, who coordinates the close? If a receiver ranges over it, who closes it? (§3.3, §6.4, §7.3.) And if the diff added a sender: is that close still right?

At a Lock, in order:

  1. What is called while this is held? A user callback (§9.3), a channel operation, a dependency call, a second bounded resource — four different bugs, all hold-and-wait.
  2. Can the blocked thing take a context? If callers pass a ctx and the wait is a mutex, the signature is promising something it cannot deliver (§18.6.3).
  3. Is the invariant one field, or one field at two moments? If either, one lock per access is not enough (§18.4.4).

At a boundary where an outcome becomes a status, one question: what else could produce this outcome? (§18.5.)

Classification is faster than recall.

You are not trying to remember 488 bugs. You are deciding which of three or four categories a line belongs to, and each category comes with one question. That is a small enough working set to hold at 4 p.m. on a Thursday, which is the actual design constraint.

18.7.4 A Worked Review

Here is a real diff, of the size you get in a real pull request. The title is “enrich records with display names”, which is accurate and points at the feature.

enricher_187.go
// Illustrative snippet — not a complete program
// EnrichAll looks up a display name for every record,
// concurrently.
func (e *Enricher) EnrichAll(
    ctx context.Context, recs []Record,
) ([]Record, error) {
    out := make(chan Record)
    errc := make(chan error, 1)

    var wg sync.WaitGroup
    for _, r := range recs {
        wg.Go(func() {
            name, err := e.name(ctx, r.ID)
            if err != nil {
                select {
                case errc <- err:
                default:
                }
                return
            }
            r.Name = name
            out <- r
        })
    }
    go func() { wg.Wait(); close(out) }()

    var res []Record
    for {
        select {
        case err := <-errc:
            return nil, err
        case r, ok := <-out:
            if !ok {
                return res, nil
            }
            res = append(res, r)
        }
    }
}

func (e *Enricher) name(
    ctx context.Context, id string,
) (string, error) {
    e.mu.Lock()
    defer e.mu.Unlock()
    if v, ok := e.cache[id]; ok {
        return v, nil
    }
    v, err := e.fetch(ctx, id)
    if err != nil {
        return "", err
    }
    e.cache[id] = v
    return v, nil
}

It is careful code. The error channel is buffered so a failing goroutine cannot block. The select with a default drops a second error rather than deadlocking. The cache is behind a mutex. wg.Go is the modern idiom. Somebody thought about this.

Measured go vet ./... clean, staticcheck ./... clean.

Now the procedure from §18.7.2, in order.

1. Goroutine creation sites. One wg.Go, one bare go func. Who joins them? The wg.Go calls are joined by wg.Wait inside the closing goroutine. Now the second half: on which paths? The senders reach out <- r on the success path — and the receive loop returns at case err := <-errc. After that return, nobody receives from out again, and out is unbuffered.

That is the finding.

Measured ten calls with one failing key out of five, counted 300 ms after the last one returned. Nine runs.
Terminal
goroutines: 1 before -> 23 to 37 after

Every non-failing fetch on every call is parked forever on its send.

2. Channel creation sites. out := make(chan Record) is owned by EnrichAll and closed by the wg.Wait goroutine, which is correct. errc is buffered to 1 and never closed, which is fine because nobody ranges it. The ownership is sound; the capacity is the bug, and §18.6.5 is the general form.

3. Context parameters. ctx is passed to e.name and on to e.fetch. It belongs to the caller of EnrichAll and does not outlive it — no sync.Once, no struct field, no cache key. This one is clean.

4. Locks. e.mu.Lock() with defer e.mu.Unlock(), and between them: e.fetch(ctx, id). A network call under the lock.

That is §18.6.3. The first caller’s ctx works — it will return when its deadline expires. Everybody else is queued on e.mu, which reads no context at all.

Measured one caller with a fetch that takes 2 s holding the lock, and a second caller with a 50 ms deadline. Three runs, identical.
Terminal
second caller (50ms deadline): still blocked past 300ms

Worse, this is a cache — its entire purpose is to be on the hot path — so under a cold start every request in the service serialises behind one slow fetch.

5. Outcome to status. Nothing here turns an outcome into a policy decision, so this step is empty.

Then the feature. Does it enrich records correctly? Yes.

Two real bugs in forty lines that both linters passed. Neither is exotic; neither required knowing anything about this codebase. Both came from a question asked at a site, in an order that has nothing to do with how the diff was written.

The comments to leave are short.

Finding 1, as a comment.

“If errc fires, the remaining wg.Go senders are still blocked on out — should out be buffered to len(recs)?”

💡 Finding 2, as a comment. e.fetch is a network call inside e.mu — is a slow fetch meant to block every other key too?”

Both are questions. Both name the site. Neither says what to do, because the author knows whether that cache is meant to be a single-flight barrier and you do not.

18.7.5 The Same Five Steps on a Four-Line Diff

The worked review above is the case where the procedure obviously earns its keep. Here is the case where it looks like overkill and is not: a one-word change inside a four-line function.

cache_187.go
// Illustrative snippet — not a complete program
// The whole diff is one word: `c.fetch(ctx, k)` became
// `go c.fetch(ctx, k)`. Four lines of context, one changed.
func (c *Cache) Warm(ctx context.Context, keys []string) error {
    for _, k := range keys {
        go c.fetch(ctx, k)
    }
    return nil
}

A performance change: warm the cache in parallel instead of serially. Obviously correct, and it is not.

Step 1 finds one go statement, and the Four Questions do not have four answers. How does it exit? When fetch returns, presumably — nothing in view bounds its runtime and nothing waits for it. How does it communicate? It does not: Warm returns nil before any fetch has finished, so the contract changed from “the cache is warm” to “the cache will be warm at some point, or not”. How are errors handled? The serial version discarded fetch's error too — a pre-existing bug this change makes unfixable, because there is no longer a caller to return it to. What data does it access? c, from len(keys) goroutines at once, and whether fetch is safe for concurrent use is said by neither the diff nor the function name.

Step 2 finds one return, immediately, with len(keys) goroutines running at that moment. Steps 3 and 5 are empty. Step 4 finds no lock in the diff — but step 1's last question already sent you to read fetch, which is where a lock will be if there is one. Step 6, the change itself, is by now the least interesting part.

Three comments come out of that.

The three comments, and none of them is “don’t use a raw goroutine”.

Warm now returns before any fetch has completed — is that the intended contract? Callers treating a nil return as 'the cache is ready' will be wrong.” Then: len(keys) is caller-controlled, so this is unbounded concurrency against whatever fetch calls — errgroup.SetLimit, or a semaphore?” And: “Is c.fetch safe for concurrent use on the same c? The serial version never needed it to be.”

Each is a missing answer, each is answerable in one line by the author, and the third is the one most likely to be a real bug. Total time: about two minutes, most of it spent reading a function the diff did not touch — which is §18.7.2's whole argument.

And notice what the tools would have said: nothing. There is no data race until fetch has one, no lost cancel, no copied lock, and -race will report only if some test happens to warm two overlapping keys. The change is a lifetime bug and a contract change, and both are invisible by construction.

18.7.6 What to Say, and What to Let Go

Three comments are almost always worth making, and all three are questions rather than assertions — the author knows the intent and you do not.

“Who closes this, and how do you know the senders are done?” — asked of the doc comment, not the code, and asked whenever a close appears in a function that is not obviously the sole sender. If the package’s documentation does not say who owns a channel’s lifecycle, the protocol does not exist and §18.4.2's whole family becomes possible. The answer is a WaitGroup, a sole-sender argument, or a bug, and you do not have to know which — the question makes the author check.

“What happens here if the caller has already gone?” — at an error path, wherever an outcome becomes a status. This is §18.5's question and it finds the whole family in ten seconds.

“Is there a test that fails without this?” — when a concurrency fix arrives without one. §16.7.7 lists the step almost everyone skips; a fix nobody watched fail is a fix taken on faith.

Then stop, because concurrency review has a specific failure mode: the reviewer who has just read a chapter like this one and flags every goroutine in the codebase.

A reviewer who flags everything is as useless as one who flags nothing.

Both produce the same outcome — the team stops reading your comments. Fire-and-forget is sometimes right (§2.5). An unbuffered channel is sometimes right (§5.3). A goroutine that runs until the process exits is right in a background flusher and wrong in a request handler, and the code is identical. If you cannot name the question from §18.7.3 that the code fails to answer, you do not have a finding — you have a preference, and this chapter’s exercise has a gate for exactly that.

Three findings that are usually not findings, offered because every reviewer who reads a chapter like this one produces them for a while.

“This goroutine has no context.” Plenty should not have one. A goroutine that owns a resource for the life of the process — a metrics flusher, a log shipper, a pool’s reaper — is stopped by closing its input or by the process exiting, and threading a context into it adds a parameter nobody cancels. The question is how it exits, not whether it takes a ctx.

“This channel is unbuffered.” §5.3 spent a section on when that is the right answer, and the rendezvous property is often the point. The finding is only real if you can name a sender that will be abandoned — which is §18.4.3's question, and it has an answer.

“This should use errgroup.” Maybe. errgroup buys first-error semantics and a limit; if the code needs neither, a WaitGroup is smaller, and §15.6.6 argued the same from the other direction. A comment that names a construct invites an argument about constructs; a comment that names a missing answer invites a check.

WHERE THE REVIEW BUDGET GOES

Two comments that each name a missing answer are both read, both discussed and both fixed. Nine comments of which two name a missing answer leave the author skimming, finding seven preferences, and starting to skim your next review as well -- with the two real ones somewhere in there. You spend the same budget either way; the only thing you choose is what you spend it on.

The corollary for the author’s side is worth stating too: when a reviewer asks “who closes this?”, the answer is not a defence. It is a sentence in a doc comment, or a directional type (§18.4.6), and then nobody has to ask again.

18.7.7 Review the Tests, Not Only the Code

A concurrency change arrives with tests. Reviewing them is a separate pass and a short one, because Chapter 16 supplied every question.

Does the test assert an invariant or a schedule? §16.1.4's distinction, and the single most useful thing to ask. if got != want after a time.Sleep is a schedule assertion: it will pass, and it will keep passing when the code breaks. A sleep between the act and the assert is the comment.

Would it fail without the fix? §16.7.7's step. Asking costs one sentence.

Does it run enough times to mean anything? A rare-branch race condition needs many runs before a single pass means much, and §16.7.5 gives the arithmetic. Either the test is deterministic — a synctest bubble, a barrier — or it needs -count.

Does it assert from the test goroutine? §16.2's rule. A t.Fatal inside a wg.Go is the bug neither linter catches (§18.2.6), and a test file is exactly where it appears.

Four questions, all from Chapter 16, and they take about as long to ask as reading the test does. The reason to do them as a separate pass is that a test file reads like scaffolding, and scaffolding is where attention goes to die.

18.7.8 When the Diff Is Too Big

The five greps assume a diff you can read. Sometimes you get four thousand lines and a deadline, and the honest answer is not to pretend otherwise.

Triage by what changed about concurrency, not by what changed.

Terminal
$ git diff --stat main... -- '*.go'
$ git diff main... -- '*.go' \
  | grep -nE '^[+-].*(go func|wg\.Go|g\.Go|make\(chan|sync\.|ctx)'

The second command is the whole review surface. A four-thousand-line diff that adds no goroutine, no channel, no lock and no context needs no concurrency pass at all, and finding that out takes one command. When it does return lines, those are the ones to read in full, with §18.7.3's question at each.

Two habits make it reliable.

Read the deletions too — which is why the pattern above matches ^[+-] rather than ^\+. A removed defer cancel(), a removed close, a select that lost its ctx.Done() case: deletions do not read as concurrency changes and they are half of this class.

Ask for the diff to be split before you ask for anything else. A change that adds a limiter and refactors the handler is two reviews wearing one hat, and §18.6 is entirely about the bug being at the seam between them. “Can the limiter land on its own first?” is often the highest-value comment available, and it is not a concurrency comment at all.

18.7.9 Reviewing Your Own Code

Most concurrent code is read most often by the person who wrote it, and self-review fails differently from peer review. A reviewer does not know what you meant, so they have to read what you wrote. You know what you meant, so you read what you meant — which is why re-reading your own diff finds typos and never finds a lifetime bug. The intended lifetime is right there in your head, filling the gap the code left.

There is one structural advantage to set against it: you are not limited to the diff. A reviewer sees the changed hunks; you can see the whole file, and §18.7.2's procedure wants the file. A change that adds one case to a select is a two-line diff and a whole-function question, and you are the only reader positioned to ask it.

Four habits work against the handicap, in ascending order of cost.

Run §18.2's step before you send it, not after. go vet ./... and staticcheck ./... take seconds and they are a reader whose attention is free. Every mechanical finding they report is one that would otherwise have consumed a human reviewer’s limited attention on something a machine already knew.

Write the invariant down first. Before the code, in the comment. “At most quota grants across all callers.” Now the sentence exists outside your head and the code can be compared against it. This is the highest-yield habit in the chapter, because it costs nothing and it changes the code rather than the review — one sentence naming two moments in time is visibly not protected by one lock per access, and §18.4.4's bug cannot survive being written down. Most check-then-act bugs are written by somebody who could have stated the invariant correctly and was never asked to.

Answer the Four Questions in writing, in the PR description. Not as ceremony: as a forcing function. “This goroutine exits when the context is cancelled; results come back on results, buffered to len(replicas); errors join into the return; it touches only its own arguments.” Four sentences. If any of them is hard to write, that is the finding, and you found it before anyone else spent time on it.

Come back tomorrow, or make a machine be the stranger. Twenty-four hours is enough to forget what you meant, which is exactly the deficit you need. Where you do not have a day, Chapter 16 is the substitute: a test is a reader with no memory of your intent.

18.7.10 When to Stop, and a Checklist

The half that is always missing, and where a review process actually fails.

Stop when every site has an answer, not when you feel certain. You will not feel certain — §18.4's bugs are invisible by construction, so a reviewer waiting to feel sure waits forever and then approves anyway, which is worse than stopping on a rule. The sites are enumerable, which is what makes the standard reachable: every go the change touches, every return between there and the end, every channel, every lock, every boundary where a value becomes a status. When each has an answer — in the code, in a comment, in a test that would fail if it were wrong, or in the author’s reply — you are done.

Stop escalating a disagreement about a hypothetical. “This could deadlock if two callers arrive together” is a testable claim, and Chapter 16 made all three of the usual claims cheap to settle.

The claim
“This depends on the order things run in”
“This leaks a goroutine on that path”
“That window is too narrow to matter”

A test settles it and then keeps settling it, which is what makes it worth doing even when you turn out to be wrong: the disagreement was worth a day of two people’s attention exactly once, and the regression guard is worth it every time somebody touches that code afterwards. The corollary is a stopping rule in the other direction — if the claim is not worth a test, it is not worth the thread.

Stop reviewing for what the tools catch. If §18.2's step is in CI, then copied locks, discarded cancels, unbuffered signal channels, Add inside goroutines, deferred Locks and spinning select loops are handled. Reading for them is unpaid work, and worse, it consumes the attention the eleven need.

Unusual is not wrong. Some correct concurrent code looks alarming. A mutex unlocked explicitly rather than deferred, so that a slow call happens outside the critical section, is the fix for §18.6.3 and reads like a violation of the defer rule. This chapter’s exercise has a gate that fails you for “correcting” it, because a reviewer who normalises every unusual shape removes the deliberate ones.

Chapter 10 already ships a code review checklist. It is a good one and it is about deadlocks — lock ordering, nested acquisition, callbacks and I/O under lock. If the change you are reviewing is lock-heavy, use that one; it goes deeper on that mechanism than this chapter can afford to. This is the other altitude: the pass you make before you know what the subject is.

Site
go / wg.Go / g.Go
make(chan ...)
A close in a diff that added a sender
A ctx parameter
A ctx stored in a field or a closure
Lock
Two critical sections with a decision between them
An early return
An error counted, retried or recorded
A new limit, buffer or pool

Ten rows, all questions, none of them a rule about how to write Go. The honest claim about them is narrow: they are the questions that would have caught the bugs in this book. They are not a proof of correctness and no checklist is. What they buy is that the categories get visited, which is the thing an unaided reader reliably fails to do — not because the questions are hard, but because nothing prompts them.

The failure this chapter cannot fix.

§18.1.5's third failure of recognition was social: you saw it, you knew it was wrong, and the comment was not acted on. No technique here helps. What helps a little is the form the finding takes — a question with a reproduction attached is much harder to defer than an opinion, and Chapter 16 is an entire chapter about how to attach one. “I think this leaks” invites a discussion. “This test fails on your branch” ends it.

18.7.11 Common Mistakes

Reading the diff in diff order
Problem

You review the feature; the bug is in the plumbing

Fix

§18.7.2's five greps first

Reviewing the diff only
Problem

The other end of the relationship was never changed

Fix

Read the goroutine’s whole lifetime

Flagging a shape
Problem

Comments stop being read

Fix

Flag a missing answer, not a pattern

Flagging every unjoined goroutine
Problem

The team stops reading your comments

Fix

Name the unanswered question or let it go

Asserting rather than asking
Problem

The author defends; nobody learns

Fix

“Who closes this?” ends in five words or a paragraph

“Correcting” unusual but correct code
Problem

The deliberate fix is removed

Fix

Unusual is not wrong; ask before changing

Reviewing logic before lifetimes
Problem

The unbounded task runs first

Fix

Steps 1–5 are minutes; step 6 is not

Reviewing for what CI already catches
Problem

Free coverage paid for twice

Fix

§18.2's step, then stop looking

Reviewing until you feel certain
Problem

Unbounded, and certainty never arrives

Fix

Stop when every site has an answer

Accepting a concurrency fix with no test
Problem

A fix nobody watched fail

Fix

Ask for the test that fails without it

Leaving a finding as an opinion
Problem

Correct, ignored, shipped

Fix

Attach a failing test (Chapter 16)

Using this checklist on a lock-heavy change
Problem

Less depth than is available

Fix

Chapter 10's first, then this one

Summary: Reviewing Concurrent Code

Chapter 2's Four Questions were the review frame all along; sixteen chapters later the only change is that you ask them about somebody else’s code, and the answers got longer while the questions did not.

The procedure is to read the lifetime before the diff, because every bug in §18.4 and §18.6 is a relationship between a changed line and an unchanged one, and the diff shows you one end of each. Five greps — goroutine creation, early returns, channels and closes, locks, outcome-to-status — each with one question attached, and then the feature. On most diffs all five come back empty, which is exactly what makes the pass cheap enough to run every time.

A worked review of a forty-line diff that both linters passed produced two real bugs: measured, senders parked from 1 goroutine to 23–37 after ten calls with one failing key, and a second caller with a 50 ms deadline blocked past 300 ms behind a network call under a cache lock. The same five steps on a four-line diff produced three missing answers in two minutes, most of it spent reading a function the diff never touched.

The discipline that keeps it useful is knowing when to stop. Flag a missing answer rather than a shape. Treat unusual as unusual rather than wrong. Settle a hypothetical with a test rather than a thread. And stop when every site has an answer, because certainty is not available for bugs that are invisible by construction.

Key Takeaways

  • §2.1's Four Questions are the review frame, unchanged, asked of code somebody else wrote
  • Every bug in §18.4 and §18.6 is a relationship between a changed line and an unchanged one — so read the lifetime, and the diff last
  • Five greps, one question each; on most diffs all five are empty and the pass costs twenty seconds
  • Classification is faster than recall: three or four categories, one question each, is a working set you can hold under time pressure
  • Measured, a worked review of forty lines both linters passed: two real bugs, from one question per site
  • Three comments always earn their place: who closes this, what if the caller has gone, is there a test that fails without this
  • A reviewer who flags everything is as useless as one who flags nothing — name the missing answer or let it go
  • Review the tests as a separate pass; a test file reads like scaffolding and scaffolding is where attention dies
  • Self-review cannot un-know the intent, so write the invariant down first — it is the one habit that changes the code rather than the review
  • Stop when every site has an answer, not when you feel certain; you will not
Section 18.7 — in one line

Read the lifetime, classify the site, ask the one question, and stop when they are answered.

Self-Check Questions: Reviewing Concurrent Code

Why read the diff in a different order from the one it is presented in?

Because the diff’s title is an accurate description of the feature, and the accuracy is the problem — it points your attention exactly where the concurrency bug is not.

The structural version of the argument is §18.7.2's: every bug in §18.4 and §18.6 is a relationship between a changed line and an unchanged one. A go here and a return twelve lines down. A close in this file and a send in another. A newly added sender, and a close that was correct when it was written. The diff shows you exactly one end of each relationship, so reading it in order is guaranteed to show you the half that looks fine.

The five greps are cheap enough to run every time, which matters more than it sounds: a procedure you only run on the diffs that look frightening will never run on the bugs in this chapter, because none of them looks frightening.

A three-line diff moves an existing call inside an existing go func. What do you read before the diff, and why?

The enclosing function’s whole lifetime, and the moved call’s body.

Three lines that move a call into a goroutine change four things the diff does not show: what now runs concurrently with the caller, what the caller no longer waits for, what errors no longer reach anybody, and what data is now shared. Those are the Four Questions, and all four answers live outside the diff.

Concretely: read every return between the go statement and the end of the enclosing function, because the moved call’s errors now have to reach somebody through a channel rather than a return value, and §14.1.1's failure mode is silence. Then read the moved function for what it touches, because data that was safely single-threaded a moment ago may not be.

This is the archetype of §18.7.2's rule, and also the archetype of the change that gets waved through, because three lines that move an existing call look like refactoring.

A colleague’s review comment says “don’t use a raw goroutine here, use errgroup”. Is that a good comment?

It is a shape, not a missing answer, and §18.7.6 argues against it in that form.

errgroup may well be the better tool — it carries the error and it bounds the group — but the comment asserts a preference where a question would establish a fact. The author may already wait for the goroutine, may already route its error, or may have a reason a group does not fit.

The version that finds bugs is “what waits for this, and where does its error go?” If the answers are “a WaitGroup on line 40” and “this channel”, the code is fine and the exchange cost one sentence. If the answers are missing, the author now knows what to fix — and may reach for errgroup themselves, which is a better outcome than being told to.

The general rule: a comment naming a construct invites an argument about constructs. A comment naming a missing answer invites a check.

You have used every question in §18.7.3 and found nothing, but something still feels wrong. What now?

Look at the seams (§18.6).

The questions in §18.7.3 are per-site, and the remaining bug class is not at a site — it is between two subsystems that are each individually correct. A limit in front of a group. A cache around a client. A drain in front of a queue. A retry around a call. Every §18.4 question passes on §18.6.2 individually: there is no channel, Wait joins everything, and the invariant is fine.

The question that finds them is different in kind: what does each side assume about the other’s blocking behaviour? Two reasonable assumptions that are individually true and jointly false is what a seam bug is.

And if the answer is still “I’m not sure”, the escalation is not more reading. It is Chapter 16 — write the test that would fail if the answer were wrong.

Chapter Summary

The chapter opened with a package on which go vet, staticcheck and go test -race -count=5 were all clean, and which contained three bugs. Everything since has been an account of how that is possible and what to do about it.

The measurements settle the shape. Across a sixteen-bug corpus chosen by a rule fixed in advance — one bug per chapter, from what that chapter teaches — go vet found three, staticcheck found two of which one was already vet’s, and -race added one. Eleven of sixteen were caught by nothing. go test with no flags contributed only the runtime’s own map check, which killed the process in one to five runs of twenty and has nothing to say about the other fifteen. Publish your corpus if you quote a ratio: three other probes gave three other numbers and one shape.

The go test result has a cause you can read in the Go source. defaultVetFlags names all seven concurrency analyzers and enables one, so of vet’s seven concurrency analyzers go test runs exactly one. The fix is a line, in either of two forms, and on the corpus it turned an ok into three findings and a failed build. The conservatism is correct rather than a bug: a vet finding fails the build before any test runs, so the automatic set cannot contain heuristics. And -race is a third kind of thing entirely — measured, it reports nothing at all on a package with no test files, and its reach is your test coverage times the odds the window opens on that run.

What is left after the tools is not a random remainder. Every bug they miss is a property of a protocol — a rule shared between two files and held by neither, which is why send-on-closed and receiver-closes are one rule with two symptoms chosen by the scheduler. Or a lifetime — a relationship across a call boundary the analyzer cannot cross, which is why waitgroup catches Add inside a goroutine and says nothing about Done before an early return. Or an invariant — a claim about your domain, which is why a quota with every access correctly locked broke in 3 to 9 rounds of 200 under a race detector that had nothing to report, and in zero rounds of a thousand without one. The tool that could not report it is the only tool that surfaced it, because a race build randomises the scheduler while remaining unable to diagnose what it perturbs.

Those three become three questions, and the questions are what survives. §18.2.7 measured a bug that one line in go.mod removes from the language; every checklist naming it is now wrong for half its readers. A list expires. A question about a contract does not — and the constructive move is to stop needing the question at all, by writing the missing fact into a directional type, a leak test, or an invariant assertion.

Two sections are about what a book organised by mechanism cannot give you. §18.3 inverts the index so it runs from a symptom, grouped by where you see one — a dashboard, a log, a test suite, a diff — because at 3 a.m. the symptom is what you have. And §18.6 collects the seven bugs that need two mechanisms at once, each of which is two correct decisions and one outage: a Once that poisoned 100 of 100 later callers with a deadline that had already expired; a bound acquired inside another bound; a 50 ms deadline that waited 480 ms on a mutex and returned err=<nil>; a shutdown that discarded 20 of 20 queued callers and reported a 0 s drain; a result channel of capacity 1 that left 6 of 8 goroutines parked; an errgroup context, cancelled by Wait, handed to a cleanup on the success path; and a compound update through sync.Map that was wrong in 98–100 of 100 rounds.

Then §18.7, which is the chapter’s actual deliverable. Read the lifetime before the diff, because every one of those bugs is a relationship between a changed line and an unchanged one. Five greps, one question each, then the feature. Classify rather than recall. And stop when every site has an answer, not when you feel certain, because with these bugs certainty is not on offer.

Two threads from earlier chapters close here.

Chapter 2 asked four questions before writing go and framed them as an author’s discipline. They turn out to be the reviewer’s discipline unchanged, which is the sort of thing that only becomes visible sixteen chapters later: the questions were never about goroutines specifically. They are about facts the language does not record — which is also the definition §18.4 arrived at from the opposite direction, by asking what the tools cannot see.

And Chapter 8's split between a data race and a race condition, quietly load-bearing since it was drawn, gets its final consequence. A data race is a property of memory, so a tool can find it. A race condition is a property of meaning, so one cannot. Every layer in this chapter sits on one side of that line or the other, and knowing which side a bug is on tells you which layer will catch it — which is, in the end, the only thing this chapter is about.

Chapter Connections

How Chapter 18 connects
Chapter 2
§2.1's Four Questions are §18.7's review frame, unchanged; §2.4's leaks are §18.3.2's first row; §2.5's fire-and-forget is the reason “unjoined goroutine” is not a finding
Chapter 3
Who Closes? is §18.4.2's canonical protocol, and the rule lives in neither file
Chapter 4
§4.3's default is the poll loop staticcheck caught; §4.5's uniform select is why cancellation is not immediate
Chapter 5
§5.3's case against large buffers is §18.3.2's memory row and half of §18.6.5
Chapter 6
§6.4's directional types are the one place a protocol fact becomes a compile error
Chapter 7
§7.2's fan-in is half of §18.6.5; §7.3's multi-sender close is §18.7.3's follow-up question
Chapter 8
§8.2's data-race/race-condition split decides which of the three layers can help at all
Chapter 9
§9.2's “nothing slow under a lock” meets Chapter 13 in §18.6.3 and stops being advice
Chapter 10
The Quick Reference is the deep, single-mechanism version of §18.3 and §18.7.10; §10.5.3's ordering rule applies to every bound, not only to locks
Chapter 11
§11.3.3's check-then-act is §18.4.4; §11.3's non-composition is half of §18.6.7
Chapter 12
§12.1.4's exactly-one-failure is what makes §18.6.1 permanent; §12.2.10 is §18.6.7 from Chapter 12's side
Chapter 13
Whose context this is, and for how long, is three of §18.6's seven entries
Chapter 14
§14.1.1's silence is the first member of §18.5's family; §14.4.4's SetLimit is half of §18.6.2
Chapter 15
§15.4.4's truncated 200 OK is the second member; §15.5.3's in-flight-versus-queued line is §18.6.4's decision
Chapter 16
§16.3.4 sent check-then-act here by name and §18.4.4 pays it with a number; §16.4.4 explains why §18.6.3 cannot be bubbled; §16.5 to §16.7 turn a review argument into a test
Chapter 17
§17.6.5, §17.3.4 and §17.4.3 are three of §18.5's five members; §17.7.5's four numbers are what §18.3's index runs on
Chapter 19
Diagnosis after capture — dumps, pprof, the tracer, delve. This chapter is before it runs and you are reading source; Chapter 19 is after it runs and you are reading a process. §18.3 produces candidates; Chapter 19 confirms one
Chapter 20
Performance and profiling, and the cost side of every bound this chapter reviews

The Measurements, in One Place

Finding
The book’s existing catalogue
go test ./... on the 16-bug corpus
go vet ./... on the same corpus
staticcheck ./... on the same corpus
go test -race on the same corpus
go test -race with the test file removed
Caught by nothing at all
golangci-lint, no config
Check-then-act quota, plain build
Check-then-act quota, under -race
sync.Once capturing a 10 ms context
A bound inside a bound
A 50 ms deadline behind a mutex
Shutdown cancelling a limiter’s context
A result channel of capacity 1
An errgroup context after Wait
Compound update through sync.Map
The worked review, 40 lines, both linters clean
The exercise starter, gate 2

Final Checklist

Before moving to Chapter 19, make sure you can:

Exercise 18.1 — The Package Every Tool Approves

Your move

The Package Every Tool Approves

Every exercise in this book so far has handed you a failing gate. This one starts by asking you to run three commands and watch them succeed.

Terminal
$ cd labs/go-concurrency/code/ch18
$ go vet ./...
$ staticcheck ./...
$ go test -race -count=5 ./...
Measured no output, no output, ok. Three tools, three clean results, on a package with three bugs in it. (Go 1.27 adds a fourth instrument that would see the second bug — the goroutineleak profile, §18.1.3 — but only if something asks for it, and none of these three commands does.)

That transcript is the chapter’s thesis with a $ in front of it, and it is why this exercise exists. Everything you have been taught to trust agrees this code is fine. Gate 1 runs go vet and go build from inside the test suite and passes, so the first thing the exercise does is watch the toolchain endorse broken code.

Service is a hundred and forty lines of ordinary production Go: a quota, a fan-out over replicas, a poller and a cache refresh. It has three bugs, and a fourth thing that looks like a bug and is not.

Here is the whole subject. Read it before the gates, the way you would read a colleague’s package — the bugs are visible from the source alone, which is the only claim this chapter makes.

ch18/service.go
// Package ch18 is the exercise for Chapter 18: Common Bugs and Code
// Review.
//
// Service is a small piece of production Go. Every tool in the
// toolchain is happy with it:
//
//	go vet ./...          reports nothing
//	staticcheck ./...     reports nothing
//	go test -race ./...   reports nothing
//
// TODO(reader): it contains three bugs anyway, and a fourth thing
// that looks like a bug and is not. Three gates fail. Each needs a
// different fix, and one gate exists to fail you for "fixing" the
// thing that was already right.
package ch18

import (
	"context"
	"errors"
	"sync"
	"time"
)

// ErrNoQuota is returned when a caller's quota is exhausted.
var ErrNoQuota = errors.New("ch18: no quota left")

// Service hands out quota, fetches from replicas, and polls.
type Service struct {
	mu    sync.Mutex
	quota int

	// slow is the dependency Refresh calls. Tests replace it.
	slow func() int

	cached int
}

// New returns a Service with n units of quota.
func New(n int, slow func() int) *Service {
	if slow == nil {
		slow = func() int { return 0 }
	}
	return &Service{quota: n, slow: slow}
}

// Take consumes one unit of quota. It reports whether it got one.
//
// The invariant: across any number of concurrent callers, Take
// returns true at most `quota` times.
func (s *Service) Take() bool {
	s.mu.Lock()
	available := s.quota > 0
	s.mu.Unlock()

	if !available {
		return false
	}

	s.mu.Lock()
	s.quota--
	s.mu.Unlock()
	return true
}

// Left reports the remaining quota. It can go negative, which is how
// a test sees the invariant break.
func (s *Service) Left() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.quota
}

// FetchAll queries every replica and returns the first result. It
// returns as soon as one replica answers, because there is no point
// waiting for the rest.
//
// The invariant: FetchAll starts goroutines and none of them outlive
// the call.
func FetchAll(
	ctx context.Context, replicas []func() (int, error),
) (int, error) {
	results := make(chan int, 1)

	for _, r := range replicas {
		go func(r func() (int, error)) {
			v, err := r()
			if err != nil {
				return
			}
			results <- v
		}(r)
	}

	select {
	case v := <-results:
		return v, nil
	case <-ctx.Done():
		return 0, ctx.Err()
	}
}

// Poll calls tick every interval until it is told to stop.
//
// The invariant: Poll returns promptly when ctx is cancelled.
func Poll(ctx context.Context, interval time.Duration, tick func()) {
	t := time.NewTicker(interval)
	defer t.Stop()
	for range t.C {
		tick()
	}
}

// Refresh updates the cached value from the slow dependency.
//
// Read this one carefully before changing it. The explicit Unlock is
// deliberate: the dependency call must not happen under the lock, or
// every concurrent Cached blocks for its whole duration. That is
// §18.6.3, and gate 5 enforces it.
func (s *Service) Refresh() {
	s.mu.Lock()
	stale := s.cached
	s.mu.Unlock()

	v := s.slow()
	if v == stale {
		return
	}

	s.mu.Lock()
	s.cached = v
	s.mu.Unlock()
}

// Cached returns the last refreshed value.
func (s *Service) Cached() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.cached
}

And the five gates. Gate 1 is the transcript above, run from inside the suite.

ch18/service_test.go
package ch18

import (
	"context"
	"os/exec"
	"runtime"
	"strings"
	"sync"
	"testing"
	"time"
)

// Gate 1 -- PASSES on the starter, and that is the whole lesson. The
// toolchain has nothing to say about a package with three bugs in it.
func TestTheToolchainIsSatisfied(t *testing.T) {
	for _, c := range [][]string{
		{"go", "vet", "./..."},
		{"go", "build", "./..."},
	} {
		out, err := exec.Command(c[0], c[1:]...).CombinedOutput()
		if err != nil || len(strings.TrimSpace(string(out))) != 0 {
			t.Fatalf("%s reported something:\n%s",
				strings.Join(c, " "), out)
		}
		t.Logf("%-18s clean", strings.Join(c, " "))
	}
	t.Log("go test -race     clean (you are running it)")
	t.Log("")
	t.Log("Three tools, no findings. The next three gates disagree.")
}

// Gate 2 -- FAILS. The quota invariant, under concurrent Take.
func TestQuotaIsNeverOverdrawn(t *testing.T) {
	const rounds = 200
	over := 0
	for r := 0; r < rounds; r++ {
		s := New(1, nil)
		var wg sync.WaitGroup
		granted := make([]bool, 8)
		for i := range granted {
			wg.Add(1)
			go func(i int) {
				defer wg.Done()
				granted[i] = s.Take()
			}(i)
		}
		wg.Wait()
		n := 0
		for _, g := range granted {
			if g {
				n++
			}
		}
		if n > 1 || s.Left() < 0 {
			over++
		}
	}
	if over > 0 {
		t.Fatalf("a quota of 1 was granted more than once in "+
			"%d/%d rounds\n\n"+
			"  Every access to s.quota is under the mutex, so\n"+
			"  -race is right to say nothing: there is no data\n"+
			"  race here. The check and the act are two critical\n"+
			"  sections, and eight callers can all pass the check\n"+
			"  before any of them acts. No tool can know that\n"+
			"  those two sections had to be one.",
			over, rounds)
	}
}

// Gate 3 -- FAILS. Nothing FetchAll starts may outlive it.
func TestFetchAllLeavesNothingRunning(t *testing.T) {
	before := runtime.NumGoroutine()

	slow := func() (int, error) {
		time.Sleep(20 * time.Millisecond)
		return 1, nil
	}
	replicas := []func() (int, error){
		slow, slow, slow, slow, slow, slow,
	}
	_, err := FetchAll(context.Background(), replicas)
	if err != nil {
		t.Fatal(err)
	}

	// Give every replica time to finish and try to send.
	time.Sleep(200 * time.Millisecond)
	after := runtime.NumGoroutine()
	if after > before {
		t.Fatalf("%d goroutines outlived FetchAll "+
			"(before=%d after=%d)\n\n"+
			"  The results channel holds one value. The first\n"+
			"  sender fills it, FetchAll returns, and nobody\n"+
			"  receives again. Every other replica blocks on its\n"+
			"  send for the life of the process. Size the channel\n"+
			"  for the work you started, not for the answer you\n"+
			"  wanted.",
			after-before, before, after)
	}
}

// Gate 4 -- FAILS. Poll must return when its context is cancelled.
func TestPollStopsOnCancel(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() {
		defer close(done)
		Poll(ctx, time.Millisecond, func() {})
	}()

	time.Sleep(20 * time.Millisecond)
	cancel()

	select {
	case <-done:
	case <-time.After(500 * time.Millisecond):
		t.Fatal("Poll ignored a cancelled context for 500ms\n\n" +
			"  Poll takes a ctx and never mentions it again. The\n" +
			"  signature promises cancellation; the body ranges\n" +
			"  over a ticker that nothing closes. A parameter you\n" +
			"  accept and do not use is a promise you never kept.")
	}
}

// Gate 5 -- PASSES on the starter, and constrains every fix. Refresh
// must not hold the lock across the dependency call.
func TestRefreshDoesNotHoldTheLock(t *testing.T) {
	const dependency = 300 * time.Millisecond
	s := New(0, func() int {
		time.Sleep(dependency)
		return 42
	})

	go s.Refresh()
	time.Sleep(20 * time.Millisecond) // Refresh is inside slow() now

	start := time.Now()
	_ = s.Cached()
	blocked := time.Since(start)

	if blocked > dependency/3 {
		t.Fatalf("Cached blocked for %v while Refresh was "+
			"running\n\n"+
			"  Refresh unlocks before calling the dependency on\n"+
			"  purpose. `defer s.mu.Unlock()` reads better and is\n"+
			"  wrong: it holds the lock for the whole call, so\n"+
			"  every reader waits on a dependency it does not\n"+
			"  use. See §18.6.3 -- a reader's deadline cannot\n"+
			"  interrupt sync.Mutex.Lock, so this is unbounded\n"+
			"  from outside.\n\n"+
			"  Not every unusual shape is a bug.",
			blocked.Round(10*time.Millisecond))
	}
}

The three bugs are one from each of §18.4's categories, which is the hint and also the method.

And Refresh unlocks explicitly before calling its dependency rather than deferring. That reads like a violation of the rule everybody has internalised. It is the fix for §18.6.3, and gate 5 exists to fail you for tidying it.

Gate
TestTheToolchainIsSatisfied
TestQuotaIsNeverOverdrawn
TestFetchAllLeavesNothingRunning
TestPollStopsOnCancel
TestRefreshDoesNotHoldTheLock
Measured the starter under -race, one run, quoted as it prints.
Terminal
$ $ go test -race ./...
$ --- FAIL: TestQuotaIsNeverOverdrawn (0.01s)
$ service_test.go:59: a quota of 1 was granted more than
$ once in 5/200 rounds
$ --- FAIL: TestFetchAllLeavesNothingRunning (0.22s)
$ service_test.go:91: 4 goroutines outlived FetchAll
$ (before=2 after=6)
$ --- FAIL: TestPollStopsOnCancel (0.52s)
$ service_test.go:118: Poll ignored a cancelled context
$ for 500ms

Gates 3 and 4 are deterministic — the same numbers on every run. Gate 2 is not. Measured, twenty-five runs under -race: it fired every time, at between 3 and 12 of 200 rounds, median 7.

Now run it without the flag, because this is the exercise’s first lesson and it arrives before you have changed a line. Measured, twenty-five plain runs of gate 2: it failed 4 times, at 1 or 2 rounds out of 200. Twenty-one runs said ok.

Sit with those two numbers next to each other. The bug is not created by -race; it is findable under -race. On a plain build it fires about one run in six, at a rate of one round in two hundred — and one failure in six runs, with nineteen digits of green either side of it, is precisely the shape that gets triaged as a flake, retried, and merged. That is §18.1's opening argument arriving inside your own test output rather than in somebody else’s incident.

Done when: go test -race ./... in code/ch18/ reports ok for all five, and keeps reporting it under -count=10. Because gate 2 is statistical, a single pass after a change is not proof. Run it ten times, under -race.
Two traps, and the second is the one this chapter exists for. The first is gate 2, where the instinct is to add a lock — and every access is already locked, which is why -race is clean and correct to be clean. The bug is the gap between two critical sections, and the fix removes a lock boundary rather than adding one. If you find yourself reaching for a second mutex, re-read §18.4.4.

The second is gate 5. Having just fixed three bugs, the natural next move is to tidy Refresh into the defer form every other method uses — and measured, that makes a concurrent Cached block for 280 ms against a 300 ms dependency, which gate 5 rejects. A reviewer who normalises every unusual shape removes the deliberate ones. A reviewer who flags everything is as useless as one who flags nothing, and this is the only gate in the book that tests judgement rather than correctness.

Where the files are: labs/go-concurrency/code/ch18/, both printed above. You should not need to edit service_test.go. solution/service.go.txt is a complete replacement for service.go — copy it over and all five gates pass under -race -count=5 — and it carries the reasoning in comments, including why gate 2's fix makes Take shorter rather than longer, and why Poll keeps its defer t.Stop() even though §17.2.3 retired that as a leak fix.

None of the three fixes is clever. Take needs one critical section instead of two. FetchAll needs len(replicas) instead of 1. Poll needs a select. That is the chapter in one sentence: the hard part was seeing them, not making them.

Further Reading

$GOROOT/src/cmd/go/internal/test/test.go — find defaultVetFlags and read the commented-out lines rather than the enabled ones. It is about forty lines including the comments, and it settles §18.2 in primary source rather than on anyone’s authority. The TODO(rsc) above it, and the issue it links, are the history of why the set is short. It also moves between releases (Go 1.27 added -stdversion and renamed the loop check to -loopclosure, which is why this chapter quotes the 1.27 excerpt), so re-check it against the toolchain you actually ship with rather than trusting this chapter’s excerpt.

go doc cmd/vet — the full analyzer list with one-line descriptions, worth reading once end to end. The list grows: waitgroup is recent, so §18.2.1's table is a snapshot. Several analyzers outside this chapter’s scope earn their keep on concurrent code, including unusedresult, which catches a discarded errgroup.Wait.

staticcheck's check index — the SA2xxx block is the concurrency family and it is short. Read SA2003 and then look for it in your own codebase; it is the check most likely to find something on the first run. SA2002 is §16.2.6's bug from the other tool’s angle.

golang.org/x/sync/errgroup — read two doc comments carefully. SetLimit says that Go blocks when the limit is reached, which is §18.6.2. WithContext says the returned context is cancelled when Wait returns, which is §18.6.6. Both bugs are about sentences everybody has read and nobody has remembered.

sync.Once — four sentences, one of which is that Do returns only after f has returned. §18.6.1 is what that means when f captured somebody’s deadline. golang.org/x/sync/singleflight is Once for things that can fail; the difference is that it forgets, which is §18.6.1's other fix.

golang.org/x/tools/go/analysis — if your team keeps getting the same protocol wrong, this is how you turn it into a check, and a useful one is often under a hundred lines. Carry §18.2.7's lesson into it: you will be writing a check for a shape, so choose the shape your codebase actually uses rather than the one the bug is famous in. If your team writes wg.Go, a check that looks for go statements will pass every file and prove nothing, which is exactly the trap both official tools fell into.

$GOROOT/src/testing/testing.go — Chapter 16 sent you here for FailNow's goroutine requirement. It is worth a second visit for a different reason: it is a well-reviewed concurrent codebase you can read with §18.7's questions in hand, and every channel in it has an obvious owner.

Chapter 10's Quick Reference — the deep, single-mechanism version of what §18.3 and §18.7.10 do broadly. When you already know the subject is locks, start there instead.

Your own repository, go vet ./... — the most useful item on this list. §18.2 measured what that command finds in a package built to be found. What it finds in yours is a better number, and you can have it in thirty seconds.

Next

You can now say which layer catches which bug and why the split falls where it does: the tools decide what one function can prove about itself, and everything else is a protocol, a lifetime or an invariant — three facts the source never states. You can enter the book from a symptom instead of a mechanism, recognise the misclassification family in a costume this chapter did not show you, and read a diff in an order that finds the most per minute. All of it happens before the program runs, with source in front of you. Chapter 19 changes the tense: goroutine dumps at scale, the two profiles that are empty until you say otherwise, the execution tracer, and how to have been recording the trace you needed.