Chapter 16: Testing Concurrent Code

Every chapter in this book has ended with an exercise, and every exercise has ended with a gate you had to make pass. Chapter 8's gate was -race. Chapter 13's counted goroutines. Chapter 15's waited for a drain and then checked that nobody was left running.

This chapter is about whether passing means anything.

That is not a rhetorical question. A sequential test that passes has demonstrated something: for these inputs, this code produced this output, and it will do so again. A concurrent test that passes has demonstrated that one interleaving out of an enormous number produced the right answer, once, on an idle laptop. The scheduler chose that interleaving. You did not, you cannot see which one it chose, and it will choose differently under load.

So the question this chapter answers is: given that a green test proves so little by default, what do you have to do to make it prove something?

Here are three tests. All three compile, all three vet clean, and all three pass.

small_race_test_16_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: passes 197 runs out of 200
func TestSmallRace(t *testing.T) {
    if got := racyCount(2, 20); got != 40 {
        t.Fatalf("counter = %d, want 40", got)
    }
}
handler_rejects_test_16_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the assertion cannot fail the test it is written in
func TestHandlerRejects(t *testing.T) {
    go func() {
        if got := serve(req); got != 503 {
            t.Fatalf("status = %d, want 503", got)
        }
    }()
    time.Sleep(50 * time.Millisecond)
}
worker_drains_test_16_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: green on a laptop, red on a loaded CI box
func TestWorkerDrains(t *testing.T) {
    w := start()
    w.Submit(job)
    time.Sleep(100 * time.Millisecond) // "long enough"
    if w.Done() != 1 {
        t.Fatal("job did not run")
    }
}

The first has a real data race and passes anyway — measured at 197 passes in 200 runs, which is worse than a coin toss, because a test that fails half the time gets fixed and a test that fails 1.5% of the time gets re-run. The second is the one this chapter’s §16.2 is about: t.Fatal from a child goroutine does not stop the test, so that assertion is decoration. The third is the most common concurrent test in existence, and its sleep is not synchronisation — it is a bet on a machine you do not own.

None of these is careless code. They are what a careful engineer writes before knowing the specific mechanics, and each one fails silently — which is the shape Chapter 14 named and Chapter 15 met at the process boundary, arriving here one last time in the place it does the most damage: the code whose entire job is to tell you the truth.

WHAT A GREEN CONCURRENT TEST ACTUALLY SAYS

A scatter of dots representing every interleaving a concurrent program could take. Most are unmarked, three are marked with a cross as orderings that would have failed, and one is marked with a filled circle and labelled as the one the scheduler happened to pick. The caption notes that running the test again picks another single dot rather than covering the space, and that the chapter is about shrinking the space until the dot you ran is the only dot there is.

What you’ll learn
  • Why a passing concurrent test is weak evidence, and the three distinct ways such a test lies
  • That t.Fatal from a child goroutine guards nothing, and that a stray assertion can fail a different test entirely
  • What a clean -race run does and does not prove — it is not timing-sensitive, and its blind spot is coverage
  • How testing/synctest makes a deadline-driven test deterministic — including a ten-minute context deadline, at no cost — and the exact boundary where the bubble stops working
  • Why goleak is a retry heuristic with a measurable cliff edge, when the bubble replaces it with a proof, and the one leak that neither of them sees
  • How to build seams for time, schedule and failure — including fault injection in a single closure — so that error paths can be tested at all
  • What stress testing actually buys you, which is not what its name suggests, and how to triage a red build without destroying the evidence
What we’re not covering
  • What a data race is, and how to read a WARNING: DATA RACE report — Chapter 8 covers both in full. This chapter starts where that leaves off and asks what a clean run is worth
  • Reading b.RunParallel output, and using -cpu deliberately — §11.2 has the measured table and the four rules. §16.7.6 covers only what it did not
  • The bug catalogue and the code-review checklist — Chapter 18
  • Diagnosing a failure once you have caught it: goroutine dumps, pprof, the execution tracer, delve — Chapter 19. This chapter builds the search that makes a failure reproducible inside a test suite; Chapter 19 takes it from there
  • Benchmarking for speed, profiling, and optimisation — Chapter 20
  • Fuzzing, property-based testing frameworks, and formal model checkers. §16.7.4 borrows one idea from that world and stays in the standard library
Building toward

Chapter 8 gave you the race detector and taught you to read its reports. Chapter 2 gave you goleak and Chapter 10 gave you the goroutine dump. Chapters 13 through 15 built things whose correctness is entirely about timing — a cancelled tree, a group that fails as one, a shutdown on a borrowed clock — and each one had to test itself with the tools available at the time. Chapter 15's exercise had to count goroutines by hand and use real short timeouts, and said so. This chapter is where those compromises stop being necessary.

Prerequisites

Chapter 8 throughout: what a data race is, why -race reports one, and §8.2's distinction between a data race and a race condition, which §16.3.4 turns into a measurement. Chapter 2's leak detection, because §16.5 is pitched directly above it. select and the done channel from Chapter 4, since every barrier in §16.6 is one. Context cancellation from Chapter 13. errgroup from §14.4, whose error-selection behaviour §16.6.4 tests. And Chapter 15's §15.7.7, which measured the one thing synctest cannot do and handed the rest here.

Which Go are we on?

Every listing and figure in this chapter was run on Go 1.25 or later. Four additions from Go 1.24 and 1.25 matter, and one removal — and Go 1.27 adds three more, below. Go 1.24 added testing.B.Loop, which supersedes the for i := 0; i < b.N; i++ form that every benchmark written before it uses, and T.Context, which is the sanctioned way to stop test-owned goroutines. Go 1.25 stabilised testing/synctest, the centrepiece of this chapter. And the removal is the trap: synctest shipped as a GOEXPERIMENT in Go 1.24 with an entry point called synctest.Run, and that function no longer exists. The stable API was two functions, Test and Wait; Go 1.27 adds a third, Sleep, which is time.Sleep followed by Wait (§16.4.5) — and two things outside the package that this chapter leans on: httptest.NewTestServer, an in-memory HTTP server that can live inside a bubble (§16.4.8), and the goroutineleak profile, a runtime leak detector that needs no bubble (§16.5.9). Go 1.26 also stopped B.Loop suppressing inlining in the loop body, so every b.N benchmark can be converted with no ill effects (§16.7.6). Nearly every article written about synctest during 2025 uses Run, so code copied from one will not compile. Check with go doc testing/synctest before you trust anything you read about it, including this chapter — go doc testing/synctest@go1.27.1 pins the version you are checking against.

Measured go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16, go.uber.org/goleak v1.3.0, golang.org/x/sync v0.22.0 (v0.23.0 at the time of this revision; the API used here is unchanged). Figures come from running the program as printed. This chapter’s measurements divide into two kinds and the text always says which. Some are behavioural — does this assertion fail the test, does this goroutine get reported, does the clock advance — and those reproduced identically on every run and are stated flatly. Others are statistical, because the whole subject is non-determinism: how often a racy test passes, how many rounds go wrong, how much -race costs. Those are reported as ranges over repeated runs, and where a proportion is the point, the run count is given so you can judge it. Where a timing and a mechanism disagree, trust the mechanism: those are quoted from the standard library source or from package documentation, with a name you can look up.

16.1 Why Concurrent Tests Lie

A test is an argument that code is correct. For sequential code the argument is strong: you fixed the inputs, so you fixed the execution, so a pass today is a pass tomorrow. Concurrency breaks the middle step. The inputs are fixed and the execution is not, because a second author — the scheduler — contributes to every run and never tells you what it wrote.

This section is about the three distinct ways that shows up, because they need different fixes and are constantly confused with each other.

16.1.1 A Test That Passes Ninety-Nine Times in a Hundred

Start with the worst case, which is not the test that fails.

racy_count_161.go
// Illustrative snippet — not a complete program
func racyCount(workers, iters int) int {
    counter := 0
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Go(func() {
            for j := 0; j < iters; j++ {
                counter++
            }
        })
    }
    wg.Wait()
    return counter
}

counter++ from two goroutines with no synchronisation is the first data race in Chapter 8, and every reader of this book can see it. The interesting part is what the test does about it.

Measured TestSmallRace asserts racyCount(2, 20) == 40. Over 200 runs of the compiled binary with no -race, it failed 3 times — a failure rate of 1.5%. The two goroutines each do twenty increments; the window in which they overlap is a few microseconds wide, and most of the time one finishes before the other starts. The rate moves with machine load, and single trials of forty runs routinely show 40 passes, which is part of the problem.

That number is the problem. A test that fails half the time is a bug report. A test that fails one run in forty is noise — it goes in the flaky bucket, gets a retry wrapper, and the data race ships. The severity of the bug and the frequency of the symptom are unrelated, and human triage keys on frequency.

Measured the same test under -race reported WARNING: DATA RACE on 10 runs out of 10.

That contrast is the strongest argument for the race detector in this book, and it is worth stating precisely, because §16.3 will spend a section on the limits: unaided, the bug surfaced 3 times in 200; instrumented, it surfaced 10 times in 10. The detector did not make the race more likely. It made the race visible, which is a different and much stronger property.

16.1.2 A Test That Fails Somewhere Else

The second failure mode is rarer and much more expensive, because the test that fails is not the test that is broken.

orphan_a_test_161_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: nothing waits for this goroutine
func TestOrphanA(t *testing.T) {
    go func() {
        time.Sleep(200 * time.Millisecond)
        t.Error("assertion from an orphaned goroutine")
    }()
}

The test body starts a goroutine and returns. The goroutine is still sleeping. Two hundred milliseconds later it calls t.Error on a *testing.T whose test finished long ago.

Measured with a slower test scheduled after it, the run produces this:
Terminal
=== RUN TestOrphanA
--- PASS: TestOrphanA (0.00s)
=== RUN TestLaterB
panic: Fail in goroutine after TestOrphanA has completed
goroutine 8 [running]:
testing.(*common).Fail(0x3c5b357d2248)
    /usr/local/.../src/testing/testing.go:969 +0xca

Read the two lines together. TestOrphanA is reported PASS. The run then dies inside TestLaterB, which is innocent, and the panic message is the only thing connecting the crash to its cause.

Your green test killed someone else’s.

And the ending depends on something with no relationship to either test.

Measured with no slow test scheduled afterwards, the binary finishes before the sleep expires, the goroutine is destroyed with the process, and the run reports ok with no indication that anything happened at all. Same code, same bug, three possible outcomes — pass silently, kill a stranger, or vanish — selected by how long the rest of the suite happens to take.

This is the exact mechanism behind a large fraction of tests that “only fail in CI”. CI runs more tests, on slower hardware, in a different order.

16.1.3 A Test That Never Finishes

The third mode produces no output at all, which makes it the one people debug last and longest.

A test that deadlocks does not fail. It stops. The Go runtime has a deadlock detector, but §10.4 established its limit precisely: it fires only when every goroutine is asleep, so a test that blocks forever while the rest of the suite runs is invisible to it. What eventually intervenes is the test binary’s own alarm.

Measured a test that blocks forever, run with -timeout 5s:
Terminal
panic: test timed out after 5s
    running tests:
        TestMutexIsNotDurable (5s)
goroutine 33 [running]:
testing.(*M).startAlarm.func1()
    /usr/local/.../src/testing/testing.go:2802 +0x34b

Two things are worth noticing. The running tests: block names the culprit and how long it has been stuck, which is more than the runtime’s detector gives you. And the price of that information is the full timeout — five seconds here, ten minutes at the default, per occurrence, on every CI run until someone fixes it.

§16.4 shows the same information arriving in 0.00 seconds, from a mechanism that does not need a timeout at all.

16.1.4 Schedules and Invariants

Three failure modes, three different fixes. What connects them is what the test was asserting on.

A test asserts on an invariant when it says something that is true of every correct execution: after Wait returns, the counter equals the number of increments. It asserts on a schedule when it says something that is only true of some executions: within 100 ms the worker will have finished, or by the time this line runs, that goroutine will have started.

Every flaky concurrent test is a test that asserts on a schedule. The sleep in the third opening listing is the obvious case, but §16.1.2's orphan is the same mistake wearing a different hat — it assumes the test outlives the goroutine, which is a claim about scheduling, not about correctness.

That gives the chapter’s thesis, and it has two halves because there are exactly two ways out:

A passing concurrent test is not evidence — it is one interleaving that happened to work. Own the schedule and it becomes proof.

Either stop asserting on the schedule, which means finding the invariant and asserting on that instead. Or take ownership of the schedule, which means making it an input to the test rather than a property of the machine. §16.4 and §16.6 are the two ways to do the second; §16.3 and §16.7 are what you do when neither is available.

16.1.5 The Determinism Ladder

Those options form a ladder, and knowing which rung you are on is most of the skill.

THE DETERMINISM LADDER

Four rungs, strongest at the top. Rung four is no concurrency in the assertion at all, testing the invariant on a sequential path, described as fastest and often possible. Rung three is virtual time using synctest, where the bubble owns the clock and the definition of everyone being blocked. Rung two is a forced schedule using barriers, seams and injected hooks, where you choose the order. Rung one is real time, sleeps and timeouts, a bet on the machine, sound only as an upper bound. The advice is to climb as far as the code allows; anything holding a socket stops at rung two.

Rung 4 deserves more attention than it gets. A great deal of code that runs concurrently can be tested sequentially, because the interesting logic is not the concurrent part. If a worker pool’s scheduling policy is a pure function of queue state, test that function directly with no goroutines at all, and use rung 2 or 3 only for the small amount of code that genuinely coordinates.

The most common mistake in this chapter’s subject is reaching for rung 1 when rung 4 was available.

16.1.6 Anatomy of a Flake

Abstractions are easier to believe with one worked example, so here is a flake taken apart. It is real, it is measured again in §16.6.4, and it is the shape most concurrent flakes take.

Two backends are queried in an errgroup. Both fail. The test asserts on the error.

query_161.go
// Illustrative snippet — not a complete program
func query(ctx context.Context) error {
    g, _ := errgroup.WithContext(ctx)
    g.Go(func() error { return errSlow })
    g.Go(func() error { return errFast })
    return g.Wait()
}
fast_backend_test_161_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: asserts on which goroutine returned first
func TestFastBackendErrorWins(t *testing.T) {
    err := query(context.Background())
    if !errors.Is(err, errFast) {
        t.Fatalf("got %v, want errFast", err)
    }
}
Measured 200 calls per run, three runs.
Terminal
no seam, 200 runs: errFast=198 errSlow=2
no seam, 200 runs: errFast=197 errSlow=3
no seam, 200 runs: errFast=196 errSlow=4

Now walk it through the people it passes.

The author ran it, it passed, they shipped it. At a 1–2% failure rate there is roughly a 90% chance that running it five times locally shows nothing.

Review saw an assertion on an error value, which is the most ordinary thing in a Go test. Nothing in the diff mentions concurrency.

CI fails one build in fifty. A large suite has many tests, so “one build in fifty fails somewhere” reads as infrastructure rather than as a specific bug.

The on-call engineer re-runs the build and it passes. The evidence is now gone: no seed, no output, no record that the failure named a different error than expected.

Six months later the assertion is deleted or wrapped in a retry, and with it goes the only signal anyone had that errgroup's first-error selection is a race the service’s error handling depends on.

Nothing in that chain is unreasonable, which is the point. (The split is lopsided rather than even for a specific scheduler reason, which §16.6.4 measures and names.) The failure is in the first step, and it is a specification failure: errors.Is(err, errFast) was never true, only usually true. §16.6.4 places a seam and the same measurement becomes 200 of 200, deterministically.

The tell.

When a test fails rarely and the failure message shows a plausible alternative — a different error, a reversed order, a count off by one — that is almost always a schedule assertion rather than a broken machine. Garbage values and nil dereferences suggest memory corruption or a lifecycle bug; plausible-but-different values suggest you assumed an ordering that was never guaranteed. The classification takes thirty seconds and it decides which half of this chapter you need.

16.1.7 What a Flake Is

One policy question, because it decides whether any of this machinery gets used.

A flaky test is a test that passes and fails on the same code. The instinct is to treat it as a defective test. Sometimes that is right — §16.1.4's schedule assertions are genuinely defective and the fix is in the test. But the other possibility is that the test is fine and it is telling you about a real race that fires rarely, which is precisely what §16.1.1 measured: a correct assertion, a real bug, a 3-in-200 symptom.

You cannot tell which from the failure rate. You have to look.

The working policy that follows: a flake is a bug report against the system until proven otherwise, and the proof is a mechanism, not a re-run. Quarantine it so it stops blocking the queue, keep it running so the data accumulates, and require someone to name the cause before it is deleted or retried. A retry wrapper added without that step converts a bug report into silence, which is the worst possible outcome — the information was there and you paid for it and then discarded it.

16.1.8 Common Mistakes

Treating pass rate as bug severity
Problem

A 1.5% data race is triaged as noise

Fix

Severity comes from the mechanism, not the frequency

time.Sleep to wait for a goroutine
Problem

Green on a laptop, red on loaded CI

Fix

A barrier (§16.6.2) or a bubble (§16.4)

Asserting from a goroutine nobody joins
Problem

The failure lands in an unrelated test

Fix

Assertions on the test goroutine (§16.2.7)

Adding a retry wrapper to a flake
Problem

A real race is silenced permanently

Fix

Quarantine, keep running, require a named cause

Testing concurrent code concurrently by reflex
Problem

Slow, weak tests for logic that is pure

Fix

Rung 4 — extract the invariant and test it directly

Relying on the runtime’s deadlock detector
Problem

It only fires when every goroutine sleeps

Fix

-timeout (§16.1.3), or a bubble (§16.4)

Running -race only in CI
Problem

The 1-in-70 failure arrives at review time

Fix

-race locally on the packages that own concurrency

Summary: Why Concurrent Tests Lie

A sequential test fixes the execution by fixing the inputs. A concurrent test cannot, because the scheduler contributes to every run and never records what it chose. So a pass is evidence about one interleaving, and the interleaving was not selected by you.

That produces three failure modes with three different fixes. A test can pass while the bug is present — measured, 197 passes in 200 runs against a real data race, where -race caught it 10 times in 10. A test can fail somewhere else entirely, because an orphaned assertion goroutine reports against whichever test is running when it wakes: measured, — PASS: TestOrphanA followed by a panic inside the innocent test that came after, or nothing at all if the binary exits first. And a test can simply stop, invisible to the runtime’s deadlock detector, until the binary’s own alarm fires — measured, five seconds to learn what §16.4 learns instantly.

What unites them is asserting on a schedule instead of an invariant. The way out is the determinism ladder: eliminate the concurrency from the assertion, or own the clock, or own the ordering, and treat real-time sleeps as what they are — a bet on hardware you do not control.

Self-Check Questions: Why Concurrent Tests Lie

A test with a genuine data race passes 197 runs in 200. A colleague argues this makes it a low-priority bug. What is wrong with the reasoning?

It confuses the frequency of the symptom with the severity of the defect, and those are unrelated for a data race.

The 1.5% rate is a property of this test — two goroutines, twenty increments each, on an idle machine. It says nothing about production, where the same code runs with more goroutines, more iterations, real contention, and a scheduler under load. The window that is a few microseconds wide here can be continuously open there.

Worse, a data race is not a bug that produces a wrong number and moves on. §8.4 established that a race on anything wider than a machine word can tear, and that the compiler is entitled to assume the race does not happen when it optimises. The observed symptom in a test — a count that is slightly low — is not a bound on the possible symptoms in production.

The frequency does have one real use: it tells you how hard the bug will be to reproduce without instrumentation, which is why the answer is to stop relying on reproduction. -race caught this one 10 times in 10.

TestOrphanA starts a goroutine that calls t.Error after the test returns. Why can the same code produce three different outcomes with no change to the test?

Because the outcome depends on what the rest of the suite is doing when the goroutine finally wakes, and the goroutine has no relationship to any of it.

If a slower test is running at that moment, t.Error reaches a testing.T whose test has completed, the testing package detects it, and the process panics with Fail in goroutine after TestOrphanA has completed — inside the innocent test.

If nothing is running because the binary has finished, the process exits and takes the sleeping goroutine with it. The failure never happens and the run reports ok.

And if the timing lands such that the goroutine wakes while TestOrphanA itself is somehow still active — for instance if the test were slower for an unrelated reason — the failure would attach correctly.

Three outcomes, selected by suite composition, test ordering and machine speed. This is why the rule in §16.2.7 is unconditional rather than a matter of style: an assertion whose effect depends on the schedule is not an assertion.

When is a real-time time.Sleep in a test acceptable?

When it is an upper bound on something that must not happen, never a lower bound on something that must.

The distinction is about which way the failure goes when the machine is slow. “Sleep 100 ms, then check the worker finished” breaks on a slow machine, because 100 ms was a guess about how fast the worker is. That is rung 1 used as synchronisation, and it is always wrong — a barrier gives you the same guarantee with no timing assumption at all.

“Sleep 100 ms, then check the worker has not started, because it should be waiting for a signal we never sent” is sound. A slow machine makes it slower, not wronger. The assertion is about absence, and waiting longer only strengthens it.

The same asymmetry governs timeouts (§16.6.3): a generous timeout used as a failure detector is sound and merely slow; a tight timeout used to establish ordering is a schedule assertion in disguise.

Key Takeaways

  • A concurrent test that passes has sampled one interleaving out of an enormous space, and the scheduler chose it
  • Pass rate is not bug severity — measured, a real data race surfaced in 1 run of 40 unaided and 10 of 10 under -race
  • An assertion from an unjoined goroutine can pass silently, kill an unrelated test, or vanish, depending only on how long the rest of the suite runs
  • A blocked test is invisible to the runtime’s deadlock detector, which fires only when every goroutine sleeps; the binary’s -timeout alarm is what eventually reports it
  • Every flaky concurrent test asserts on a schedule rather than an invariant
  • The determinism ladder — no concurrency, virtual time, forced schedule, real time — and you climb as high as the code allows
  • A flake is a bug report until someone names the mechanism; a retry wrapper added before that destroys the evidence
Section 16.1 — in one line

A green concurrent test is a sample, not a proof, and the whole craft is shrinking the space it sampled from until the sample is the whole space.

16.2 The testing Package Under Concurrency

The previous section assumed that when a test asserts, the assertion counts. For concurrent tests that assumption is often false, and the ways it fails are not documented anywhere a reader is likely to look. This section comes second because everything after it depends on it: there is no point owning the schedule if the assertion that checks the result cannot fail the test.

Four behaviours, each measured, each with the same root cause — testing.T is bound to one goroutine, and Go gives you no help remembering which.

16.2.1 t.Fatal Does Not Stop What You Think

The documentation for FailNow says it plainly, and almost nobody has read it:

From the testing.T.FailNow documentation

“FailNow must be called from the goroutine running the test or benchmark function, not from other goroutines created during the test. Calling FailNow does not stop those other goroutines.”

Fatal and Fatalf call FailNow. So this does not do what it looks like:

fatal_in_test_162_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the Fatal does not stop the test
func TestFatalInGoroutine(t *testing.T) {
    var wg sync.WaitGroup
    wg.Go(func() {
        t.Log("child: about to call t.Fatal")
        t.Fatal("failing from a child goroutine")
    })
    wg.Wait()
    t.Log("REACHED: line after the child called t.Fatal")
}
Measured running that test, with the child’s Fatal on line 13 and the test body’s next statement on line 16.
Terminal
=== RUN TestFatalInGoroutine
    b_test.go:12: child: about to call t.Fatal
    b_test.go:13: failing from a child goroutine
    b_test.go:16: REACHED: line after the child called t.Fatal
--- FAIL: TestFatalInGoroutine (0.00s)

The test does fail — Fatal marks it failed and that part works. What does not happen is the stopping. FailNow terminates its goroutine by calling runtime.Goexit, and the goroutine it terminates is the child. The test body carries straight on to the next line.

What it does stop is the child. FailNow ends its goroutine with runtime.Goexit, which runs that goroutine’s deferred calls and then destroys it — silently, as far as the rest of the test is concerned. That combination is worse than either half alone:

fatal_in_test_162_x_2.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the Fatal kills the child before it can send
func TestFatalInChildHangs(t *testing.T) {
    results := make(chan int)
    go func() {
        conn := pool.Get()
        if conn == nil {
            t.Fatal("no connection")  // Goexit: no send happens
        }
        results <- serve(conn)
    }()
    t.Log("status:", <-results)
}

The guard fires, the child dies, and the send on results never happens. The test goroutine is now blocked forever on a receive that has no sender left alive.

Measured the test does not report “no connection”. It reports this, four seconds later:
Terminal
panic: test timed out after 4s
    running tests:
        TestFatalInChildHangs (4s)

A clear failure with a written-out message has been converted into §16.1.3's third failure mode — a hang with no output — by a line whose entire purpose was to report the failure clearly. And the message itself does not survive either.

Measured without -v, no connection appears nowhere in the output — test logs are buffered per test and flushed when the test completes, and this test never completes. With -v it does appear, on the second line, above the panic rather than buried under it, followed by a 33-line dump.

So the failure message is not hard to find. It is discarded, by the very mechanism that was supposed to report it.

The fix is t.Error plus an explicit return, which is honest about what is happening, or better, moving the assertion out of the goroutine entirely (§16.2.7).

16.2.2 The Assertion That Blames Another Test

§16.1.2 measured the symptom. Here is the mechanism, because it explains a class of CI failures that otherwise look supernatural.

testing.T carries a done flag, set when the test function returns. Every call that records a result checks it:

From $GOROOT/src/testing/testing.go, in (*common).Fail

“if c.done { panic(”Fail in goroutine after “ + c.name + ” has completed“) }”

The panic is deliberate and it is the right design — the alternative is silently discarding a real failure. But notice what it costs. The panic fires on the orphaned goroutine, at whatever moment it happens to run, and it takes down the process. The test that was executing at that moment is the one whose output you will be reading.

WHERE THE FAILURE LANDS

Two adjacent test boxes. The left box, TestOrphanA, spawns a goroutine and returns, and is reported as PASS. An arrow carries the goroutine into the right box, TestLaterB, where after two hundred milliseconds it calls t.Error, which raises a panic and kills the run inside that innocent test. The caption notes that the name in the panic message is the only link back to the test that actually caused it.

The practical advice: when a CI run dies with Fail in goroutine after X has completed inside test Y, the bug is in X and Y is a bystander. Read the name in the message, not the name in the header. It is the one case in Go testing where the reported test and the responsible test are reliably different.

16.2.3 defer, t.Cleanup, and t.Parallel

t.Parallel changes when a subtest body runs, and that breaks the most natural way to write teardown.

The mechanism: calling t.Parallel signals that the subtest should be paused and resumed later, after the parent test function has returned. The parent’s t.Run calls therefore return immediately, the parent body finishes, and only then do the parallel subtests actually execute.

defer is scoped to the parent function. t.Cleanup is scoped to the parent test, which now ends later.

defer_vs_test_162_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: the fixture is closed before the subtests use it
func TestDeferVsParallel(t *testing.T) {
    open := true
    defer func() { open = false }()

    for _, n := range []string{"a", "b"} {
        t.Run(n, func(t *testing.T) {
            t.Parallel()
            t.Logf("sub %s sees open=%v", n, open)
        })
    }
}
Measured the full ordering, with a t.Cleanup added alongside the defer for comparison:
Terminal
=== PAUSE TestDeferVsParallel/a
=== PAUSE TestDeferVsParallel/b
    b_test.go:29: parent body end
    b_test.go:21: parent defer: fixture closed
=== CONT TestDeferVsParallel/a
    b_test.go:26: sub a sees open=false
=== CONT TestDeferVsParallel/b
    b_test.go:26: sub b sees open=false
    b_test.go:22: parent Cleanup ran

The defer runs fourth, before either subtest. The t.Cleanup runs last, after both. Every parallel subtest sees a fixture that has already been torn down, and if the fixture were a database handle rather than a boolean, the subtests would fail with an error that mentions a closed connection and says nothing about t.Parallel.

WHEN TEARDOWN ACTUALLY RUNS

A vertical timeline of a parent test with two parallel subtests. Both t.Run calls return immediately and the subtests are paused. The parent body ends, then the parent’s defer runs and closes the fixture, marked with a cross as the wrong place. Only then do subtest a and subtest b run, each seeing a closed fixture. Finally t.Cleanup runs, marked with a tick as the correct place. The caption explains that t.Parallel pauses a subtest until the parent function has returned, so defer is scoped to the function while t.Cleanup is scoped to the test.

So: inside any test that runs parallel subtests, teardown belongs in t.Cleanup, not in defer. This is not a style preference. The two run at different times and only one of them is correct.

There is a second interaction worth knowing, because it fails loudly rather than subtly. Some testing helpers mutate process-global state and are therefore incompatible with parallelism outright.

Measured a test calling t.Setenv and then t.Parallel panics rather than misbehaving quietly.
Terminal
testing: test using t.Setenv, t.Chdir, or
cryptotest.SetGlobalRandom can not use t.Parallel

A panic, not a silent misbehaviour — which is the right trade, and worth contrasting with the defer case above, where the same category of mistake produces no diagnostic at all.

The loop-variable bug is not on this list.

Table-driven parallel subtests were, for years, broken by a different problem: the loop variable was shared across iterations, so every subtest saw the last case. Go 1.22 changed loop-variable scoping and that bug no longer exists. It is still the first thing most articles warn about, and warning about it now trains readers to look for the wrong thing. §16.6.6 covers what actually goes wrong with table-driven concurrent tests today.

16.2.4 t.Context and the End of the Test

Go 1.24 added T.Context, which answers a question every previous chapter had to answer by hand: how does a test-owned goroutine know to stop?

From the testing.T.Context documentation

“Context returns a context that is canceled just before Cleanup-registered functions are called.”

“Just before” is load-bearing, and it is exactly the ordering you want.

Measured reading the same context from both places:
Terminal
body: ctx.Err()=<nil>
cleanup: ctx.Err()=context canceled

And the consequence that makes it useful — a worker parked on <-t.Context().Done() has already returned by the time cleanup runs, so a wg.Wait() in a t.Cleanup does not hang:

Terminal
body: worker still running: true
cleanup: worker had already stopped: true

That is the whole pattern for a test-owned goroutine:

worker_stops_test_162.go
// Illustrative snippet — not a complete program
func TestWorkerStops(t *testing.T) {
    var wg sync.WaitGroup
    wg.Go(func() {
        <-t.Context().Done()
        // ... shut down ...
    })
    t.Cleanup(wg.Wait)

    // ... the actual test ...
}

Compared with a hand-rolled context.WithCancel plus defer cancel(), this is shorter and, more importantly, it composes with the previous subsection: t.Cleanup is already the correct place for teardown under t.Parallel, and t.Context is cancelled to line up with it.

16.2.5 Assertion Helpers, and Where a Verdict Lives

Concurrent tests accumulate helpers faster than sequential ones — waiting for a condition, draining a channel, checking an invariant across several goroutines — and helpers are where §16.2.1's bug survives review, because a helper looks like a function rather than like an assertion.

Start with the mechanical part. t.Helper() marks a function as a helper so failures are reported at the caller’s line:

must_receive_162.go
// Illustrative snippet — not a complete program
func mustReceive(t *testing.T, ch <-chan int) int {
    t.Helper()   // report at the CALLER's line, not this one
    select {
    case v := <-ch:
        return v
    case <-time.After(5 * time.Second):
        t.Fatal("nothing on the channel after 5s")
        return 0
    }
}

Without it, every failure in a suite that uses mustReceive points at the same line of the same file, which tells you nothing. Two details make it reliable: call t.Helper before anything that can fail, including before a select that might time out, because it is not retroactive; and remember that it changes only the reported line, not which goroutine may call it.

Which is the part that matters here. The signature is the giveaway. A helper taking *testing.T announces a verdict and belongs on the test goroutine. A helper returning error computes a verdict and may be called from anywhere:

check_drained_162.go
// Illustrative snippet — not a complete program
func checkDrained(p *Pool) error {
    if n := p.Pending(); n != 0 {
        return fmt.Errorf("%d jobs still pending", n)
    }
    return nil
}

Now it composes. A goroutine can call it and send the result back; the test goroutine asserts. When you are unsure which form you want, write the second — it converts to the first in one line and never the other way round.

Some codebases encode this in names, so a caller can tell without reading the body: require* uses Fatal and is test-goroutine-only, check* uses Error or returns a value and is safe anywhere. Whatever the convention, the point is that “may I call this from a goroutine?” should be answerable from the call site.

This is why the chapter’s exercise is shaped the way it is.

Its Verify returns an error rather than calling t.Fatal, and that single decision is what makes its mutation gate possible: a check whose findings are values can be pointed at a second, deliberately broken implementation and asked whether it complains. A check whose findings are side effects on a *testing.T can only be run, never interrogated. Assertions-as-values is a testability property, not a style preference.

16.2.6 What go vet Catches, and What It Misses

There is a vet analyzer for exactly the bug in §16.2.1, which is good news with a sharp edge on it.

From go doc cmd/vet

“testinggoroutine — report calls to (*testing.T).Fatal from goroutines started by a test”

Measured it catches more than the obvious case. All three of these are reported:
Terminal
vetprobe_test.go:10:14: call to (*testing.T).Fatal from a
                        non-test goroutine
vetprobe_test.go:24:2: ... (check calls (*testing.T).Fatal)
vetprobe_test.go:30:2: ... (fn calls (*testing.T).Fatal)

The first is go func() { t.Fatal(...) }(). The second is go check(t), where the Fatal is one level down inside a helper — and vet names the helper. The third is go fn() where fn is a closure variable. It does not flag t.Errorf from a goroutine, which is correct: Errorf does not call FailNow and is safe there.

Now the edge.

Measured neither of these is reported:
wg_162.go
// Illustrative snippet — not a complete program
var wg sync.WaitGroup
wg.Go(func() { t.Fatal("not flagged") })       // Go 1.25

var g errgroup.Group
g.Go(func() error { t.Fatal("not flagged"); return nil })

The analyzer looks for go statements. sync.WaitGroup.Go and errgroup.Group.Go start goroutines by calling a method, so there is no go statement in the test’s syntax tree and nothing to flag.

That gap matters more than it would have two releases ago. wg.Go arrived in Go 1.25 and is now the idiomatic way to start a tracked goroutine — this book switched to it in Chapter 14 — and errgroup has been the recommended way to run a group of failing-capable tasks since Chapter 14 introduced it. So the two constructs this book has spent three chapters teaching you to prefer are precisely the two that defeat the check.

And one more, easy to be caught by:

Measured go test does not run this analyzer. The vet subset that go test runs automatically is a short list chosen to be fast and low-noise, and testinggoroutine is not on it. The check only fires when someone runs go vet explicitly. (Go 1.27 added stdversion to that short list, which tells you what kind of check qualifies: a fact about the source, never a heuristic.)

Taken together, the practical position is that vet is a useful backstop for the shape a newcomer writes and no help at all for the shape an experienced Go programmer writes. Run go vet ./... in CI as its own step rather than relying on go test to do it — and do not let a clean vet run substitute for the rule below.

16.2.7 The Rule

Four behaviours, one rule:

Assertions live on the test goroutine. Children report over a channel.

Everything in this section is a consequence. t.Fatal from a child does not stop the test because testing.T belongs to the test goroutine. An orphan blames another test because it outlived the goroutine it was bound to. Cleanup ordering is a question about when the test ends, not when a function returns.

In practice:

handler_rejects_test_162.go
// Illustrative snippet — not a complete program
func TestHandlerRejects(t *testing.T) {
    type result struct {
        status int
        err    error
    }
    results := make(chan result, 1)   // buffered: never block a child

    go func() {
        status, err := serve(req)
        results <- result{status, err}
    }()

    select {
    case r := <-results:
        if r.err != nil {
            t.Fatal(r.err)         // on the test goroutine: works
        }
        if r.status != 503 {
            t.Fatalf("status = %d, want 503", r.status)
        }
    case <-time.After(2 * time.Second):
        t.Fatal("handler did not return")
    }
}

Three things are deliberate. The channel is buffered, so a child that finishes after the test has given up does not block forever holding a goroutine — an unbuffered channel here converts a test failure into a goroutine leak. The select has a timeout arm, used as a failure detector rather than for ordering (§16.6.3). And every t.Fatal is lexically inside the test function, where it does what it says.

The pattern generalises to any number of children: send a result per child, receive that many, and assert once you have them all. §16.6.2 shows the barrier version for the cases where you need to control when a child proceeds rather than just collect what it produced.

16.2.8 Common Mistakes

t.Fatal inside a child goroutine
Problem

The code after it runs anyway; a guard guards nothing

Fix

t.Error + return, or report over a channel

Assertion in a goroutine nobody joins
Problem

Fail in goroutine after X has completed, in test Y

Fix

Join every goroutine the test starts

Reading the header instead of the panic message
Problem

Debugging the innocent test

Fix

The name in the message is the guilty test

defer teardown in a test with parallel subtests
Problem

Subtests see a closed fixture

Fix

t.Cleanup

t.Setenv alongside t.Parallel
Problem

Panic naming both

Fix

Drop the parallelism, or isolate the env-dependent case

Unbuffered result channel from a child
Problem

A timed-out test leaks the goroutine it abandoned

Fix

Buffer it to the number of sends

Hand-rolled cancel for test-owned goroutines
Problem

Cleanup ordering has to be maintained by hand

Fix

t.Context() plus t.Cleanup(wg.Wait)

Warning readers about loop-variable capture
Problem

Attention spent on a bug Go 1.22 removed

Fix

§16.6.6's actual failure modes

A Fatal-ing helper called from a goroutine
Problem

The verdict cannot fail anything

Fix

Helpers taking *testing.T are test-goroutine-only

t.Helper() after the code that can fail
Problem

Not retroactive; the line is still wrong

Fix

Call it first

Trusting go test's vet to catch t.Fatal in a goroutine
Problem

The analyzer is not in its default subset

Fix

A separate go vet ./... step in CI

Assuming vet covers wg.Go / errgroup.Go
Problem

Measured, neither is flagged — it looks for go statements

Fix

The rule below, not the tool

Summary: The testing Package Under Concurrency

testing.T is bound to the goroutine running the test, and the language provides no reminder. Four consequences follow, and none of them is discoverable by reading test code that looks correct.

t.Fatal from a child marks the test failed but does not stop it — measured, the line after the child’s Fatal executed. A guard clause written that way does not guard. An assertion from a goroutine the test never joined lands wherever it happens to land: measured, — PASS for the guilty test and a panic inside the innocent one that followed, or total silence when the binary exits first. Under t.Parallel, subtests run after the parent body returns, so measured, a defer teardown fires before every subtest and a t.Cleanup fires after all of them. And t.Context is cancelled just before cleanup, which makes t.Cleanup(wg.Wait) the complete idiom for a test-owned goroutine.

There is a vet analyzer for the first of these, and it has a gap worth knowing. Measured, go vet reports t.Fatal inside go func(), go helper(t) and go fn() — but not inside wg.Go or errgroup.Go, because it looks for go statements and those start goroutines by calling a method. Those are the two forms this book has taught since Chapter 14. And measured, go test does not run that analyzer at all.

One rule covers all of it: assertions live on the test goroutine, children report over a buffered channel.

Self-Check Questions: The testing Package Under Concurrency

A test spawns a goroutine that does if conn == nil { t.Fatal("no connection") } and then sends the result on an unbuffered channel the test is waiting to receive from. What does the test report when the connection is nil?

panic: test timed out, after the full -timeout duration — and the words “no connection” do not appear at all unless the run used -v. Test logs are buffered per test and flushed when the test completes, and this test never completes, so the message the guard was written to produce is discarded with the buffer.

t.Fatal calls FailNow, which marks the test failed and then calls runtime.Goexit on the calling goroutine. From a child that means the child stops — its deferred calls run and it is destroyed — while the test goroutine is entirely undisturbed. It is still blocked on <-results, and the only goroutine that was ever going to send there no longer exists.

So the guard did fire, and the failure was recorded, and the test still hangs. The information was captured and then made almost impossible to find, because the test’s visible outcome is a timeout rather than an assertion failure.

This is the sharpest reason the rule in §16.2.7 is unconditional. It is not that t.Fatal in a child is merely ineffective — it is that it converts a clean, immediately-diagnosable failure into §16.1.3's worst failure mode, and does so on the path that only runs when something has already gone wrong.

You are debugging a CI failure: panic: Fail in goroutine after TestPoolResize has completed, reported while TestQueueDrain was running. Where is the bug, and why did it appear only now?

The bug is in TestPoolResize, which starts a goroutine that outlives it and asserts on a completed testing.T. TestQueueDrain is a bystander — it was simply the test executing when the orphan woke up.

It appeared “only now” because the outcome depends on suite timing, not on either test. For the panic to fire, some test has to still be running when the orphan calls into testing. Anything that changes that window changes the outcome: adding a slower test, reordering with -shuffle, running on a slower CI machine, or running with -race, which makes everything take longer and therefore makes the window much easier to hit.

That last one is worth remembering — turning on -race frequently surfaces orphan-assertion bugs that have been latent for months, and they look like the race detector found something when it did not.

The fix is in TestPoolResize: join the goroutine, or give it t.Context() so it stops when the test does.

Why is t.Cleanup correct for teardown under t.Parallel when defer is not, given that both run “at the end”?

Because they end different things. defer is scoped to the function; t.Cleanup is scoped to the test, and t.Parallel makes those two moments different.

Calling t.Parallel in a subtest pauses it and schedules it to resume after the parent’s function body returns. So the parent’s t.Run calls return immediately, having started nothing, and the parent function completes with all of its subtests still pending. Its defer fires there — before any parallel subtest has run.

t.Cleanup functions are held until the test and all of its subtests are genuinely finished, which is after the parallel children complete. Measured, the full order was: parent body end, parent defer, subtest a, subtest b, parent cleanup.

The practical consequence is that the failure is not a hang or a panic but a confusing error from inside the subtest — a closed database handle, a deleted temp directory — with nothing in the message pointing at parallelism.

Key Takeaways

  • testing.T is bound to one goroutine, and nothing in the language reminds you which
  • t.Fatal from a child marks the test failed but does not stop it — measured, the following line ran
  • The testing package panics on an assertion after a test completes, and the panic lands in whichever test was running: read the name in the message, not the header
  • Under t.Parallel, subtests run after the parent body returns — defer teardown fires too early, t.Cleanup fires correctly
  • t.Setenv and t.Chdir panic when combined with t.Parallel, which is a better outcome than the silent defer case
  • t.Context() is cancelled just before cleanup, making t.Cleanup(wg.Wait) the complete idiom for a test-owned goroutine
  • go vet's testinggoroutine analyzer catches go func, go helper(t) and go fn() — but measured, misses wg.Go and errgroup.Go, the two forms this book taught you to prefer
  • Measured, go test does not run that analyzer; it needs its own go vet ./... step
  • A helper taking *testing.T announces a verdict and is test-goroutine-only; one returning error computes a verdict and is safe anywhere
  • The rule: assertions live on the test goroutine, children report over a buffered channel
  • Loop-variable capture is not on this list; Go 1.22 fixed it, and warning about it now misdirects attention
Section 16.2 — in one line

A testing.T belongs to one goroutine, and every concurrent-testing bug in this section is the same bug — using it from somewhere else.

16.3 The Race Detector as a Test Instrument

Chapter 8 taught the race detector as a tool: how to turn it on, how to read a WARNING: DATA RACE report, what the two stack traces mean, how to wire it into CI. This section asks a different question, and it is the one that decides how you use it: what is a clean -race run actually worth as evidence?

The answer is unusually precise, which is rare in this subject. The detector’s guarantees and its blind spots are both exact, and knowing them changes what you do with the rest of your testing budget.

16.3.1 What a Clean Run Proves

The race detector implements a happens-before algorithm. It maintains a vector clock per goroutine, records every synchronisation event that establishes an ordering — channel operations, mutex operations, WaitGroup waits, atomic operations — and for every memory access it checks whether the previous access to that address is ordered with respect to this one. If two accesses touch the same address, at least one is a write, and no happens-before edge connects them, it reports a race.

Two properties follow, and they are asymmetric.

No false positives. If the detector reports a race, there is a race. It is not a heuristic, and a report is never something to argue with — it is a proof that two unordered accesses occurred. This is a stronger guarantee than almost any other tool in the Go toolchain provides, and it is the reason a -race report should always outrank a passing test in your priorities.

Abundant false negatives. A clean run proves only that the accesses that executed on this run were properly ordered. It says nothing about accesses that did not execute. This is not a limitation of the implementation; it is inherent to a dynamic analysis, and it is where every practical mistake with the tool comes from.

The next three subsections make that asymmetry concrete, because the intuitions people bring to it are usually wrong in specific ways.

16.3.2 It Does Not Care About Timing

The most common misconception is that the race detector catches races when two goroutines happen to collide, and misses them when the timing is lucky — that it is a sampling tool whose hit rate depends on how tight the window is.

That is false, and it is worth measuring rather than asserting.

race_wide_test_163.go
// Illustrative snippet — not a complete program
// Two writes to x, 50ms apart in wall-clock time.
func TestRaceWideApart(t *testing.T) {
    x := 0
    var wg sync.WaitGroup
    wg.Go(func() { x = 1 })
    wg.Go(func() {
        time.Sleep(50 * time.Millisecond)
        x = 2
    })
    wg.Wait()
    _ = x
}

The two writes are fifty milliseconds apart. On a 3.8 GHz machine that is roughly two hundred million cycles — they could not overlap if they tried.

Measured WARNING: DATA RACE, on the first run, every run.

The reason is the whole design. A time.Sleep is not a synchronisation operation, so it creates no happens-before edge. The detector does not ask “did these overlap in time?” — it asks “is there a chain of synchronisation events ordering these two accesses?” There is not, so they race, and the fifty milliseconds are irrelevant.

This has a direct practical consequence that surprises people: you cannot hide a race from the detector by adding sleeps, and you cannot help the detector find one by removing them. Timing pressure is the wrong lever entirely. §16.3.3 shows what the right one is.

One nuance keeps that statement honest, and it is a genuinely useful fact in its own right. Detection is not timing-sensitive — but enabling the detector changes which interleavings your program takes, because the race build deliberately perturbs the scheduler:

From $GOROOT/src/runtime/proc.go

“const randomizeScheduler = raceenabled”, and in runqput: “if randomizeScheduler && next && randn(2) == 0”

Under -race, the runnext fast path from §16.6.4 is skipped at random half the time.

Measured the errgroup pair from §16.6.4, where the second-registered goroutine normally wins 197–200 times out of 200.
Terminal
plain -> second-registered wins: 197, 198, 199, 200 / 200
-race -> second-registered wins: 104, 107, 108, 121 / 200

A structural 99% bias becomes a near coin toss. So -race is two tools in one wrapper: a happens-before analyser that reports what it sees, and a partial scheduling fuzzer that changes what there is to see.

It is worth being precise about which of the two earned §16.1.1's result, because the tempting answer is wrong. Run that racy counter under -race and print the total alongside the report:

Measured six runs of TestSmallRace under -race.
Terminal
run 1: counter=40 race reports=1
run 2: counter=40 race reports=1
...
run 6: counter=40 race reports=1

The counter is correct every time — no update was lost, so the two goroutines never meaningfully overlapped — and the race is reported anyway, six times out of six. None of that 10-of-10 came from the fuzzer. It came from the analyser, doing exactly what §16.3.2 said it does: reporting two unordered accesses because they executed, regardless of how far apart.

Which places the fuzzer correctly. It buys you nothing for data races, where the analyser already sees everything that runs. It buys you the other half of the chapter’s bug population — the race conditions of §16.3.4 that contain no data race, and the interleaving search of §16.7 — because those depend on ordering and nothing else in the toolchain perturbs it.

It also means a -race run is not a slower version of the same execution. It is a different sample of the schedule, which is a second, independent reason to run it.

Why the detector still misses races in practice.

Given the above, the folklore that “-race only catches it sometimes” has to come from somewhere, and it does — from §16.3.3. The variable that matters is not when the accesses happen but whether they happen at all. A run that never takes the branch containing the racy write gives the detector nothing to analyse, and no amount of repetition or timing pressure changes that.

16.3.3 It Only Sees What Ran

Here is the blind spot, in the form it actually takes in a real codebase.

cache_163.go
// Illustrative snippet — not a complete program
type cache struct {
    mu   sync.Mutex
    hits int
    cold int  // written without the lock, on the miss path only
}

func (c *cache) get(miss bool) {
    if miss {
        c.cold++      // RACE -- but only on the miss path
        return
    }
    c.mu.Lock()
    c.hits++
    c.mu.Unlock()
}

A cache with a correctly-locked hot path and a racy cold path. The test suite, like most test suites, mostly exercises the hot path.

Measured eight goroutines on the hit path only, under -race, repeated fifty times:
Terminal
$ $ go test -race -run TestFastPathOnly -count=50
$ ok corebackend.dev/go-concurrency/ch16 1.914s

Fifty clean runs. Now one run of the miss path:

Terminal
$ $ go test -race -run TestSlowPath -count=1
$ WARNING: DATA RACE

One run finds instantly what fifty could not find at all.

This is the single most important fact about the race detector as a test instrument, and it inverts the usual advice. -count=50 bought nothing here — not because fifty was too few, but because repetition was the wrong axis. The detector needed the miss path to execute once. It needed coverage, not iterations.

WHAT THE RACE DETECTOR CAN SEE

A two by two grid crossing whether memory accesses were ordered against whether they executed. Ordered and executed is clean and correct. Unordered and executed is always REPORTED. Both cells in the not-executed column are INVISIBLE to the detector. The caption states that the left column is a proof, the right column is why a clean run is not one, and that code moves from right to left with coverage rather than with repetition.

The practical rule: treat -race as a multiplier on your test coverage, not as a test in itself. A package with 40% branch coverage running under -race is checking 40% of its code for races. The way to find more races is the same as the way to find more bugs of any other kind — execute more of the program.

16.3.4 What It Cannot See At All

Coverage explains the races the detector misses. There is a second category it cannot see at any coverage level, and §8.2 named it: a race condition is not a data race.

account_163.go
// Illustrative snippet — not a complete program
func (a *account) withdraw(n int) {
    a.mu.Lock()
    ok := a.balance >= n    // check
    a.mu.Unlock()
    if !ok {
        return
    }
    a.mu.Lock()
    a.balance -= n          // act
    a.mu.Unlock()
}

Every access to balance is under the mutex. There is no data race here, and the detector is correct to say so. There is still a bug: the check and the act are separate critical sections, so two goroutines can both read a sufficient balance before either subtracts.

Measured eight goroutines each withdrawing 100 from a balance of 100, 200 rounds per run, under -race. The balance went negative in 5 to 13 rounds out of 200 across nine runs — and the run reported:
Terminal
--- PASS: TestCheckThenAct (0.01s)
ok corebackend.dev/go-concurrency/ch16 1.376s

Clean under the detector, wrong in a few percent of rounds — and the rate moves with load, which is exactly the property that makes it survive review.

This is the boundary of the tool, stated as sharply as it can be stated: -race verifies that your synchronisation is present. It cannot verify that your synchronisation is sufficient. Holding a lock for each individual access is exactly what the detector checks for, and exactly what is wrong here — the invariant needs one critical section spanning both operations, and no dynamic analysis of memory access ordering can know that.

Race conditions of this shape are found by the techniques in §16.7.4, by tests written against an invariant rather than an access pattern, and by code review (Chapter 18). Not by -race.

16.3.5 What It Costs

“Always run -race in CI” is good advice given as a slogan, which means the people who most need to weigh it get no help. The cost is real, it varies by an order of magnitude across workloads, and the variation is predictable.

Measured three workloads, plain against -race, three runs each at -benchtime 300ms:
Workload
Pure compute, nothing shared
Uncontended mutex, serial loop
Contended mutex, RunParallel
Channel ping-pong

The top row is the one that decides budgets, and it is the row usually left out. Code that shares nothing pays nothing — a loop doing arithmetic on local variables costs the same instrumented as it does plain. Your JSON parsing, your business logic, your template rendering: free.

The rest of the ordering is the opposite of what most people guess. The cheapest synchronising operation shows the worst multiplier.

The three synchronising factors move with machine load — the contended row has measured anywhere from 9× to 14× across sessions — so treat them as an order of magnitude rather than a constant. The ordering is the load-bearing part and it reproduces every time.

The reason is that the detector’s cost is roughly fixed per synchronisation event and per memory access, not proportional to what your code does between them. An uncontended Lock/Unlock pair around a single increment is almost pure synchronisation — there is no real work to dilute the instrumentation, so the overhead is the whole measurement. Channel ping-pong already spends most of its time in scheduler machinery that the detector does not multiply, so the same instrumentation is a much smaller fraction.

Which gives a rule you can apply without measuring your own code first: -race costs the most on the code that synchronises most per unit of work, which is exactly the code you most want to run under it. The tax is highest where the value is highest.

Do not use these numbers to predict your suite’s runtime.

These are microbenchmarks chosen to isolate the instrumentation cost. A real test suite spends most of its time on I/O, setup, process startup and assertions, none of which the detector touches. Suites commonly slow down by far less than any row in that table. Measure your own, once, and then decide — the point of the table is the shape of the cost, not its magnitude.

Memory matters too, and is less often mentioned: the detector allocates shadow memory proportional to the memory your program touches, so a test with a very large working set can need several times its usual footprint.

The 8,192-goroutine limit is folklore now.

Older write-ups warn that the race detector tracks a bounded number of goroutines and aborts past it. Measured: 150,000 simultaneously-live goroutines, all blocked on one gate, ran under -race and passed in 1.88 s. If you have avoided -race on a high-goroutine test because of that advice, re-test it.

16.3.6 Exit Codes and CI

One detail that decides whether your CI actually enforces any of this.

Measured the exit code of a program that races depends on how it was launched.
How it was run
Binary built with go build -race
go run -race
go test -race

The 66 is the one that catches people. It is the race detector’s own exit code, chosen to be distinctive, and it appears when you run a -race binary directly — which is what a CI job does when it builds an artifact and executes it, and what an integration-test harness does when it launches your service as a subprocess.

A shell script that checks if [ $? -eq 1 ] will conclude that a 66 means success. A supervisor that restarts on non-zero will restart in a loop without reporting why. And a docker run whose healthcheck only inspects stdout will see a crash with no explanation, because the WARNING: DATA RACE report goes to stderr.

The practical form: check for non-zero, not for a specific value, and make sure stderr from -race binaries is captured and surfaced. If you run your service under -race in an integration environment — which is an excellent idea and finds races no unit test will — this is the difference between the practice working and it silently doing nothing.

For the per-PR versus nightly split that follows from §16.3.5's cost data, see §16.7.7, which sets it out alongside the other knobs.

16.3.7 Common Mistakes

Believing -race is timing-sensitive
Problem

Sleeps added or removed to “help” it

Fix

It is happens-before based; measured, a 50 ms gap is still reported

-count=N to find more races
Problem

Cost with no benefit on paths already covered

Fix

Add coverage, not iterations (§16.3.3)

Reading a clean run as “no races”
Problem

Untested branches ship unchecked

Fix

It proves only that executed accesses were ordered

Expecting -race to catch check-then-act
Problem

A logic race passes cleanly

Fix

Measured, negative in 5–13 of 200 rounds, reported PASS

Arguing with a -race report
Problem

Time lost; the report was right

Fix

No false positives — a report is a proof

Quoting one overhead factor for all code
Problem

Wildly wrong estimates either way

Fix

It ranges from nothing to 26×, by synchronisation density

CI checking for exit code 1
Problem

A -race binary exits 66 and looks like success

Fix

Check for non-zero; capture stderr

Summary: The Race Detector as a Test Instrument

The detector’s guarantees are asymmetric and both halves matter. A report is a proof — no false positives, never worth arguing with. A clean run is not a proof of anything beyond the accesses that actually executed.

Two intuitions need correcting. It is not timing-sensitive: measured, two writes fifty milliseconds apart with no happens-before edge are reported on the first run, because a sleep is not a synchronisation event. And its blind spot is coverage: measured, fifty -race runs of a hot path found nothing, while one run of the cold path found the race instantly. Repetition is the wrong axis; coverage is the right one.

It also cannot see race conditions that contain no data race. Measured, a check-then-act withdrawal with every access correctly locked drove the balance negative in 5–13 rounds out of 200 and reported PASS. The detector verifies that synchronisation is present, not that it is sufficient.

The cost ranges from nothing — code that shares nothing pays 1.02× — up to about 26×, ordered inversely to how much real work each operation does, so it is most expensive exactly where it is most valuable. And a -race binary exits 66, not 1, which is how the practice silently stops working in CI.

Self-Check Questions: The Race Detector as a Test Instrument

Your team runs go test -race -count=100 nightly on a package and has never seen a report. What have you established, and what have you not?

You have established that on the code paths your tests execute, every memory access was properly ordered — one hundred times over. That is a real result and it is worth having.

You have not established that the package is race-free, and the hundred runs contributed almost nothing to the parts you have not established.

The reason is §16.3.3: a race is reported when the two conflicting accesses execute without an ordering between them. If your tests never take the branch containing the second access, the detector has nothing to compare and repetition does not change that. Measured, fifty runs of a hot path missed a race that one run of the cold path found immediately.

So the honest reading of “100 clean runs” is: “the covered fraction of this package is race-free.” The number that qualifies that claim is your branch coverage, not your run count. If coverage is 60%, you have checked 60% of the package a hundred times and 40% of it zero times.

What the hundred runs do buy is a different thing entirely — interleaving diversity for race conditions that -race cannot see (§16.3.4). That is a real benefit, but it belongs to §16.7's argument, not to the race detector’s.

A colleague adds time.Sleep(10 * time.Millisecond) between two goroutine launches “so the race detector has a better chance of seeing the interleaving.” What happens?

Nothing useful, and possibly something harmful.

The detector does not observe interleavings; it observes synchronisation edges. Two accesses race if no chain of synchronisation events orders them, and time.Sleep creates no such chain. Measured, two writes separated by fifty milliseconds — five times the proposed sleep — were still reported on the first run.

So the sleep does not improve detection. What it can do is reduce it, by changing which code paths run. If the sleep lets one goroutine finish its work before the other starts, a piece of logic that only executes under contention — a retry, a slow-path fallback, a queue-full branch — may now never execute at all. Per §16.3.3, code that does not execute is invisible to the detector, so the sleep can move a racy path from “checked” to “unchecked”.

The correct lever for finding more races is coverage: write a test that takes the other branch. The correct lever for finding more race conditions is §16.7's, and it is -count and -cpu, not sleeps.

Your CI builds a -race binary, runs it against a smoke-test suite, and reports success. A developer later reproduces a data race in that exact path locally. What is the most likely explanation?

The CI job is almost certainly checking the wrong exit code, and the race was detected and reported all along.

Measured, a binary built with go build -race exits with 66 when it detects a race, not 1. Scripts that test $? -eq 1, or that treat one specific non-zero value as the failure case, classify 66 as something other than failure.

To be clear about the common case, because the 66 alarms people unnecessarily: measured, go test -race exits 1 on a detected race, and go run -race exits 1 too. Any ordinary CI runner — anything using set -e, or GitHub Actions, GitLab or Jenkins step semantics — fails the build correctly on all three, because all three are non-zero. The 66 only bites bespoke logic that inspects the value.

The second possibility, and it compounds the first, is that the WARNING: DATA RACE report went to stderr and the job only captures or displays stdout. So even a human looking at the log sees a normal-looking run.

The fix is both halves: treat any non-zero exit as failure, and capture stderr. It is worth testing this deliberately by introducing a race on purpose once and confirming the pipeline goes red — an untested failure path in CI is exactly as trustworthy as an untested failure path anywhere else.

Key Takeaways

  • A -race report is a proof — no false positives; a clean run proves only that executed accesses were ordered
  • The detector is happens-before based, not window based: measured, writes 50 ms apart are still reported on run 1
  • Its blind spot is coverage — measured, 50 runs of a covered path found nothing where 1 run of an uncovered path found the race immediately
  • Repetition is the wrong axis for finding data races; branch coverage is the right one
  • It cannot see a race condition with no data race: measured, check-then-act went negative in 5–13 of 200 rounds and passed
  • Overhead ranges from nothing (1.02× on code that shares nothing) to ~26×, worst on code that synchronises most per unit of work — most expensive where it is most valuable
  • A -race binary exits 66, not 1, and its report goes to stderr; CI that checks for 1 silently passes every race
Section 16.3 — in one line

A -race report is a proof and a clean -race run is a coverage report — which is why the way to find more races is to execute more code, not to run the same code more times.

16.4 Determinism: testing/synctest

Everything so far has been about living with non-determinism: recognising it, measuring how much a green test proves, working out what the race detector does and does not cover. This section is about removing it.

testing/synctest went stable in Go 1.25. It is the largest change to testing concurrent code in Go’s history, and the reason is a single design decision: inside a synctest bubble, the runtime knows when every goroutine is blocked, so it can advance a fake clock instead of waiting. A test with a thirty-second timeout runs in no time at all, and it runs the same way every time.

Chapter 15 used it for one measurement and forwarded the rest here. This is the rest.

16.4.1 The Bubble

The entire API is three functions, and two of them were the whole package until Go 1.27.

test_164.go
// Illustrative snippet — not a complete program
func Test(t *testing.T, f func(*testing.T))
func Wait()
func Sleep(d time.Duration)   // Go 1.27

That is all of it. Test runs f inside a new bubble; every goroutine started within it joins the bubble. Wait blocks until every other goroutine in the bubble is durably blocked. Sleep, added in Go 1.27, advances the bubble’s clock by d and then waits for quiescence — time.Sleep(d) followed by Wait() in one call. Everything below is written in terms of Test and Wait; where a listing sleeps and then waits, Sleep is the shorter spelling.

synctest.Run no longer exists.

The package shipped in Go 1.24 behind GOEXPERIMENT=synctest with an entry point called Run, taking a bare func(). When it stabilised in 1.25 that function was removed and replaced by Test, which takes the *testing.T. Nearly every article, talk and blog post written about synctest during 2025 uses Run, so code copied from one will not compile against a current toolchain — and the error message, an undefined symbol, does not suggest the fix. Confirm the surface yourself with go doc testing/synctest before trusting any external source, including this chapter.

The *testing.T handed to f is not quite the one you are used to, and the differences all follow from the bubble being a closed world:

That last restriction is worth pausing on. A bubble is a single deterministic world; subtests and parallelism would mean several, which is precisely what determinism rules out. Table-driven tests still work — you write the loop outside, with one synctest.Test call per case (§16.6.6).

16.4.2 Virtual Time

Inside a bubble, the time package is backed by a fake clock. Each bubble has its own, and it starts at the same instant every time.

From the testing/synctest documentation

“Within a bubble, the time package uses a fake clock. Each bubble has its own clock. The initial time is midnight UTC 2000-01-01.”

The rule that makes it useful: time only advances when every goroutine in the bubble is durably blocked. When that happens, the clock jumps straight to the next moment that would unblock someone. Nothing waits.

virtual_time_test_164.go
// Illustrative snippet — not a complete program
func TestVirtualTime(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        start := time.Now()
        time.Sleep(30 * time.Second)
        t.Logf("virtual elapsed=%v", time.Since(start))
    })
}
Measured the listing above, run with -v.
Terminal
=== RUN TestVirtualTime
    a_test.go:13: virtual elapsed=30s
--- PASS: TestVirtualTime (0.00s)

Thirty seconds of virtual time in 0.00s of wall clock, and time.Since reports exactly 30s — not “about 30s”, not 30.0001s. The clock is not being sped up; it is being skipped.

That exactness is worth as much as the speed. A test of a backoff schedule can assert the delays were exactly 1s, 2s, 4s, 8s, rather than asserting they fell in tolerance bands that have to be widened every time CI gets busier. A fifteen-minute retry policy becomes a test that runs instantly and asserts on precise values:

backoff_schedule_test_164.go
// Illustrative snippet — not a complete program
func TestBackoffSchedule(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        var delays []time.Duration
        start := time.Now()
        for attempt := range 5 {
            retryAfter(attempt)             // sleeps internally
            delays = append(delays, time.Since(start))
            start = time.Now()
        }
        want := []time.Duration{1, 2, 4, 8, 16}
        for i, d := range delays {
            if d != want[i]*time.Second {
                t.Errorf("attempt %d: %v, want %v",
                    i, d, want[i]*time.Second)
            }
        }
    })
}

Every chapter in this book that measured a timeout — Chapter 13's context deadlines, Chapter 14's group cancellation, Chapter 15's grace budget — could have been tested this way for the parts that do not touch a socket.

16.4.3 Durably Blocked

Everything depends on that phrase, so it is worth getting exactly right.

From the testing/synctest documentation

“A goroutine in a bubble is 'durably blocked' when it is blocked and can only be unblocked by another goroutine in the same bubble. A goroutine which can be unblocked by an event from outside its bubble is not durably blocked.”

The definition is about who can wake you. If the only thing that can unblock a goroutine is another goroutine in the same bubble, then the bubble knows the complete set of possible futures, and it can reason about them. If something outside could wake it — the network, the OS, another thread — the bubble cannot know when, so it cannot safely advance the clock.

The operations that block durably:

Operation
Send or receive on a channel created in the bubble
select where every case is a bubbled channel
sync.Cond.Wait
sync.WaitGroup.Wait, when Add was called in the bubble
time.Sleep
Locking a sync.Mutex or sync.RWMutex
Reading or writing a network connection
Any system call
WHEN EVERY BUBBLED GOROUTINE IS BLOCKED

A three-way branch from the condition that every goroutine in a synctest bubble is durably blocked. If someone called Wait, Wait returns. If a timer could fire, the clock jumps to it. If neither, it is a deadlock and the test is failed. The caption adds that a goroutine which is blocked but not durably blocked, such as one parked on a mutex, a socket or a syscall, satisfies none of the three branches, so the bubble can neither advance nor conclude, and that is what a hang is.

When every goroutine in the bubble reaches one of the “yes” states, exactly one of three things happens: Wait returns if someone called it; otherwise the clock advances to the next timer; otherwise there is no possible future at all, and that is a deadlock, which Test reports as a failure (§16.5.6).

16.4.4 What Is Not Durably Blocked

The “No” rows in that table are not footnotes. They are where tests go wrong, and the mutex row is the one that will bite first, because a mutex feels like exactly the kind of thing a bubble should understand.

It is not, and the reason is precise: a sync.Mutex has no bubble identity. Any goroutine anywhere in the process can hold it, so the bubble cannot prove that only a bubbled goroutine will release it.

Here is what that costs:

mutex_is_test_164_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: this test hangs forever
func TestMutexIsNotDurable(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        var mu sync.Mutex
        mu.Lock()
        go func() {
            time.Sleep(time.Second)
            mu.Unlock()
        }()
        mu.Lock()   // parks on a mutex: NOT durable
        t.Log("acquired")
    })
}

Trace it. The root goroutine holds the lock and then tries to take it again, so it parks on the mutex. The child needs the clock to move a second forward before it can unlock. The clock will move only when every goroutine is durably blocked — and the root is parked on a mutex, which does not qualify. So the clock never advances, the child never wakes, the lock is never released, and nothing happens. Ever.

Measured the failure mode is not a panic. It is a hang, ended only by the test binary’s alarm:
Terminal
panic: test timed out after 5s
    running tests:
        TestMutexIsNotDurable (5s)

This is the most important trap in the whole package, and it deserves a blunt statement: the bubble’s deadlock detector has exactly the same blind spot as its clock. Detection requires every goroutine to be durably blocked. A goroutine parked on a mutex is blocked but not durably, so the bubble cannot tell “deadlocked” from “waiting for something outside” — and it must assume the latter, because assuming the former would fail correct tests.

So synctest gives you an instant, precise deadlock report (§16.5.6) for code that coordinates through channels, and degrades to a plain timeout for code that coordinates through mutexes. That is a real argument for channel-based coordination in code you intend to test deterministically, and one of the few places in this book where testability should influence a design choice that Chapter 9 and Chapter 12 would otherwise call a toss-up.

There is a related trap with WaitGroup, documented as a technical limitation:

Measured a package-level var wg sync.WaitGroup used inside a bubble hangs the test the same way — the value cannot be associated with a bubble, so its Wait is not durably blocking. The pointer form works:
pkg_wg_164_x_1.go
// Illustrative snippet — not a complete program
var pkgWG sync.WaitGroup      // ✗ Wait() is not durably blocking
var ptrWG = new(sync.WaitGroup) // ✓ associates with the bubble
Measured the pointer form reported 1s virtual elapsed and passed in 0.00s; the value form timed out. Since package-level WaitGroups are a bad idea for the reasons Chapter 12 gives anyway, the practical advice is simply to declare them inside the test — but if you are bubbling an existing package that has one, this is why it hangs.

16.4.5 synctest.Wait

The second function solves the problem §16.6.2 otherwise solves with barriers: how does a test know a goroutine has got far enough?

wait_no_test_164.go
// Illustrative snippet — not a complete program
func TestWaitNoBarrier(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        done := false
        go func() { done = true }()
        synctest.Wait()
        t.Log("after Wait, done =", done)
    })
}
Measured after Wait, done = true — deterministically, with no channel, no mutex, no sleep, and no WaitGroup.

Wait returns when every other goroutine in the bubble is durably blocked, which includes finished — a goroutine that has exited is not going to do anything else. So it is a general “let everything that can run, run” operation, which is precisely the primitive a test wants and which nothing outside a bubble can provide.

Note what this means for the data race in that listing. Writing done from one goroutine and reading it from another with no synchronisation is a data race by every rule in Chapter 8 — and synctest.Wait establishes a happens-before edge, so it is not. The bubble is not suspending the memory model; Wait is a real synchronisation operation, and §16.4.9 confirms the detector agrees.

The typical use is checking that something has not happened yet, which is normally the hardest kind of concurrent assertion:

fired_164.go
// Illustrative snippet — not a complete program
synctest.Test(t, func(t *testing.T) {
    ctx, cancel := context.WithCancel(t.Context())
    fired := false
    context.AfterFunc(ctx, func() { fired = true })

    synctest.Wait()
    if fired {
        t.Fatal("AfterFunc ran before cancellation")
    }

    cancel()
    synctest.Wait()
    if !fired {
        t.Fatal("AfterFunc did not run after cancellation")
    }
})

Outside a bubble, the first assertion is untestable — “it has not happened yet” is always true if you check soon enough, and always suspect. Inside, Wait makes it exact: everything that could have run has run, so if it did not happen, it was never going to.

When the trigger is time rather than an explicit action, Go 1.27’s synctest.Sleep(d) is the same pattern with the sleep folded in: it advances the clock by d and then Waits. The package documentation gives the reason to prefer it over a bare time.Sleep: if the test and the system under test sleep for the same duration, which of the two wakes first is unpredictable, and the test almost always wants the system under test to settle first — which is exactly what the trailing Wait guarantees.

16.4.6 Contexts Use the Bubble’s Clock

A question comes up immediately, because so much Go code expresses its timing through context rather than through time directly: does a context deadline respect the bubble?

context_uses_test_164.go
// Illustrative snippet — not a complete program
func TestContextUsesBubbleClock(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ctx, cancel := context.WithTimeout(
            t.Context(), 10*time.Minute)
        defer cancel()
        start := time.Now()
        <-ctx.Done()
        t.Logf("ctx done after virtual %v; err=%v",
            time.Since(start), ctx.Err())
    })
}
Measured ctx done after virtual 10m0s; err=context deadline exceeded, in 0.00s of wall clock.

It does, and it follows from the mechanism rather than from special handling. context.WithTimeout is built on time.AfterFunc, which is built on the runtime timers the bubble controls. Anything layered on the time package inherits the virtual clock for free.

That is more consequential than it first sounds, because it means the assertion can straddle the deadline:

Measured a thirty-second context deadline, sampled at 29 s and 31 s of virtual time.
Terminal
at 29s: err=<nil>
at 31s: err=context deadline exceeded

Both sides, exactly, in no real time. Chapter 13's whole context tree, Chapter 15's grace budget, every per-request deadline and every WithTimeout in a retry loop becomes assertable at its production value. A ten-minute deadline is as cheap to test as a ten-millisecond one, which removes the last honest reason anyone had for parameterising timeouts down for tests — a practice that quietly means the number you ship is the one number you never tested.

16.4.7 Isolation Rules

A bubble is a closed world, and the runtime enforces it. Three rules, all of which produce immediate failures rather than subtle misbehaviour.

Bubbled channels, timers and tickers belong to their bubble. Operating on one from outside panics. This is what stops a bubble from leaking determinism into the rest of your suite, and it means a channel created inside a bubble cannot be used as a bridge to the outside — which is occasionally what you want to do and always wrong.

A WaitGroup associates with a bubble on its first Add or Go. After that, calling Add or Go on it from outside is a fatal error. §16.4.4 covered the package-variable limitation this creates.

sync.Cond.Wait is durably blocking, and waking a bubbled waiter from outside is a fatal error. The symmetry with WaitGroup is deliberate.

One more, easy to miss and occasionally the answer to a puzzling failure:

Cleanups and finalizers run outside every bubble.

Functions registered with runtime.AddCleanup or runtime.SetFinalizer are invoked by the garbage collector on its own goroutines, which are not bubbled — so they see the real clock, cannot use bubbled channels, and are not counted when the bubble decides whether everyone is durably blocked. If you are testing something that releases resources from a finalizer, the bubble will not observe it. This is not a limitation to work around so much as a reason to prefer explicit Close over finalizers, which Chapter 15's §15.6 argued on other grounds.

16.4.8 The Socket Boundary

The largest limitation follows directly from §16.4.3 and Chapter 15 already measured it.

Network I/O parks a goroutine on the runtime’s network poller, waiting for the operating system. The OS is outside the bubble. So a goroutine blocked on a socket read is not durably blocked, the clock cannot advance, and any test that puts a real network connection inside a bubble does not run — it hangs.

Measured in §15.7.7: a real net/http server inside a bubble never advanced; the test was still blocked after five seconds of real time.

That is not a bug to be fixed in a later Go release. It is inherent to real sockets: virtual time means the bubble decides when time passes, and it cannot decide that for a peer in another process.

WHERE THE BUBBLE ENDS

Two columns divided by a vertical line. Inside the bubble: bubbled channels, bubbled timers, Cond.Wait, bubbled WaitGroup.Wait and time.Sleep, described as virtual, exact and instant. Outside: a real socket, a mutex anyone could unlock, any syscall, and finalizers, described as the real world on its own clock. The caption warns that crossing the line stops the clock advancing, because the bubble can no longer know who might wake you, and gives the rule to bubble the logic rather than the transport.

The way through is to replace the network rather than to work around the clock. net.Pipe gives you a full net.Conn implemented entirely with in-memory channel operations — which are bubbled, and therefore durably blocking:

protocol_handshake_test_164.go
// Illustrative snippet — not a complete program
func TestProtocolHandshake(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        client, server := net.Pipe()
        t.Cleanup(func() { client.Close(); server.Close() })

        go handle(server)          // the code under test

        // ... write a request, read a response, assert ...
        // Every operation is a bubbled channel operation, so
        // timeouts inside handle() run on virtual time.
    })
}

This is the technique the standard library itself uses for exactly this purpose — the synctest documentation’s own worked example tests HTTP’s Expect: 100-continue handling over a fake network for the same reason.

For HTTP specifically the standard library now ships that fake network ready-made. Go 1.27’s httptest.NewTestServer(t testing.TB, handler http.Handler) returns an httptest.Server whose listener is in-memory rather than a loopback socket — the package documentation now says most tests should use it, and names testing/synctest as the reason. Create it inside synctest.Test, use srv.Client() as usual, and a handler-plus-client round trip runs on virtual time. The rule is unchanged: bubble the logic, not the transport — NewTestServer simply makes HTTP’s transport bubble-safe without you writing a net.Conn. (It is also the one 1.27 symbol the new default stdversion vet check flags: in a module still declaring go 1.25, go test refuses to build a file that uses it, so the go.mod bump and the listing land together.)

The decision rule is short. Bubble the logic, not the transport. A shutdown sequencer expressed in channels and contexts belongs in a bubble; the socket it eventually closes does not. Chapter 15's exercise used real short timeouts and hand-counted goroutines precisely because it kept a real listener; this chapter’s exercise strips the socket and gets determinism in exchange.

16.4.9 -race Still Works

One assumption worth killing before it costs someone their coverage: making a test deterministic does not blind the race detector.

Measured the same racy function — two unsynchronised writes to a shared variable — reported WARNING: DATA RACE both plain and inside synctest.Test.

That result is not obvious. A bubble controls scheduling closely enough that you might expect it to serialise everything and make races impossible to observe. It does not, and it should not: the detector works on happens-before edges (§16.3.1), and the bubble does not manufacture edges that the program did not create. synctest.Wait creates one because it genuinely is a synchronisation operation; two bare writes to a variable do not, bubbled or otherwise.

So -race composes with everything in this section, and the two are complementary rather than alternative: the bubble gives you a deterministic schedule, the detector checks the ordering of the memory accesses that schedule produced.

16.4.10 Common Mistakes

Copying synctest.Run from a 2025 article
Problem

Undefined symbol; no hint about the cause

Fix

synctest.Test(t, func(t *testing.T))

A sync.Mutex contended inside a bubble
Problem

Test hangs until -timeout; no deadlock report

Fix

Coordinate with channels, or keep it out of the bubble

A package-level var wg sync.WaitGroup
Problem

Same hang, no diagnostic

Fix

Declare it in the test, or use new(sync.WaitGroup)

A real socket inside a bubble
Problem

The clock never advances; the test hangs

Fix

net.Pipe, httptest.NewTestServer (Go 1.27) for HTTP, or keep the transport outside (§16.4.8)

t.Run or t.Parallel inside a bubble
Problem

Immediate failure

Fix

Loop outside; one synctest.Test per case

Using a bubbled channel from outside
Problem

Panic

Fix

Nothing crosses the boundary; report through the test

Expecting a finalizer to run in the bubble
Problem

The release is never observed

Fix

Finalizers run outside; prefer explicit Close

Assuming determinism means no data races
Problem

Real races ship unnoticed

Fix

Measured, -race reports inside a bubble too

Summary: Determinism: testing/synctest

The package is three functions. Test runs a closure in a bubble; Wait blocks until every other bubbled goroutine is durably blocked; Sleep (Go 1.27) advances the clock and then Waits. Run, from the Go 1.24 experiment, no longer exists, and most published examples still use it.

Inside a bubble the time package is fake and per-bubble, starting at midnight UTC 2000-01-01, and it advances only when every goroutine is durably blocked — jumping straight to the next timer rather than waiting. Measured, thirty seconds of virtual time in 0.00s of wall clock, with time.Since reporting exactly 30s, which turns tolerance-band assertions into equality assertions.

“Durably blocked” means only a goroutine in the same bubble can wake you. Bubbled channel operations, Cond.Wait, bubbled WaitGroup.Wait and time.Sleep qualify. Mutexes, I/O and syscalls do not — and the consequence is sharper than a missing feature. Measured, a contended mutex inside a bubble hangs until the test binary’s alarm fires, because the bubble’s deadlock detection has the same blind spot as its clock. The socket boundary is the same rule in its most common form, already measured in §15.7.7, and net.Pipe — or, for HTTP, Go 1.27’s httptest.NewTestServer — is the way through.

synctest.Wait makes “this has not happened yet” a testable assertion for the first time. And measured, -race still reports races inside a bubble: determinism and detection compose.

Self-Check Questions: Determinism: testing/synctest

A test inside a bubble hangs until -timeout instead of reporting a deadlock. What are the two most likely causes, and what do they have in common?

A goroutine parked on a mutex, or a goroutine blocked on real I/O — most often a socket, sometimes a package-level WaitGroup.

What they have in common is the definition of durably blocked: a goroutine is durably blocked only if nothing outside the bubble could wake it. A mutex can be released by any goroutine in the process, so the bubble cannot rule out an external unlock. A socket is woken by the operating system. A package-level WaitGroup has no bubble identity at all.

The consequence is the same in all three cases and it is the key insight: the bubble’s clock and its deadlock detector are driven by the same condition. Time advances when everyone is durably blocked; a deadlock is declared when everyone is durably blocked and no timer can fire. A goroutine that is blocked but not durably blocked satisfies neither, so the bubble can neither advance nor conclude — it simply waits, forever, which is what a hang is.

That is also why the diagnosis is easy once you know it: a bubble that hangs is telling you that something in it is blocked on the outside world. Find that thing and either remove it (net.Pipe) or move it out of the bubble.

Why can synctest.Wait prove that something has not happened, when no ordinary technique can?

Because it converts “not yet” into “not ever, given the current state” — and those are completely different claims.

Outside a bubble, checking that a callback has not fired proves nothing, because you have only established that it had not fired at the moment you looked. Wait longer and it might. There is no amount of waiting that turns the observation into a proof, which is why “assert that nothing happened” is normally the hardest concurrent assertion to write and the easiest to write uselessly.

Wait returns only when every other goroutine in the bubble is durably blocked — meaning none of them can make progress without either a timer firing or another bubbled goroutine acting. So if the callback has not run at that point, nothing that is currently possible will cause it to run. The state is quiescent, and quiescence is what the assertion actually needs.

The context.AfterFunc example in §16.4.5 is the canonical shape: Wait, assert not fired, cancel(), Wait, assert fired. Both halves are exact, and neither involves a duration.

You are testing a rate limiter that allows 100 requests per minute. Why is this a good candidate for a bubble, and what would disqualify it?

It is a good candidate because its entire behaviour is defined in terms of time, and testing it honestly outside a bubble means either waiting real minutes or asserting on tolerance bands that get flakier as CI gets busier. In a bubble the test asserts exact values — the 101st request is refused, the clock advances exactly sixty seconds, the next is allowed — and finishes in no measurable time.

What would disqualify it is holding anything the bubble cannot see. If the limiter’s tests drive it through a real HTTP handler over a real socket, the goroutines block on the network poller, the clock never advances, and the test hangs (§16.4.8). If the limiter’s internals coordinate through a contended mutex rather than channels, a test that waits on that mutex hangs the same way (§16.4.4). And if it consults a distributed store for its counters, that is a syscall and the same rule applies.

The way to keep it bubble-friendly is §16.4.8's rule: bubble the logic, not the transport. Test the limiter’s decision function — given this history and this clock, allow or refuse — inside a bubble, and test its HTTP wiring separately with the techniques in §16.6, where real time is unavoidable and a generous timeout is the honest tool.

Key Takeaways

  • The API is three functions — Test, Wait and, since Go 1.27, Sleep; synctest.Run was removed when the package stabilised and most published examples still use it
  • Virtual time is per-bubble and exact — measured, 30s elapsed in 0.00s, which converts tolerance assertions into equality assertions
  • Time advances only when every goroutine is durably blocked, and jumps to the next timer rather than waiting
  • Durably blocked means only a bubbled goroutine can wake you: channels, Cond.Wait, bubbled WaitGroup.Wait and Sleep qualify; mutexes, I/O and syscalls do not
  • Measured, a contended mutex or a package-level WaitGroup in a bubble hangs until -timeout — the deadlock detector shares the clock’s blind spot
  • Sockets are the same limitation in its commonest form; net.Pipe — or httptest.NewTestServer (Go 1.27) for HTTP — is the way through, and the rule is to bubble the logic, not the transport
  • synctest.Wait makes “this has not happened yet” provable, which no real-time technique can
  • Measured, -race reports races inside a bubble — determinism and detection compose
Section 16.4 — in one line

A bubble buys exact, instant, repeatable time in exchange for giving up the outside world, and every one of its failure modes is the same sentence: something in here is waiting on something out there.

16.5 Leak Detection: Heuristic and Proof

Chapter 2 introduced goleak and built an exercise around it. Chapter 10 used it again in the deadlock-detection toolkit. Both chapters pointed here, and Chapter 2's Further Reading was specific about what was owed:

From Chapter 2's Further Reading

go.uber.org/goleak — the detector used in the exercise. Chapter 16 makes it part of the test suite properly.”

“Properly” is the whole assignment. Anyone can add defer goleak.VerifyNone(t) to a test; the reason that is not enough is that goleak answers a question it cannot actually answer, and the gap between what it reports and what is true has a measurable size.

16.5.1 What Chapter 2 Left On the Table

goleak works by comparing goroutine profiles. It takes a snapshot, filters out the ones it knows about, and reports whatever is left.

main_test_165.go
// Illustrative snippet — not a complete program
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)   // once, for the whole package
}

func TestOne(t *testing.T) {
    defer goleak.VerifyNone(t) // or per-test
}

That much is Chapter 2. The choice between them is about blast radius: VerifyTestMain runs one check after the entire package, which is cheap and catches everything, but tells you only that something in the package leaked. VerifyNone runs per test and names the culprit, at the cost of a check per test — and §16.5.2 shows what that cost actually is.

The part Chapter 2 could not cover, because it needed the vocabulary of this chapter, is the nature of the answer. A goroutine profile is a snapshot of a moving system. A goroutine that is about to exit and a goroutine that will never exit look identical in it. There is no field that distinguishes them, and there cannot be — the difference is about the future.

So goleak cannot observe the property it is named after. What it does instead is wait, and retry, and eventually give up. That makes it a heuristic with a tunable threshold, and everything surprising about it follows from where that threshold sits.

16.5.2 goleak Is a Retry Loop

The threshold is not documented in the package’s README, but it is three constants in options.go:

opts_165.go
// Illustrative snippet — not a complete program
// options.go, verbatim -- the whole of goleak's patience.
const _defaultRetries = 20          // and maxSleep: 100ms

func (o *opts) retry(i int) bool {
    if i >= o.maxRetries {
        return false
    }
    d := time.Duration(int(time.Microsecond) << uint(i))
    if d > o.maxSleep {
        d = o.maxSleep
    }
    time.Sleep(d)
    return true
}

Twenty attempts. The delay starts at one microsecond and doubles, capped at one hundred milliseconds. Summing that schedule: attempts 0 through 16 contribute 1µs, 2µs, 4µs … 65.5ms, which totals about 131 ms; attempts 17, 18 and 19 are each capped at 100 ms, adding 300 ms.

Derived the total retry budget is approximately 431 ms. A goroutine that exits within that window is not reported. A goroutine that takes longer is reported as a leak, whether or not it is one. Measured the arithmetic holds. A check against a genuine, permanent leak — a goroutine parked forever on a channel nobody will send to — took 436 ms to return its verdict, which is the full budget plus the cost of twenty profile captures.

That number is worth carrying around for a practical reason before a conceptual one. VerifyNone in a package with 200 tests, in the ordinary case where nothing leaks, is fast — the first profile is usually clean. But VerifyTestMain on a suite that does leak pays 431 ms once, and a per-test VerifyNone in a suite where one goroutine lingers pays it repeatedly.

16.5.3 The False Positive, Measured

The conceptual consequence is more important: the threshold is the entire definition. goleak does not report leaks. It reports goroutines that outlived 431 ms of patience.

That is testable directly. Start a goroutine that is definitely going to exit, at various delays, and ask goleak about it:

goleak_false_test_165.go
// Illustrative snippet — not a complete program
func TestGoleakFalsePositive(t *testing.T) {
    for _, d := range []time.Duration{
        100 * time.Millisecond,
        400 * time.Millisecond,
        600 * time.Millisecond,
    } {
        done := make(chan struct{})
        go func() { time.Sleep(d); close(done) }()
        err := goleak.Find()
        t.Logf("exits after %-6v -> leak: %-5v", d, err != nil)
        <-done
    }
}
Measured the same goroutine, at three different exit delays, asked about immediately.
Terminal
exits after 100ms -> reported as a leak: false (took 140ms)
exits after 400ms -> reported as a leak: false (took 440ms)
exits after 600ms -> reported as a leak: TRUE (took 440ms)

A clean cliff edge, exactly where the arithmetic put it. Not one of these three goroutines is a leak — all three exit, on their own, without intervention. The third is reported as one because it took 600 ms and the tool waits 431.

GOLEAK'S RETRY BUDGET, AND ITS CLIFF

The goleak retry schedule and the false positive it produces. Attempts zero through sixteen back off from one microsecond doubling to sixty-five milliseconds, totalling about one hundred and thirty-one milliseconds; attempts seventeen through nineteen are capped at one hundred milliseconds each, adding three hundred; the total budget is about four hundred and thirty-one milliseconds. Below, three goroutines: one exiting after one hundred milliseconds and one after four hundred are correctly not reported, while one exiting after six hundred is reported as a leak. None of the three is leaking; the third is only slower than the tool is patient.

This is what “heuristic” means in practice, and it explains every goleak false positive anyone has ever filed, without needing a catalogue of special cases. The usual suspects — http.Transport's idle connections, a pending time.AfterFunc, a package’s initialisation goroutine, a background flusher on a ticker — are not separate phenomena. They are all the same phenomenon: something that will exit, or would exit if asked, taking longer than 431 ms to do it.

It also explains the property that makes goleak failures so unpleasant to debug: they get worse on slower machines. A goroutine that exits in 300 ms on a developer laptop can take 600 ms on a loaded CI runner, and the tool flips from silent to failing with no change to the code. The same test, the same binary, a different verdict — which is §16.1's opening problem, arriving inside the tool meant to solve it.

16.5.4 Counting Goroutines by Hand

Chapter 14's exercise counted goroutines with runtime.NumGoroutine() “so the mechanism stays visible”, and Chapter 15's fourth gate did the same. Now that the mechanism is visible, here is why neither chapter recommended it as a practice.

before_165_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN as a general technique
before := runtime.NumGoroutine()
doTheThing()
if after := runtime.NumGoroutine(); after != before {
    t.Errorf("leaked %d goroutines", after-before)
}

Three problems, in increasing order of how long they take to diagnose.

It races with correct shutdown. A goroutine that is exiting but has not yet been descheduled still counts. That is goleak's problem from §16.5.3 without goleak's 431 ms of patience — the same false positive, arriving sooner and far more often.

It counts the whole process. The test framework’s goroutines, the garbage collector’s, anything a background package started at init, and — crucially — anything a parallel sibling test started or finished during your measurement. Under t.Parallel the number is close to meaningless.

It reports a number, not a stack. “Leaked 1 goroutine” gives you nothing to act on. goleak prints the offending stack; the bubble prints the source line that created it.

What hand-counting has going for it is that it is dependency-free and completely obvious, which is exactly why the two earlier exercises used it — the goal there was to make a leak visible, not to establish a practice. In a real suite, use goleak or a bubble.

If you must count, count what you own.

A counter incremented when your goroutine starts and decremented when it exits — an atomic.Int64 per §11.4 — is sound where NumGoroutine is not, because it counts only your goroutines and changes only at points you control. It is more code and it is correct, which is usually the right trade for a lifecycle you actually care about.

16.5.5 Ignoring, and When It Is a Lie

The tool’s answer to false positives is to ignore things.

main_test_165_2.go
// Illustrative snippet — not a complete program
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m,
        goleak.IgnoreTopFunction("internal/poll.runtime_pollWait"),
        goleak.IgnoreAnyFunction("example.com/stats.(*worker).start"),
    )
}

IgnoreTopFunction matches on the goroutine’s topmost frame; IgnoreAnyFunction matches anywhere in the stack; IgnoreCurrent snapshots what is running now and excludes all of it, which is the blunt instrument for a package with unavoidable background goroutines.

These are necessary and there is nothing wrong with using them. But each one is an assertion, and it is worth being explicit about what you are asserting: “a goroutine sitting in this function is not a leak.”

That claim is sometimes true and sometimes a lie, and the test that tells them apart is not whether the failure went away. It is whether you can answer two questions about the goroutine:

If you can answer both, the ignore is honest: you have a goroutine with a real lifecycle that happens to be slower than 431 ms or genuinely process-lifetime. If you cannot, you have just told the tool to stop reporting a leak you do not understand — and IgnoreTopFunction on a frame like runtime_pollWait is especially prone to this, because it covers every goroutine blocked on any network read anywhere in the program, including the one that is actually leaking.

IgnoreCurrent has a scope you may not intend.

It excludes everything running at the moment it is called, which in TestMain means everything the package’s init functions started — the intended use — but also anything an earlier test leaked, if it is called later. Used per-test with defer goleak.VerifyNone(t, goleak.IgnoreCurrent()), it excludes goroutines leaked by previous tests, so a leak introduced early in the file can silence the check for every test after it. Prefer naming functions over snapshotting state.

16.5.6 The Bubble’s Version

Everything above is a consequence of not knowing whether a goroutine will exit. Inside a bubble, the runtime does know.

§16.4.3 established the mechanism: when every goroutine in the bubble is durably blocked, the bubble advances the clock to the next timer — and if there is no timer, then no future exists in which anything makes progress. That is a deadlock, definitionally, and it is detectable rather than heuristic.

Test uses this in two ways:

From the testing/synctest.Test documentation

“Test waits for all goroutines in the bubble to exit before returning. If the goroutines in the bubble become deadlocked, the test fails.”

So a bubbled goroutine that never exits is not a goroutine that survives the test. It is a goroutine that stops the test from finishing — and the bubble notices immediately.

bubble_leak_test_165.go
// Illustrative snippet — not a complete program
func TestBubbleLeak(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ch := make(chan int)
        go func() { <-ch }()   // nobody will ever send
    })
}
Measured running TestBubbleLeak — one bubbled goroutine waiting on a channel nobody will send to.
Terminal
--- FAIL: TestBubbleLeak (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked
goroutines remain
goroutine 10 [chan receive (durable), synctest bubble 1]:
ch16.TestBubbleLeak.func1.1()
    .../leak_test.go:79 +0x19
created by ch16.TestBubbleLeak.func1 in goroutine 9

Compare that against goleak, line by line. It took 0.00s rather than 436 ms. It required no dependency. It reported a proof rather than a threshold verdict — there is no interpretation under which that goroutine was going to exit. And it named the source line where the goroutine was created, which is the information you actually need and which a goroutine profile gives you only indirectly.

The goroutine state annotation is worth reading closely: [chan receive (durable), synctest bubble 2]. The runtime tags both the durability of the block and the bubble identity in ordinary goroutine dumps — and, from Go 1.27, any pprof labels the goroutine carries, after the bracket, for modules declaring go 1.27 or later. When you are debugging a bubbled test that is behaving strangely, kill -QUIT (§10.4) shows you exactly which goroutines the bubble considers durably blocked — which, per §16.4.4, is the first thing you want to know.

16.5.7 The Leak That Is Armed But Not Running

It would be tidy to stop there, and it would be wrong. The bubble has a blind spot of its own, and it is worth measuring because the obvious mental model gets it backwards.

arm_later_165.go
// Illustrative snippet — not a complete program
// The leak is armed, not yet running.
func armLater() {
    time.AfterFunc(2*time.Second, func() { select {} })
}

At the moment the test ends, nothing is leaking. Something is scheduled to.

Measured goleak.Find reports no leak. It asked whether extra goroutines were running, and none were. Measured the same shape inside a bubble, with the root function returning immediately, also passes. Virtual time stops advancing when the bubble’s root goroutine exits, so the timer never fires and the goroutine is never created.

Both tools miss it, for the same reason from two directions: neither was asked about the future. Now add one line that costs nothing:

bubble_catches_test_165.go
// Illustrative snippet — not a complete program
func TestBubbleCatchesArmedLeak(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        armLater()
        time.Sleep(3 * time.Second)   // virtual: free (synctest.Sleep)
    })
}
Measured the armed timer again, this time with the clock advanced past it.
Terminal
--- FAIL: TestBubbleCatchesArmedLeak (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked
goroutines remain

Still 0.00s of wall clock. The rule that falls out is short and almost nobody writes it down:

Advance the clock past every timer your code arms.

A bubble proves that everything which ran finished. To make it prove something about work your code scheduled for later, sleep past the schedule first. It is free, so there is no reason not to — and a test that ends the instant its subject returns is checking a narrower claim than it appears to. Go 1.27’s synctest.Sleep(d) is the same advance followed by a Wait, so it is the form to use when you want to advance the clock and then assert on what has and has not happened — the §16.4.5 pattern with the sleep folded in.

16.5.8 Adopting Detection in a Suite That Already Leaks

Everything above assumes a clean starting point. Most real suites do not have one: the first time anyone adds goleak.VerifyTestMain, thirty tests fail, and the change is reverted within the hour. That outcome is avoidable, and the sequence matters.

Do not start with VerifyTestMain across the whole tree. It produces a single failure with no attribution and a wall of goroutines, most of which turn out to be the same two causes.

Start at the leaves instead — the packages that own goroutines and have the fewest dependencies — one at a time, with an explicit exception list:

main_test_165_3.go
// Illustrative snippet — not a complete program
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m,
        // Owner:   package-level pool, created in init
        // Exits:   never — process lifetime
        // Tracked: ISSUE-4412
        goleak.IgnoreTopFunction("myapp.(*Pool).reap"),
    )
}

Every ignore carries three facts in a comment: owner, exit condition, tracking reference. That is §16.5.5's honesty test, written down where the next person will read it. An ignore with all three is documentation. An ignore with none is where the leak you are looking for will eventually hide.

Then work the list. Two causes account for most of a first run:

A test that starts a goroutine and returns. §16.2.2's orphan. The fix is t.Cleanup(wg.Wait), and it usually fixes several tests at once.

http.Transport idle connections. Not really a leak — the transport is holding a keep-alive connection open on purpose. Give each test its own client with t.Cleanup(client.CloseIdleConnections), or use httptest, whose Close handles it.

Finally, adopt in the order that preserves signal: VerifyTestMain in the package to prove it is clean, VerifyNone on the tests that own goroutines so future regressions are attributed to the right test, and a bubble for anything that can move into one.

The economics are better than the first run suggests.

A package that fails in thirty tests almost always leaks for two or three reasons, not thirty. Fix the orphan rule once and watch the count collapse. Say that out loud before someone looks at the first red run and reverts the change — the job is far shorter than it looks, and the moment of maximum discouragement comes before any of the work.

16.5.9 Choosing

Two tools with a clean division, and the boundary is §16.4.8's boundary rather than a matter of preference — plus, since Go 1.27, a third instrument that sits between them.

Property
Nature of the answer
Cost of a verdict
False positives
Names the creation site
Works with sockets and syscalls
Works across the whole process
Dependency
False negatives
Usable in production

Go 1.27 adds a third instrument that sits between the two. The runtime’s goroutineleak profile — pprof.Lookup("goroutineleak"), or /debug/pprof/goroutineleak — reports goroutines blocked on a channel, mutex, Cond or similar primitive that the garbage collector can prove no runnable goroutine will ever reach. That is a proof in the bubble’s sense (no threshold to tune, no Ignore list), it covers the whole process in goleak’s sense, and it costs nothing until you ask for it. Its blind spots are the mirror of the bubble’s: it cannot judge a goroutine parked on I/O or a syscall, and it can miss a primitive still reachable through a global or a runnable goroutine’s locals — so it is an instrument for the socket-holding boundary and for production, not a replacement for either column. It shares the bubble’s other blind spot too: §16.5.7’s goroutine that is armed but not yet running is parked on nothing, so a reachability walk cannot see it either. And §16.5.8’s suite that is too leaky to gate on is a good fit for it, because a profile you read is not a check that fails. Experimental in Go 1.26 behind GOEXPERIMENT=goroutineleakprofile, standard in 1.27; Chapter 19 (§19.3.6) covers how it works.

The rule that follows: use the bubble for everything you can put in one, and goleak for the boundary — and, from Go 1.27, the goroutineleak profile wherever you want the runtime’s own proof, including in production (Chapter 19). Concretely, in a package that has both kinds of code:

That last point is the one worth internalising. Every Ignore in your codebase exists because a tool had to guess. Moving code into a bubble does not make the guessing more accurate; it removes the need to guess.

16.5.10 Common Mistakes

Reading a goleak report as proof of a leak
Problem

Time spent chasing a goroutine that exits fine

Fix

It reports goroutines slower than ~431 ms

Adding an Ignore to make a failure stop
Problem

A real leak is silenced permanently

Fix

Name the owner and the exit condition first

IgnoreTopFunction("...pollWait")
Problem

Every network-blocked goroutine is excluded

Fix

Match the specific function that owns the lifetime

IgnoreCurrent per test
Problem

An early leak silences the check for later tests

Fix

Use it in TestMain, or name functions instead

goleak inside a synctest bubble
Problem

Redundant, and it sees unbubbled goroutines too

Fix

The bubble already proves it

Only VerifyTestMain on a leaky suite
Problem

“Something leaked” with no culprit

Fix

VerifyNone on the suspect tests to localise

Expecting goleak to be deterministic
Problem

Passes locally, fails on slow CI

Fix

The threshold is wall-clock; the bubble is not

No leak check at all outside bubbles
Problem

Socket-holding code leaks silently

Fix

VerifyTestMain is one line and catches the rest

runtime.NumGoroutine() as a leak check
Problem

Counts the whole process, races with shutdown

Fix

goleak, a bubble, or a counter you own

A bubble that ends the instant its subject returns
Problem

An armed AfterFunc leak is never created

Fix

Sleep past every timer the code arms

VerifyTestMain on the whole tree, first try
Problem

30 failures, no attribution, reverted by lunch

Fix

Start at the leaves, three-fact ignores

Summary: Leak Detection: Heuristic and Proof

goleak cannot observe whether a goroutine will exit, because a goroutine about to exit and one that never will look identical in a profile. So it waits: twenty retries with an exponential backoff capped at 100 ms, which is a derived budget of about 431 ms and a measured 436 ms against a real leak.

That threshold is the whole definition, and it produces a clean cliff. Measured, goroutines exiting after 100 ms and 400 ms were not reported; the same goroutine exiting after 600 ms was reported as a leak. Every known goleak false positive is that one phenomenon wearing different clothes, and it explains why the tool gets less reliable as machines get slower — the same failure mode §16.1 opened with, inside the tool meant to fix it.

The Ignore options are necessary and each one is an assertion. The honest test is whether you can name the goroutine’s owner and its exit condition; if you cannot, you have silenced a leak you do not understand.

Inside a bubble none of this applies, because the runtime knows. Measured, a bubbled goroutine parked on a channel nobody will send to produced an immediate panic: deadlock, in 0.00s, naming the creation line, with the goroutine tagged [chan receive (durable), synctest bubble 2]. Proof rather than threshold. Use the bubble wherever code will fit in one and goleak for the socket-holding boundary that will not.

Self-Check Questions: Leak Detection: Heuristic and Proof

A test passes goleak.VerifyNone locally and fails it on CI, with no code change. What is the most likely cause, and is the goroutine leaking?

The most likely cause is that CI is slower, and probably not that anything is leaking.

goleak retries twenty times with an exponential backoff capped at 100 ms — a derived budget of about 431 ms, measured at 436 ms. A goroutine that shuts down in 300 ms on a developer machine can easily take 600 ms on a shared CI runner under load, and measured, the tool reports exactly that goroutine as a leak while reporting the 400 ms version as clean.

So the first move is not to add an Ignore. It is to determine whether the goroutine has an exit condition at all. If it does — a Close you call, a context you cancel, a channel you drain — it is slow, not leaked, and the real problem is that your test finishes before the shutdown it triggered completes. Waiting for that shutdown explicitly fixes the test and removes the timing dependence.

If it has no exit condition, then CI found a real leak that your laptop was fast enough to hide, which is the happier interpretation and does happen.

Either way, an Ignore added at this point is the one response that guarantees you never find out which.

Why does a bubble need no equivalent of goleak.IgnoreTopFunction?

Because Ignore exists to correct a guess, and the bubble does not guess.

goleak compares goroutine profiles and applies a time threshold, so it will sometimes be wrong in both directions. The Ignore options are how you hand-correct the false positives: you are telling the tool that a goroutine it flagged is one you know about.

The bubble’s mechanism is different in kind. It reports a deadlock when every goroutine is durably blocked and no timer can fire — meaning there is no possible future in which any of them proceeds. That is not a threshold being exceeded; it is an exhaustive statement about the bubble’s state. There is nothing to be wrong about, so there is nothing to correct.

The corollary is worth noticing: the bubble’s report is also narrower. It can only make that statement about goroutines it contains, and only because it contains them. A goroutine blocked on a socket is not durably blocked (§16.4.4), so it is precisely the case the bubble cannot judge — which is why goleak still guards the boundary and why the division in §16.5.9 falls where it does.

You inherit a package whose TestMain has eleven IgnoreTopFunction calls. How would you assess them?

Apply the two-question test to each one: can you name the goroutine’s owner, and can you name what makes it exit?

Ignores that pass — a metrics exporter started in init that runs for the process lifetime, a connection pool’s background reaper with a documented Close — are legitimate. They describe goroutines with a real lifecycle that either exceeds the 431 ms budget or genuinely never ends.

Ignores that fail the test are the interesting ones, and there is usually a pattern: broad matches on runtime or standard-library frames. internal/poll.runtime_pollWait is the classic, because it matches every goroutine blocked on any network read in the entire program. One of those may be the goroutine someone was trying to silence; the other ten are now invisible, including any that appear in future.

The order of work I would use: replace broad frames with specific ones first, since that restores detection without changing behaviour and often makes a real leak appear immediately. Then, for anything that survives, ask whether the code could move into a bubble (§16.5.9) — if it coordinates through channels rather than sockets, the ignore becomes unnecessary rather than merely narrower.

And record the answers. An Ignore with a comment naming the owner and the exit condition is maintainable; one without is a permanent hole nobody will dare remove.

Key Takeaways

  • goleak cannot observe whether a goroutine will exit — a goroutine about to finish and one that never will look identical in a profile
  • It waits instead: 20 retries, 1µs << i capped at 100 ms, a derived ~431 ms budget, measured at 436 ms
  • The threshold is the definition, and the cliff is sharp — measured, exits at 100 ms and 400 ms pass, the same goroutine at 600 ms is reported as a leak
  • Every known false positive is that one mechanism, which is also why goleak gets less reliable on slower machines
  • Each Ignore is an assertion; the honest test is whether you can name the goroutine’s owner and its exit condition
  • Broad matches like runtime_pollWait exclude every network-blocked goroutine in the program, including future ones
  • Inside a bubble it is a proof, not a threshold — measured, panic: deadlock in 0.00s, naming the creation line
  • runtime.NumGoroutine() races with correct shutdown, counts the whole process, and reports a number rather than a stack
  • Measured, neither tool sees a leak that is armed but not yet running — advance the clock past every timer your code arms
  • Use the bubble wherever code fits in one and goleak for the socket-holding boundary it cannot cover — and, from Go 1.27, the runtime’s goroutineleak profile wherever you want a reachability proof outside a bubble, production included
Section 16.5 — in one line

goleak answers “did anything outlast my patience?” and the bubble answers “can anything still happen?” — only the second is the question you meant to ask.

16.6 Seams: Making Time, Schedule and Failure Injectable

A bubble is the best tool in this chapter and it does not reach everything. Anything holding a socket, a file, a subprocess or a contended mutex stays outside it, which in most real services is a substantial fraction of the interesting code. §16.1.5's ladder called that rung 2: you cannot own the clock, so you own the ordering instead.

A seam is a place where a test can substitute its own decision for one the program would otherwise make. Michael Feathers named the idea for sequential code; concurrency adds three things worth seaming that sequential code does not have — when something happens, in what order things happen, and which of several possible failures occurs first.

16.6.1 Clock Seams

The oldest technique in this section, and still necessary, because a bubble cannot reach code that talks to a socket while it waits.

The seam is an interface with one method:

real_clock_166.go
// Illustrative snippet — not a complete program
type Clock interface {
    Now() time.Time
    After(d time.Duration) <-chan time.Time
}

type realClock struct{}

func (realClock) Now() time.Time { return time.Now() }
func (realClock) After(d time.Duration) <-chan time.Time {
    return time.After(d)
}

The code under test takes a Clock; production passes realClock{}; the test passes something it can drive. This is the standard shape and there are several good third-party implementations of the fake side, so the interesting question is not how to write one but when it is still the right answer now that virtual time exists.

Two cases, and they are narrower than they used to be:

When the component must hold a real connection while it waits. A connection pool that expires idle connections after five minutes genuinely needs a socket in the test to be worth anything, so its timer cannot be a bubbled timer. A clock seam lets the test jump five minutes without waiting, while the connection stays real.

When the code is not yours to change. A dependency that calls time.Now internally cannot be bubbled usefully — the bubble will supply a fake clock, but the dependency’s own timers and the code’s expectations may be built around wall-clock assumptions the bubble breaks in confusing ways. A seam at your boundary is more predictable than a bubble around someone else’s code.

Outside those, prefer the bubble. A clock seam costs you an interface in your production API, an implementation to maintain, and the risk that the fake and the real clock diverge in behaviour — while a bubble costs nothing and is exact.

context.AfterFunc is a seam you already have.

Added in Go 1.21, it runs a function in a new goroutine when a context is cancelled, and returns a stop function. Because the trigger is a context rather than a duration, code written with it is testable by cancelling, which requires no clock at all — real or fake. Any place you were about to write “after N seconds, give up” as a timer, consider whether “when this context is done, give up” expresses the same thing; if it does, the test gets simpler and the production code gets more composable. §16.4.5's worked example tests exactly this shape.

16.6.2 Schedule Seams

The more common need. The test does not want to control when, it wants to control what happens before what.

The wrong tool is a sleep, and the reason is §16.1.4: a sleep asserts on a schedule. The right tool is a channel that makes the ordering explicit, and there are three shapes worth naming because they get confused with each other.

A one-shot gate blocks a goroutine until the test lets it proceed:

gate_166.go
// Illustrative snippet — not a complete program
gate := make(chan struct{})
go func() {
    <-gate           // wait for the test's permission
    doTheThing()
}()
// ... set up whatever must be true first ...
close(gate)          // release it

A barrier blocks the test until a goroutine has reached a point:

reached_166.go
// Illustrative snippet — not a complete program
reached := make(chan struct{})
go func() {
    setup()
    close(reached)   // announce arrival
    doTheThing()
}()
<-reached            // the test waits here

A rendezvous is both: each side waits for the other, which is what an unbuffered channel does natively.

Terminal
THREE SHAPES OF CHANNEL SEAM
  GATE the test releases the goroutine
             goroutine: <-gate
             test: close(gate)
  BARRIER the goroutine releases the test
             goroutine: close(reached)
             test: <-reached
  RENDEZVOUS both wait for the other
             an unbuffered channel, natively
  A sleep is none of these. It is a guess about
  how long one of them would have taken.

Chapter 15's exercise needed the second kind and the reason generalises. Its handler recorded whether it had been told to stop, and the test read that record — but the handler wrote it from its own goroutine, and the shutdown report could arrive first. The fix was a returned channel that the handler closes on exit, received from before reading the record, and the reason is written into that exercise’s own source:

From code/ch15/shutdown_test.go

“The handler writes told from its own goroutine and the report can arrive first, so wait for the handler to return before reading it.”

The fix was not a longer sleep. It was making the ordering explicit, so that the read cannot happen before the write regardless of how the scheduler feels.

The temptation to reach for something cheaper is strong, and there is one candidate that looks like it should work:

gosched_is_test_166_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: Gosched is not synchronisation
func TestGoschedIsNotSync(t *testing.T) {
    done := false
    go func() { done = true }()
    runtime.Gosched()
    if !done {
        t.Fatal("the goroutine had not run")
    }
}

runtime.Gosched yields the processor, which sounds like “let the other goroutine run”. It offers no guarantee that any particular goroutine runs, let alone that it finishes.

Measured across 2000 rounds of exactly that pattern, the value was not visible after Gosched in 42 to 52 rounds per run over five runs — a failure rate of roughly 2 to 3%.

Two to three percent is the worst possible number. It is low enough that the pattern looks like it works, and high enough that a suite running it a few hundred times will fail somewhere, occasionally, with no pattern. It is §16.1.1's 39-out-of-40 problem, self-inflicted.

The rule: Gosched is a scheduling hint, never a happens-before edge. If you need one goroutine to have finished, use a channel. The race detector agrees — a Gosched between two conflicting accesses does not stop -race reporting them, for exactly the reason §16.3.2 gave.

16.6.3 Timeouts, Honestly

Some tests must use real time. The question is how to do it without building §16.1's flakiness back in.

The answer is an asymmetry that §16.1's self-check introduced and that is worth stating as a rule:

WHICH WAY DOES A SLOW MACHINE PUSH IT?

Three kinds of timing assertion judged by what a slow machine does to them. An assertion that something finishes within a fixed time fails on a slow machine and is unsound, because the timeout is being used as ordering. An assertion that something has not finished after a fixed time stays true and is sound, because it is an assertion about absence. An assertion that something eventually finishes, with the timeout as the giving-up point, becomes slower but reaches the same verdict and is sound as a failure detector. The rule is that a timeout is a failure detector and never a device for establishing ordering.

The middle and bottom rows are both fine and they are different. The bottom row is the one you use constantly: a select with a generous timeout arm, where reaching the timeout means the test fails. Its only cost on a slow machine is that a failing test takes longer to fail, and passing tests are unaffected because they never reach the arm.

That yields two practical rules. Make failure timeouts generous — seconds, not milliseconds. A 50 ms timeout and a 5 s timeout catch the same bugs; the first also catches a busy CI runner. And never derive an ordering from a timeout, which is what “sleep 100 ms, then assert it happened” does.

One standard-library behaviour is worth knowing here because it removes a timeout you might otherwise write.

Measured httptest.Server.Close blocks until outstanding requests have finished. With a handler sleeping 300 ms and a request that landed 50 ms earlier, Close blocked for 250 ms — exactly the remainder.

That makes httptest a natural bridge from Chapter 15's subject: the test server does the graceful-shutdown wait for you, so a test that wants to assert “the handler completed before the server went away” needs no timing logic at all — just defer srv.Close() and an assertion afterwards. It is also a trap in the other direction: a handler that blocks forever makes Close block forever, turning a handler bug into §16.1.3's hang.

16.6.4 Testing Error Paths

Chapter 14 built error handling for concurrent code and named this as its outstanding debt:

From Chapter 14's “What we’re not covering”

“Testing concurrent error paths, goleak, and race-detector integration — Chapter 16”

Error paths are the hardest thing in this chapter to test, and the reason is specific. Success has one shape: everything worked. Failure has many, and which one you get is decided by a race — so an error-path test is a schedule assertion unless you make it something else.

The sharpest case is errgroup. §14.4 established that Wait returns the first non-nil error, where “first” means first to return, not most important. A test that asserts on which error comes back is therefore asserting on a schedule.

Measured two goroutines in an errgroup, both returning an error immediately, 200 rounds:
Terminal
over 200 rounds: map[slow failure:198 fast failure:2]

A 99-to-1 split. Not a coin toss — and that is worse, because a coin toss is obviously non-deterministic and a 198-to-2 split looks stable. A test written against the majority outcome passes 99% of the time, ships, and fails in CI at a rate low enough to be dismissed as flake. §16.1.1's problem again, now in the code that verifies error handling.

Compare that result with §16.1.6's, which printed the opposite winner from what looks like the same experiment. Both are correct, and the reason is worth knowing because it explains why the split is so lopsided rather than even.

Measured the same pair, with the registration order swapped, four runs of 200 each.
Terminal
slow registered first -> fast wins: 196, 196, 197, 198 / 200
fast registered first -> slow wins: 197, 198, 198, 200 / 200

The second goroutine registered wins, about 98–100% of the time, whichever error it carries. The mechanism is in the scheduler. newproc — the runtime function behind every go statement — finishes with runqput(pp, newg, true), and that final true means put this goroutine in the P’s runnext slot. runnext holds exactly one goroutine and it runs next, so each new goroutine displaces the previous one into the ordinary local queue.

Two g.Go calls in a row therefore do something that looks unfair and is entirely deterministic: the second one takes runnext, the first is pushed back, and the second runs first.

That is the whole explanation for the lopsidedness. There is no near-coin-toss here to be sampled — there is a structural bias with a little noise on top, which is precisely why the test looks reliable for fifty runs. §16.1.6's five parties were not unlucky; they were reading a real pattern and assuming it was a guarantee.

The fix is a seam that forces the order:

err_166.go
// Illustrative snippet — not a complete program
g, _ := errgroup.WithContext(context.Background())
g.Go(func() error { return errFast })
g.Go(func() error {
    time.Sleep(time.Millisecond)   // ordering, not synchronisation
    return errSlow
})
err := g.Wait()
Measured with the delay in place, 200 rounds returned fast failure 200 times. Deterministic.

That listing deserves a caveat, because it appears to contradict §16.6.2. The sleep here is not being used to establish that something has happened — it is being used to make one path deliberately slower than another, and the assertion is about which of two errors is selected. A slow machine slows both. It is the sound use, and it is still worth replacing with a gate when the code under test offers one:

release_166.go
// Illustrative snippet — not a complete program
release := make(chan struct{})
g.Go(func() error { return errFast })
g.Go(func() error { <-release; return errSlow })
// ... assert Wait() == errFast ...
close(release)

Three more error-path shapes, with the seam each needs:

Proving a pipeline error cancels upstream. The invariant is that the producer stops. Seam: give the producer a counter or a channel it writes to per item, assert the count stops growing after the consumer fails. Inside a bubble, synctest.Wait makes this exact (§16.4.5).

Proving context.Cause names the right failure. §14.4's WithCancelCause means the cause is set once, by the first failure — so the test needs the same ordering seam as the errgroup case, and then asserts on context.Cause(ctx) rather than on Wait's return.

Asserting on a panic recovered in a goroutine. Per §16.2, the assertion cannot live in the goroutine that recovers. The recovering goroutine sends the recovered value over a channel and the test goroutine asserts on it:

panicked_166.go
// Illustrative snippet — not a complete program
panicked := make(chan any, 1)
go func() {
    defer func() { panicked <- recover() }()
    riskyWork()
}()
if got := <-panicked; got == nil {
    t.Fatal("expected a panic")
}

Note the buffered channel and the unconditional send — recover() returns nil when there was no panic, so the test distinguishes “panicked with nil” from “did not panic” by always sending exactly once.

16.6.5 Fault Injection Without a Framework

A clock seam controls when; a schedule seam controls what order; the third seam controls what goes wrong. It needs no library, and the pattern is one closure.

fail_n_166.go
// Illustrative snippet — not a complete program
// failN fails the first n calls, then succeeds. Atomic because the
// code under test may call it from any goroutine.
func failN(n int64) func() error {
    var calls atomic.Int64
    return func() error {
        if calls.Add(1) <= n {
            return errors.New("upstream unavailable")
        }
        return nil
    }
}

Point that at a retry with 1s/2s/4s backoff, inside a bubble, and a claim that would otherwise need seven real seconds and a tolerance band becomes an equality assertion:

recovers_on_test_166.go
// Illustrative snippet — not a complete program
func TestRecoversOnThirdAttempt(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        start := time.Now()
        if err := Do(t.Context(), failN(2)); err != nil {
            t.Fatalf("Do = %v, want nil", err)
        }
        got, want := time.Since(start), 3*time.Second
        if got != want {
            t.Fatalf("recovered after %v, want %v", got, want)
        }
    })
}
Measured recovered after exactly 3s, in 0.00s of wall clock — the 1 s and 2 s backoffs, and nothing else.

The same closure shape covers most of what people reach for a chaos library to do. Three come up constantly:

A failing io.Reader. Wrap a real one and return an error after N bytes. This is how you test that a partial read is handled rather than assumed away.

An http.RoundTripper that fails on demand. http.Client{Transport: rt} with an rt you wrote is the single most useful seam in a service, because it turns every network failure — timeout, connection reset, 503, a body that stops mid-stream — into a value you choose rather than an outage you wait for.

A net.Conn from net.Pipe. §16.4.8's escape hatch, and it lets you close one side at a moment you pick, which is how you simulate a peer disappearing.

One rule applies to all of them: the injector’s own state must be safe for concurrent use. failN uses atomic.Int64 rather than a plain counter for exactly that reason. Getting it wrong produces a data race in your test, which -race will duly report — with a stack that points into the code under test, sending you to debug the wrong file.

16.6.6 Table-Driven Concurrent Tests

The standard Go idiom, with the concurrency-specific parts that actually matter in 2026.

policy_test_166.go
// Illustrative snippet — not a complete program
func TestPolicy(t *testing.T) {
    cases := []struct {
        name    string
        workers int
        want    int
    }{
        {"single", 1, 100},
        {"parallel", 8, 100},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got := run(tc.workers)
            if got != tc.want {
                t.Errorf("got %d, want %d", got, tc.want)
            }
        })
    }
}

What may vary between cases: the concurrency level, the order operations are submitted in, the timing seam’s schedule. Those are the axes worth tabulating, and varying concurrency level across cases is the single most valuable column — running the same assertion at 1, 2 and 8 workers catches a surprising number of bugs that a fixed count does not.

What must not vary, and this is the failure mode: a shared fixture. If the cases run in parallel and touch the same object, the table has stopped being a set of independent tests and become one concurrent test with a confusing structure. Each case needs its own instance, created inside the subtest.

The cleanup interaction from §16.2.3 applies with full force here, because table-driven tests are where t.Parallel gets used most:

policy_test_166_x_2.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: every parallel subtest sees a closed store
func TestPolicy(t *testing.T) {
    store := open()
    defer store.Close()
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            t.Parallel()
            store.Query(tc.input)   // store is already closed
        })
    }
}

t.Cleanup(store.Close) fixes it — but the better fix is usually one store per subtest, which removes the sharing that made the ordering matter.

Two smaller notes. t.Setenv anywhere in the loop body means no case can call t.Parallel (§16.2.3), which is a common surprise in tables that configure behaviour through environment variables. And a bubble cannot contain t.Run, so a table of synctest cases puts the loop outside:

policy_table_test_166.go
// Illustrative snippet — not a complete program
func TestPolicyTable(t *testing.T) {
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            synctest.Test(t, func(t *testing.T) {
                // one bubble per case
            })
        })
    }
}

And the bug that is not on this list: loop-variable capture. Before Go 1.22 the loop variable was shared across iterations, so subtests that ran after the loop finished — which is exactly what t.Parallel causes — all saw the final case. Go 1.22 gave each iteration its own variable and the bug is gone. It remains the most-warned-about hazard in table-driven concurrent testing, which now costs readers attention they should be spending on shared fixtures.

16.6.7 Common Mistakes

time.Sleep to wait for a goroutine
Problem

Flaky under load

Fix

A barrier: the goroutine closes a channel on arrival

runtime.Gosched() as synchronisation
Problem

Measured, fails 2–3% of the time

Fix

A channel; Gosched is a hint, not an edge

A tight timeout on a failure detector
Problem

Fails on busy CI, catches nothing extra

Fix

Seconds, not milliseconds — cost is paid only on failure

Deriving ordering from a timeout
Problem

A schedule assertion in disguise

Fix

A gate or a barrier

Asserting which errgroup error wins
Problem

Measured, a 198:2 split that looks stable

Fix

Force the order with a gate, then assert

Asserting on recover() inside the goroutine
Problem

The assertion cannot fail the test (§16.2)

Fix

Send the recovered value out; assert on the test goroutine

Unbuffered channel for a recovered panic
Problem

The goroutine blocks if the test gave up

Fix

Buffer it; send exactly once, including nil

A shared fixture across parallel table cases
Problem

Cross-talk, or a closed handle

Fix

One instance per subtest

A clock seam where a bubble would do
Problem

An interface in the production API for nothing

Fix

Bubble the logic; seam only across a real connection

A fault injector with unsynchronised state
Problem

-race fires, pointing at the code under test

Fix

atomic in the injector; it is called concurrently

Summary: Seams: Making Time, Schedule and Failure Injectable

Everything a bubble cannot contain is tested by owning the ordering instead of the clock. A seam is where the test substitutes its own decision, and concurrency adds three things worth seaming: when something happens, in what order, and which failure occurs first.

Clock seams still matter, but in a narrower range than before — when a real connection must stay open across the wait, or when the code is not yours to change. Otherwise a bubble is exact and free. context.AfterFunc is often better than either, because cancellation is testable without any clock at all.

Schedule seams are gates, barriers and rendezvous, and the thing they replace is the sleep. runtime.Gosched is not a substitute: measured, the value was invisible in 42–52 rounds out of 2000, a 2–3% failure rate that is exactly bad enough to look like it works.

Timeouts stay honest as long as they are failure detectors rather than ordering devices, and generous rather than tight. Measured, httptest.Server.Close blocks until outstanding requests finish — 250 ms for a request with 250 ms left — which removes a timing assertion rather than adding one.

Error paths are the hardest case because failure has many shapes and a race picks one. Measured, two errgroup goroutines failing simultaneously produced a 198-to-2 split, which is more dangerous than a coin toss because it looks deterministic; forcing the order made it 200-to-0.

Self-Check Questions: Seams: Making Time, Schedule and Failure Injectable

Why is a 198-to-2 outcome split more dangerous in a test than a 100-to-100 split?

Because a 100-to-100 split is visibly non-deterministic and a 198-to-2 split is not.

If a test’s outcome flips half the time, the first person to run it twice discovers that the assertion is wrong, and fixes it — usually by adding the ordering seam that should have been there. The bug is loud and it gets corrected at the moment it is introduced.

At 198-to-2, the test passes locally, passes in review, passes the first fifty CI runs, and fails on the fifty-first. By then it is established as trustworthy, so the failure is attributed to infrastructure. It gets re-run, it passes, and the incident is closed. Measured, that is exactly the split two errgroup goroutines failing simultaneously produced.

The general principle, and it recurs throughout this chapter: a bug’s cost is inversely related to how often it manifests, over the range where it manifests at all. §16.1.1's data race passed 197 runs in 200 for the same reason and had the same consequence.

The fix is not to run the test more times to characterise the split. It is to remove the race from the test by forcing the order, so the outcome is 200-to-0 and the assertion means something.

A colleague replaces time.Sleep(10*time.Millisecond) with runtime.Gosched() in a test, calling it “the same thing but instant”. What is wrong?

Both are wrong, but they are wrong in different ways, and the replacement is the more dangerous of the two.

Gosched yields the processor and returns. It makes no guarantee that any particular goroutine ran, and none at all that one finished. Measured, in the canonical pattern — start a goroutine that sets a flag, Gosched, read the flag — the flag was unset in 42 to 52 rounds out of 2000, a 2–3% failure rate.

The sleep is more likely to work by accident, because 10 ms is a long time for a goroutine that only sets a variable. So the change makes the test more flaky while looking like a strict improvement, and it removes the one clue — a magic duration — that would have made a reviewer suspicious.

Neither creates a happens-before edge, which has a second consequence: if the flag is written by one goroutine and read by another, this is a data race, and -race reports it in both versions. §16.3.2 explains why the Gosched does not hide it.

The fix is a barrier. Have the goroutine close a channel when it is done and receive from it. That is instant and correct, which is what the colleague actually wanted.

You need to test that a worker pool cancels its producer when a consumer fails. What is the invariant, and what seam does it need?

The invariant is that the producer stops producing after the failure — not that it stops within some duration, and not that it produced exactly N items, since the count at the moment of failure is inherently racy.

The seam has two parts. First, make the failure happen at a moment you choose rather than a moment the scheduler chooses: give the failing consumer a gate, so the test decides when it returns its error. Second, make the producer’s activity observable: have it increment a counter or send on a channel per item, so the test can see whether it is still working.

The assertion then becomes: open the gate, wait for quiescence, record the count, wait again, and assert the count did not change. Two observations of the same value, separated by a point at which everything that could run has run.

That middle step — “wait for quiescence” — is the hard part outside a bubble, and it is where most versions of this test go wrong by substituting a sleep. Inside a bubble it is exactly synctest.Wait, which is why §16.4.5 called it the primitive that makes absence assertions possible. If the pool holds no sockets, bubble it and the test becomes three lines and deterministic. If it does, the honest version uses a generous timeout as a failure detector and accepts that it is testing “stopped within 2 seconds” rather than “stopped”.

Key Takeaways

  • A seam lets a test substitute its own decision; concurrency adds three worth seaming — when, in what order, and which failure first
  • Clock seams are now for the narrow cases: a real connection held across the wait, or code you cannot change. Otherwise bubble it
  • context.AfterFunc makes cancellation the trigger, which is testable with no clock at all
  • Gates, barriers and rendezvous replace sleeps; measured, Chapter 15's barrier took a correct fix from 36-of-40 to 40-of-40
  • runtime.Gosched is not synchronisation — measured, 2–3% failure, bad enough to look like it works
  • A timeout is a failure detector, never an ordering device, and should be generous because passing tests never reach it
  • Measured, httptest.Server.Close blocks until outstanding requests finish, removing a timing assertion rather than adding one
  • Measured, simultaneous errgroup failures split 198-to-2 — force the order before asserting which error wins
  • Loop-variable capture was fixed in Go 1.22; shared fixtures across parallel cases are the real table-driven hazard
Section 16.6 — in one line

When you cannot own the clock, own the ordering — and every sleep in a test is an ordering you declined to own.

16.7 Stress: What More Runs Actually Buy

“Run it a thousand times and see if it breaks” is the oldest advice in concurrent testing, and it is given without qualification often enough that it has become folklore. It is worth keeping, but not for the reason usually offered — and the difference decides whether a nightly stress job is finding bugs or burning CPU.

16.7.1 The Reframe

§16.3.3 measured the fact that reorganises this section: fifty -race runs of a covered path found nothing, and one run of an uncovered path found the race immediately.

That result rules out the intuitive model. Repetition does not make the race detector more sensitive, because the detector is not sampling — it reports every unordered pair of accesses that executes, on the first run they execute (§16.3.2). Running the same code path again gives it nothing it did not already have.

So what does -count=1000 buy?

Interleavings, for the bugs -race cannot see. §16.3.4's check-then-act withdrawal has no data race and drove a balance negative in 5–13 rounds out of 200. That bug is invisible to the detector at any coverage level, and it is a function of scheduling — so more runs, on more cores, genuinely does increase the chance of observing it.

Paths, when the repetition varies something. -count alone re-runs identical code. -cpu changes GOMAXPROCS, -shuffle changes test order and therefore the state each test inherits, and a randomised test body chooses different operations. Those are the flags that change what executes.

The distinction in one line: -race needs coverage; stress needs interleavings. They are different problems with different tools, and running -race -count=1000 on a package with 40% coverage spends a thousand runs on the same 40%.

With one addition from §16.3.2: because randomizeScheduler is on in a race build, -race -count=N explores a genuinely wider set of orderings than -count=N alone. That is worth nothing for data races — the analyser already sees those on the first run — and a great deal here, because the race build is the only scheduling fuzzer the toolchain ships and this is the half of the bug population that needs one.

16.7.2 The Knobs

Five flags, and what each actually varies.

Flag
-count=N
-cpu 1,2,4,16
-shuffle=on
-failfast
-timeout

Two of these deserve more than a row.

-cpu 1 is the most under-used flag in Go testing. Running at GOMAXPROCS=1 does not remove concurrency — goroutines still interleave at every preemption point — but it removes parallelism, and that changes which bugs are reachable. Some races need true parallelism and vanish; others become far more likely, because a goroutine that would have run on another core now has to wait for a scheduling point, widening windows that are microseconds wide at -cpu 16. Running both ends is worth more than running either one a hundred times. Chapter 1's exercise made this point at the very start of the book; it is the same lever.

-shuffle is how you find tests that depend on each other, which is a large fraction of “it only fails in CI” once the §16.2.2 orphans are dealt with. Crucially, it reports its seed:

Measured a shuffled run, which prints the seed it chose.
Terminal
$ $ go test -shuffle=on -v ./...
$ -test.shuffle 1788712755386749000

That number is the whole point. A failure under -shuffle=on is reproducible with -shuffle=1788712755386749000, which pins the order exactly. Without capturing the seed a shuffled failure is nearly useless; with it, it is an ordinary bug report. Make sure your CI prints and preserves that line.

16.7.3 Triage: From Flake to Fix

The techniques above are only worth having if there is a path from “CI went red once” to a fix. Chapter 10 has an incident workflow for production; this is the test-suite equivalent, and the first step is the one people skip.

Capture before you do anything else. The full output, the seed if -shuffle was on, the iteration number, the -cpu value, the toolchain version, the commit. Re-running destroys all of it, and re-running is the single most common way a real finding is lost. Paste it into an issue before touching anything.

That instinct — run it again to see it — is exactly wrong. A failure that reproduces one time in five hundred will not survive the debugging process; every change made to observe it changes the timing, and adding a print statement is enough to hide it for a week. The one in front of you is likely the best evidence you will get for months.

Then classify it, using §16.1.6's tell. A plausible alternative — a different error, a reversed order, a count off by one — points at a schedule assertion, and the fix is in the test. Garbage values, a nil dereference or a torn struct point at a data race or a lifecycle bug, and the fix is in the program. The two get completely different treatment from here, so thirty seconds spent on this saves hours.

Then reproduce with the cheapest tool that works.

CHEAPEST TOOL THAT REPRODUCES IT, IN ORDER

An ordered ladder of tools for reproducing an intermittent test failure, each tried when the one above fails. First -count=1, to see whether it fails deterministically. Then -count=100 with -race, for a rare interleaving on a path already covered. Then -cpu 1 and -cpu 16, for parallelism-dependent bugs. Then -shuffle with a recorded seed, for order-dependence, which needs a sibling test. Finally running the test alone: if it passes alone the bug is between tests, and an orphaned assertion goroutine is the first suspect. The caption notes the last rung is a diagnosis rather than a failure to reproduce.

The last rung is worth stating plainly because it is so often misread as defeat. A test that fails in the suite and passes alone is telling you something specific: the bug is between tests. Shared global state, an orphaned goroutine from §16.2.2, a t.Setenv sibling, an unclosed listener on a fixed port.

Then build a deterministic reproduction, before fixing anything. This is the step that decides whether the rest of the work means anything, because a failure you can only reproduce statistically cannot be confirmed fixed — §16.7.5 puts a number on exactly how little a clean run buys you. The techniques in §16.4 and §16.6 exist to turn a statistical failure into a deterministic one: a bubble that pins the timing, a gate that forces the guilty ordering, a seam that makes the failing branch execute.

Then fix the class, not the instance. A schedule assertion means finding the invariant or placing a seam. A leak means adding the check that would have caught it at its source. A data race means asking which other paths have the same shape — a race in one method of a type is rarely alone.

The step almost everyone skips.

After the fix, make the old test fail again. Revert the production change, or point the assertions at a deliberately broken implementation, and confirm the suite goes red. A fix you have never seen fail is a fix you are taking on faith — and per §16.7.5, “it stopped failing” is also what a timing change looks like. This chapter’s exercise makes it a gate for exactly that reason.

Diagnosing the failure once it is captured and reproducible — reading the dump in depth, pprof, the execution tracer, delve — is Chapter 19's subject. This chapter’s job ends at a test that fails on demand.

16.7.4 Randomised Model Checking

One technique that finds a class of bug nothing else in this chapter reaches: race conditions in the §16.3.4 sense, where synchronisation is present but insufficient.

The idea is to stop asserting on specific outcomes and start asserting on an invariant, against a reference implementation that cannot be wrong.

concurrent_map_test_167.go
// Illustrative snippet — not a complete program
func TestConcurrentMapAgainstModel(t *testing.T) {
    real := NewConcurrentMap()
    model := map[string]int{}   // the reference: a plain map
    var mu sync.Mutex           // guarding the model only

    ops := randomOps(t, 500)    // seeded from -shuffle or a flag

    var wg sync.WaitGroup
    for _, op := range ops {
        wg.Go(func() {
            switch op.kind {
            case put:
                real.Put(op.key, op.val)
                mu.Lock()
                model[op.key] = op.val
                mu.Unlock()
            case del:
                real.Delete(op.key)
                mu.Lock()
                delete(model, op.key)
                mu.Unlock()
            }
        })
    }
    wg.Wait()

    // The invariant: every key the model has, the real map has,
    // with the same value -- regardless of the order they ran in.
    for k, want := range model {
        if got, ok := real.Get(k); !ok || got != want {
            t.Errorf("key %q: got (%v,%v), want %v", k, got, ok, want)
        }
    }
}

Three properties make this worth the effort. The assertion is an invariant, so it holds under every interleaving and the test is not asserting on a schedule. The reference model is obviously correct, because it is a plain map under one lock — you are not testing two implementations against each other and hoping. And the operation sequence is randomised and seeded, so a failure is reproducible from the seed, exactly as with -shuffle.

The design rules that make it work: keep the model trivial, because a model with a bug produces failures you will chase in the wrong code; log the seed on failure without exception; and prefer commutative operation sets or an invariant that does not depend on order, since otherwise you must model the ordering too and the model stops being obviously correct.

This is a small, hand-rolled corner of a large field — property-based testing, linearisability checking, and formal model checkers all live here and all do more. The version above needs nothing beyond the standard library and finds check-then-act bugs that -race structurally cannot.

16.7.5 The Statistics of Zero Failures

A stress job passes. What has it established? The question has a numerical answer, and knowing it prevents a common and expensive mistake.

If a bug manifests with probability p on each run, then N clean runs are consistent with any p small enough to have plausibly hidden. The standard statistical result — the “rule of three” — is that observing zero failures in N independent trials gives a 95% confidence upper bound of roughly 3/N.

Derived the rule of three applied to the run counts people actually reach for.
Clean runs
10
100
1,000
10,000

Read the second row carefully, because it is the one that matters in practice. One hundred clean runs are consistent with a bug that fires 3% of the time. That is a bug which would take down a service handling a hundred requests a second roughly three times a second.

WHAT N CLEAN RUNS RULE OUT

The rule of three applied to clean test runs. Ten clean runs remain consistent with a thirty per cent per-run failure rate; one hundred runs with three per cent; one thousand with nought point three per cent; ten thousand with nought point nought three per cent. The caption argues for reporting the bound rather than the run count, because saying a test passed one thousand times sounds conclusive and is not, while saying you can rule out rates above nought point three per cent states the same fact in a form a reader can judge.

The same arithmetic run forwards says how many runs you need to have a reasonable chance of seeing a known-rare bug at all:

Derived runs required for a 95% chance of observing at least one failure:
Per-run failure rate
50%
10%
1.5%
1%
0.1%

Now put that beside §16.1.1. That data race manifested at about 1.5% — three runs in two hundred. To catch it by repetition with 95% confidence you would need around 199 runs. Under -race, it was reported 10 times out of 10.

That comparison is the argument for this whole chapter in two numbers. Repetition is the most expensive way to find a concurrency bug and the least conclusive. Every other technique here — the detector, the bubble, a barrier, an invariant, a model — replaces a statistical argument with a structural one, and the structural one is both cheaper and stronger.

Which gives the honest way to report a stress result. Not “we ran it 1,000 times and it passed”, which sounds conclusive and is not, but “we ran it 1,000 times and can rule out failure rates above roughly 0.3%”. If 0.3% is not good enough for the system in question, the answer is not more runs — it is a technique that does not rely on runs.

16.7.6 Benchmarks as Correctness Tests

Benchmarking belongs to §11.2 for reading RunParallel output and to Chapter 20 for optimisation. Two things fall outside both.

A benchmark is a test. go test -race -bench . runs your benchmarks under the race detector, and benchmarks routinely reach code that tests do not: higher concurrency, larger inputs, longer runs, and the contended paths that only open under real load. Per §16.3.3 the detector’s limit is coverage, and a benchmark suite is coverage you already wrote. It is close to free to run occasionally and it finds things.

b.Loop supersedes b.N, and the reason is a correctness problem rather than an ergonomic one. Go 1.24 added it:

benchmark_old_167.go
// Illustrative snippet — not a complete program
// The form every benchmark written before Go 1.24 uses:
func BenchmarkOld(b *testing.B) {
    for i := 0; i < b.N; i++ {
        result = expensive(i)   // needs a sink to survive
    }
}

// Go 1.24+:
func BenchmarkNew(b *testing.B) {
    for b.Loop() {
        expensive(1)            // kept alive automatically
    }
}

b.Loop resets the timer on its first call and stops it when it returns false, so setup and cleanup fall outside the measurement without ResetTimer/StopTimer bookkeeping. More importantly, the compiler keeps arguments, results and assigned variables inside the loop body alive, which retires the KeepAlive-and-sink folklore that every benchmarking guide teaches.

The function under test, so the numbers below are reproducible:

mix_167.go
// Illustrative snippet — not a complete program
func mix(a, b int) int { return a*31 + b*17 }
Measured three runs each, with empty loops as controls.
Form
for i := 0; i < b.N; i++ { } — control
for i := 0; i < b.N; i++ { mix(i, 3) }
for i := 0; i < b.N; i++ { sink = mix(i, 3) }
for b.Loop() { mix(1, 3) }
for b.Loop() { } — control

Read rows one and two together, because that pair is the proof: calling mix costs exactly what calling nothing costs. 0.214 either way. The call is not being measured, it is being deleted, and the benchmark reports a number for work that never happened.

Row three shows why the sink was invented and why it is not free — assigning to a package variable keeps the call alive, and adds a store that the function itself never does, so the figure is now real but inflated.

Rows four and five are the surprise, and they correct an obvious misreading of the table. b.Loop reports the largest number not because it is the only form measuring the call, but because b.Loop has a per-iteration floor of roughly a nanosecond — the empty control costs 1.12, which is more than the mix measurement above it. At this scale mix is invisible inside that floor.

So the honest statement about b.Loop is narrower than the marketing and more useful: it is the only form that neither deletes your code nor charges you for a sink, and its floor of about a nanosecond means it cannot resolve operations faster than that. For anything sub-nanosecond, measure a batch per iteration and divide.

A benchmark that gets optimised away does not fail. It reports an impressively small number, and someone puts it in a commit message.

One incompatibility, and it is worth a mistakes row because the failure is confusing:

Measured b.Loop inside b.RunParallel fails, and fails two different ways across runs. Over 12 runs of the same benchmark: 7 produced
Terminal
panic: iteration count 51 < fixed target 50
    testing.(*B).loopSlowPath(...)

and 5 produced 15–16 repetitions of

Terminal
benchmark.go:417: B.Loop called with timer stopped

The cause is that b.Loop manipulates the shared benchmark timer and RunParallel documents that the timer functions must not be used inside its body, because they have global effect. Which of the two symptoms you get depends on which parallel goroutine reaches the check first — a concurrency bug in the benchmarking harness for a chapter about concurrency bugs, whose own failure mode is non-deterministic. Inside RunParallel, keep using pb.Next().

16.7.7 What Runs When

Pulling §16.3.5's cost data and this section’s knobs into a policy.

Terminal
A TESTING PIPELINE THAT PAYS FOR ITSELF
  LOCAL, every save
    go test ./... fast, no flags
    go test -race ./internal/pool the concurrent packages
  PER PR
    go test -race ./... non-negotiable
    go test -shuffle=on ./... catches coupling early
    Fails the build. Seconds to minutes.
  NIGHTLY
    go test -race -count=20 ./...
    go test -race -cpu 1,2,4,16 ./...
    go test -race -shuffle=on -count=5 ./...
    Files a bug. Tens of minutes.
  RELEASE / ON DEMAND
    -race against the integration suite
    randomised model checks, long seeds
    Blocks a release only on a confirmed failure.

The one non-negotiable is -race per PR on the packages that own concurrency. Per §16.3.5 it is most expensive on exactly that code, and per §16.1.1 it is the difference between a 1.5% symptom and a 10-out-of-10 report.

Beyond that, three properties matter more than the exact commands, and a pipeline that lacks any of them decays into decoration within a couple of months.

Every job has a named owner. A red nightly that belongs to nobody becomes background noise within two weeks, at which point you have converted a detectable bug into documented negligence — the failure is on record, visible, and ignored. That is strictly worse than not running the job, because it also manufactures the belief that stress testing is happening.

Failures carry their own reproduction. The job logs the seed, the iteration, the -cpu value and the full output automatically, per §16.7.3. If reproducing a nightly failure requires asking the person who wrote the job, it will not happen.

The expensive tiers cannot block a merge. The moment a weekly stress run gates a PR, someone will find a way to skip it, and they will be right to. That constraint is why the tiers exist at all: a single go test -race -count=100 on every push is worse than the whole pipeline in both directions — too slow to keep, and too shallow to find what the weekly run finds.

16.7.8 Common Mistakes

-count=1000 to find data races
Problem

Cost with no gain on covered paths

Fix

Coverage finds races; -count finds interleavings

“1,000 runs passed, therefore correct”
Problem

False confidence

Fix

Derived, 1,000 clean runs allow a 0.3% rate

Never running -cpu 1
Problem

Bugs that need serialisation are unreachable

Fix

-cpu 1,2,4,16; both ends beat repetition

Losing the -shuffle seed
Problem

An unreproducible failure

Fix

Print and preserve -test.shuffle N

Re-running a rare failure to see it again
Problem

The best evidence you had is gone

Fix

Capture first, then build a deterministic repro

Fixing before a deterministic repro exists
Problem

No way to confirm the fix

Fix

§16.4/§16.6 turn a statistical failure into a test

A complicated reference model
Problem

Failures chased in the wrong code

Fix

The model must be obviously correct

b.N with a discarded result
Problem

Measured, 0.214 ns/op — identical to an empty loop

Fix

b.Loop keeps the body alive

b.Loop inside RunParallel
Problem

Two different failures across runs

Fix

pb.Next() inside RunParallel

A nightly job nobody triages
Problem

Belief in coverage that does not exist

Fix

Route failures to an owner, or delete the job

Re-running a red build to “check” it
Problem

The seed, iteration and output are gone for good

Fix

Capture first, always (§16.7.3)

Shipping a fix you never saw fail
Problem

“It stopped failing” is also what a timing change looks like

Fix

Revert, confirm red, re-apply

Summary: Stress: What More Runs Actually Buy

Repetition does not make the race detector more sensitive — measured in §16.3.3, fifty runs of a covered path found nothing that one run of an uncovered path did not find instantly. What -count buys is interleavings, which matters for the race conditions -race structurally cannot see, and nothing at all unless something varies between runs.

-cpu 1,2,4,16 varies parallelism and is the most under-used flag in Go testing; -shuffle=on varies order and measured, prints its seed as -test.shuffle N, which is what makes a shuffled failure reproducible rather than merely interesting.

When stress finds something, capture before investigating, and build a deterministic reproduction before fixing — because derived, confirming a fix for a 0.1% bug by repetition needs about 3,000 runs. The rule of three sets the honest bound: 100 clean runs are consistent with a 3% failure rate, and 1,000 with 0.3%. Against §16.1.1's 1.5% data race, repetition needs ~199 runs for 95% confidence where -race reported it 10 times out of 10.

Randomised model checking — random operations against an obviously-correct reference, asserting an invariant — reaches check-then-act bugs nothing else here does, using only the standard library.

And benchmarks are tests: measured, a discarded call under b.N cost 0.214 ns/op — exactly what an empty loop costs, which is what deletion looks like — while b.Loop keeps it alive at the price of a ~1 ns per-iteration floor.

Self-Check Questions: Stress: What More Runs Actually Buy

A nightly job runs go test -race -count=500 and has been green for six months. What can you conclude?

Much less than the job’s existence suggests, and the two halves fail for different reasons.

For data races, the 500 is doing almost nothing. The detector reports every unordered access pair that executes, on the first run it executes (§16.3.2), so run 500 examines the same code as run 1. What the job establishes is bounded by coverage: the covered fraction of these packages is race-free. Branch coverage, not run count, is the number that qualifies the claim.

For race conditions — the check-then-act bugs the detector cannot see — the 500 is doing real work, and the rule of three says how much. Derived, zero failures in 500 runs gives a 95% upper bound of about 0.6% per run. So a bug firing once in every 200 runs is entirely consistent with six months of green.

There is a third thing worth checking that has nothing to do with either: whether the job varies anything. -count=500 alone re-runs identical code in identical order at identical parallelism. Adding -cpu 1,2,4,16 and -shuffle=on would make the same runtime budget explore genuinely different executions, which is where the remaining value is.

You are told a bug reproduces “about one run in five hundred”. Your fix is in. How many runs should you do to confirm it?

The question has no good answer, and recognising that is the point.

Running it to confirm means distinguishing “fixed” from “still fires at 0.2%”. Derived, seeing a 0.2% bug at least once with 95% confidence takes about 1,500 runs — so a clean 1,500 gets you to roughly “no worse than it was”, and you would need several times that to claim a real improvement. If each run takes ten seconds, that is more than four hours to reach a weak conclusion.

Worse, a clean result does not distinguish a fix from a timing change. Adding a check, a log line, or the fix itself perturbs the schedule, and a bug that fires at 0.2% can drop below observability without being repaired at all. That failure mode is invisible: the job goes green and the bug ships.

The correct move is to stop trying to confirm statistically. Build a deterministic reproduction first (§16.7.3) — a bubble that pins the timing, a gate that forces the guilty ordering, or an invariant test in the shape of §16.7.4. Then the fix is confirmed by a test that fails before and passes after, in one run, permanently, and the test stays in the suite as a regression guard.

If the deterministic repro genuinely cannot be built, that is information: it usually means the failing interleaving is not yet understood, and more runs will not supply the understanding.

Why is -cpu 1 worth running when your production service always runs on many cores?

Because it changes which bugs are reachable, not which environment you are simulating.

At GOMAXPROCS=1 goroutines still interleave — the scheduler preempts at function calls, channel operations, allocations and asynchronous preemption points — but only one runs at a time. That shift moves the bug population in both directions. Races that need genuine simultaneity become unreachable and stop appearing. Races that need one goroutine to be descheduled at an awkward point become much more likely, because a goroutine that would have proceeded on another core now waits, and a window that is nanoseconds wide at -cpu 16 can stay open for a whole scheduling quantum.

That second category is the one you are buying. Check-then-act bugs (§16.3.4) are a good example: at high parallelism the two operations are usually close together in time, while at -cpu 1 an unlucky preemption between them lasts far longer.

There is also a cheap, unrelated benefit. Tests that pass only at -cpu 16 sometimes do so because they accidentally depend on parallelism — a background goroutine that happens to get a core, a timing assumption that holds only when nothing has to wait. -cpu 1 surfaces those as failures, and they are real bugs in the tests.

Running both ends is worth more than running either one repeatedly, which is §16.7.1's point about varying something rather than repeating.

Key Takeaways

  • Repetition does not improve race detection — the detector reports on the first run a path executes; coverage is the lever
  • -count buys interleavings, and only for bugs the detector cannot see; it buys nothing if nothing varies between runs
  • -cpu 1,2,4,16 varies parallelism and reaches bugs that repetition at one setting never will
  • Measured, -shuffle=on prints -test.shuffle N; capturing that seed is what makes the failure reproducible
  • Capture before investigating, and build a deterministic reproduction before fixing — a rare failure will not survive debugging
  • Derived, 100 clean runs allow a 3% failure rate and 1,000 allow 0.3%; report the bound, not the run count
  • Derived, catching §16.1.1's 1.5% race by repetition needs ~199 runs; -race reported it 10 of 10
  • Randomised operations against an obviously-correct model, asserting an invariant, reaches check-then-act bugs nothing else here does
  • Measured, a discarded call under b.N costs 0.214 ns/op, identical to an empty loop — deletion, not speed; b.Loop keeps it alive but has a ~1 ns floor of its own
Section 16.7 — in one line

More runs is the most expensive and least conclusive way to find a concurrency bug, and every other technique in this chapter exists to replace a statistical argument with a structural one.

Chapter Summary

Every other chapter in this book taught you to build something. This one asks what it would take to know that what you built works — and the honest starting position is that a green concurrent test barely tells you anything, because the scheduler picked the execution you observed and will pick differently tomorrow.

That gives the chapter its spine. A passing concurrent test is not evidence — it is one interleaving that happened to work. Own the schedule and it becomes proof.

Everything here is one of two moves. Either shrink the space of possible executions until the one you ran is the only one there is — which is what a synctest bubble does, and why a thirty-second budget or a ten-minute deadline becomes an equality assertion costing no real time. Or accept that you cannot, and be exact about what your evidence is worth — which is what the race detector’s asymmetry gives you, and what the rule of three takes away.

The order of the seven sections is the order of the dependencies, and it is worth seeing as a whole. You cannot own a schedule if your assertions run on the wrong goroutine, so §16.2 comes before everything. You cannot judge how much determinism you need until you know exactly what a clean -race run proves and what it costs, so §16.3 comes before the bubble. §16.4 buys determinism where the code will fit; §16.5 shows what that determinism gives you free, and where it still cannot see. §16.6 covers everything a bubble cannot reach, which in most services is most of the interesting code. And §16.7 is what remains when none of the above applies — a search, with honest arithmetic about what it bought.

Three ideas recur and are worth carrying out of the chapter on their own.

A bug’s cost is inversely related to how often it manifests. A test that fails half the time gets fixed on the day it is written. A test that fails 1.5% of the time gets re-run, then trusted, then quoted in a review, and the mechanism it was reporting ships. That is true of the opening data race, of errgroup's ordering, and of goleak on a slow CI box.

The tools' limits are exact, and knowing them is most of the skill. The detector proves what executed and says nothing about what did not. The bubble knows every possible future of the goroutines inside it and nothing about a socket. goleak waits 431 ms and calls anything slower a leak. None of these are approximations to be worked around; each is a precise boundary that tells you which tool the code in front of you needs.

Assertions are values, not side effects. It is the smallest idea in the chapter and it appears in every section — in t.Fatal from a child, in a helper’s signature, in the recovered panic sent over a channel, and finally in the exercise, where a check that returns a verdict can be pointed at a broken implementation and asked whether it complains, and a check that calls t.Error cannot.

Chapter Connections

How Chapter 16 connects
Chapter 1
§1.5's -cpu 1 lever returns in §16.7.2 as the most under-used flag in Go testing
Chapter 2
§2.4 introduced goleak; §16.5 delivers the “properly” its Further Reading promised, and the bubble replaces it where code fits in one
Chapter 4
Every gate, barrier and rendezvous in §16.6.2 is §4.2's select on a done channel
Chapter 7
§7's pipelines are what §16.6.4 tests when it proves a consumer failure stops the producer
Chapter 8
§8.3 taught the race detector; §16.3 asks what a clean run is worth, and §8.2's data-race/race-condition split becomes §16.3.4's measurement
Chapter 9
§16.4.4 is the one place where testability argues for channels over mutexes — a bubble cannot see a lock
Chapter 10
§10.4's “the detector only fires when every goroutine sleeps” is why §16.1.3 exists; §16.5.6's dump is §10.4's, with bubble tags
Chapter 11
§11.2 owns RunParallel and -cpu; §16.7.6 adds only b.Loop and benchmarks-as-tests
Chapter 12
§12's sync primitives are what §16.4.3's durably-blocked table divides in two
Chapter 13
t.Context() (§16.2.4) is §13's cancellation with the test’s lifetime attached, and §16.4.6 makes every deadline in that chapter assertable at its production value
Chapter 14
§14.4's “first error wins” is a schedule — §16.1.6 takes that flake apart, §16.6.4 places the seam, and §16.5.4 says why its exercise counted goroutines by hand
Chapter 15
Shutdown is the motivating case: §15.7.7 measured the socket limit and handed the technique here, and this chapter’s exercise is that sequencer with the socket removed
Chapter 18
The bug catalogue; §16.1 names failure modes but does not enumerate bugs
Chapter 19
Diagnosis after capture — dumps, pprof, the tracer. §16.7.3 stops at a reproducible test
Chapter 20
Benchmarking for speed; §16.7.6 stops at benchmarks that are correctness tests

Final Checklist

Before moving to Chapter 17, ensure you can:

Exercise 16.1 — Own the Clock, and Return a Verdict

Your move

Own the Clock, and Return a Verdict

Every exercise in this book has handed you broken code and asked you to fix it. This one hands you a working sequencer and a broken check, and the check is the deliverable.

Chapter 15 built a shutdown sequence and then could not test it properly — its own exercise fell back to real timeouts and hand-counted goroutines, because the service held a socket and §15.7.7 measured why that keeps it out of a bubble. This is that sequencer with the socket removed: pure phases, contexts and timers, so all of it fits inside one.

Sequencer is given, and it is correct. It runs phases in order inside a budget, giving each an equal share, and cutting off any phase that overruns. These are its declarations from ch16/shutdown.go; the implementation is on disk and you do not need to change it.

ch16/shutdown.go
// Illustrative snippet — not a complete program
// A Phase is one step of a shutdown. Run must return when its ctx
// is cancelled; the per-phase deadline is what stops one slow step
// from eating the whole window.
type Phase struct {
    Name string
    Run  func(ctx context.Context) error
}

// Report describes how a shutdown went.
type Report struct {
    Graceful bool          // every phase finished inside its share
    Drain    time.Duration // how long the whole sequence took
    Stalled  string        // first phase to run out of budget, or ""
}

// Shutdowner is the contract the checks are written against, so the
// same checks can be pointed at a deliberately broken build.
type Shutdowner interface {
    Shutdown(ctx context.Context) (Report, error)
}

// Factory builds a Shutdowner. Verify takes one of these rather than
// a concrete type: that is what lets one set of checks run against
// New and against NewBroken.
type Factory func(budget time.Duration, phases []Phase) Shutdowner

Every gate uses the same three phases inside a thirty-second budget — propagate wants 2 s, drain wants an hour, release wants 1 s — so each gets a ten-second share and the middle one must be cut off. Measured, the correct sequencer reports Graceful=false, Stalled="drain", and Drain of exactly 13 s, which is 2 + 10 + 1.

What is broken is verify.go, printed here in full because it is the file you will be rewriting. It compiles, gofmt and go vet are clean, go test -race finds nothing, and it returns nil for the real Sequencer. It proves almost none of the contract.

ch16/verify.go
package ch16

import (
	"context"
	"errors"
	"testing"
	"time"
)

// Budget is the window every gate uses.
const Budget = 30 * time.Second

// Phases returns the three phases every gate uses: one that fits,
// one that never finishes, and one that fits. With a 30s budget each
// gets a 10s share, so the middle one must be cut off.
func Phases(log *[]string) []Phase {
	return []Phase{
		{Name: "propagate", Run: func(ctx context.Context) error {
			*log = append(*log, "propagate")
			return sleepCtx(ctx, 2*time.Second)
		}},
		{Name: "drain", Run: func(ctx context.Context) error {
			*log = append(*log, "drain")
			return sleepCtx(ctx, time.Hour)
		}},
		{Name: "release", Run: func(ctx context.Context) error {
			*log = append(*log, "release")
			return sleepCtx(ctx, time.Second)
		}},
	}
}

func sleepCtx(ctx context.Context, d time.Duration) error {
	t := time.NewTimer(d)
	defer t.Stop()
	select {
	case <-t.C:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

// Verify checks the contract a Shutdowner must keep, and returns
// the first breach it finds, or nil.
//
// TODO(reader): this compiles, vets clean, has no data race, and
// returns nil for the real Sequencer. It proves almost none of the
// contract. It waits by sleeping, so it burns the whole budget in
// real time, and it reports its findings with t.Error from a
// goroutine, so those findings cannot be returned.
func Verify(t *testing.T, newFn Factory) error {
	t.Helper()

	var log []string
	rep, err := newFn(Budget, Phases(&log)).
		Shutdown(context.Background())
	if err != nil {
		return err
	}

	go func() {
		if len(log) != 3 {
			t.Errorf("ran %d phases, want 3", len(log))
		}
		if rep.Graceful {
			t.Error("Graceful = true, want false")
		}
		if rep.Stalled != "drain" {
			t.Errorf("Stalled = %q, want \"drain\"", rep.Stalled)
		}
	}()

	// Wait "long enough" for anything still running to settle.
	time.Sleep(100 * time.Millisecond)

	if rep.Drain > Budget {
		return errors.New("drain exceeded the budget")
	}
	return nil
}

Note why go vet is silent here. The analyzer in §16.2.6 flags t.Fatal from a goroutine, and these are t.Error — which is genuinely safe to call from anywhere, so vet is right not to complain. The bug is not that the calls are unsafe. It is that a finding recorded with t.Error can never be returned, and gate 4 asks Verify a question it can only answer with a return value. That is §16.2.5's distinction with real consequences attached: these three checks are the right checks, written in a form that cannot be interrogated.

Five gates. Three already pass, and two of those are worked examples showing the shape to copy.

Gate
TestPhasesRunInOrderInsideTheBudget
TestStalledPhaseIsCutOffAtItsShare
TestVerifyReturnsNilForTheRealSequencer
TestVerifyCatchesTheBrokenSequencer
TestVerifyLeavesNothingRunning

Gate 3 supplies the budget, so shrinking it is not available; the only way through is to stop waiting on the real clock. Measured, the starter takes 13.1 s on that gate alone and 39.75 s for the package.

Gate 4 is the one worth thinking about hardest. NewBroken runs the phases correctly and then lies in the report — it sets Graceful = true and clears Stalled. The starter’s Verify does check for that, in the goroutine, where the finding cannot fail anything and cannot be returned. Fixing gate 3 without fixing gate 4 is possible and is the trap: moving into a bubble makes the check fast while leaving its verdicts unreachable.

That is the chapter’s own lesson turned into a constraint. A check whose findings are values can be pointed at a second implementation and interrogated; a check whose findings are side effects on a *testing.T can only be run. §16.2.5 argued it; this gate enforces it.

Measured the reference solution — Verify wrapped in synctest.Test, synctest.Wait before reading state, and every finding returned rather than reported — passes all five gates in 0.00s each, 0.400 s for the package, and stays clean under -race -count=10.

From 39.75 seconds and two failures to under half a second and a proof.

Done when: go test -race ./... in code/ch16/ reports ok for all five gates, and keeps reporting it under -count=10.
Two traps, and only one of them looks like a trap. The first is fixing gate 3 by shortening something — the budget is handed to Verify by the gate, so the only thing left to shorten is the phases, and a Verify that proves the contract for a two-millisecond budget has proved nothing about the thirty-second one. The second is subtler and catches more people: moving into a bubble makes gate 3 pass immediately, and gate 4 keeps failing, because a bubble changes when the checks run and not what they can say. The three findings are still t.Error calls inside a goroutine, and a finding that cannot be returned cannot be interrogated. Both fixes are needed and neither implies the other.
Where the files are: labs/go-concurrency/code/ch16/. A worked answer sits in solution/verify.go.txt, including why synctest.Wait is required before reading log — without it the check races the phases it is inspecting — and why Verify takes a Factory rather than a Shutdowner, which is what lets gate 4 build a second, broken implementation with the same three phases.

Further Reading

Next

You can now write a concurrent test that means something. You can own the clock where the code lets you and build a seam where it does not; you can tell a data race from a race condition and say which tool finds which; and you can read a green run and state, with a number, what it does and does not rule out. The shutdown sequencer from Chapter 15 is no longer a thing you hope works on deploy day — its thirty-second budget is proved, exactly, in well under a second.