Chapter 19: Debugging Concurrent Programs

Chapter 16 ended at a test that fails on demand. This chapter starts one step later, at the thing that test cannot give you: a service that failed at 03:14, once, on one replica, and has been fine ever since.

You have a dump, or a graph, or a report. The bug is over. Everything you can still look at is the wreckage.

That is the situation this chapter is about, and the difficulty is not that concurrent bugs are complicated. Most of them are embarrassingly simple once you can see them. The difficulty is that a concurrent bug lives in an interleaving, not in a state, and an interleaving has already finished by the time you think to look at it.

A sequential bug waits for you. Set a breakpoint, stop the program, read the variables, and the answer is sitting there — because the bug is a state, and states persist. A concurrent bug is a relationship between two goroutines that happened at 03:14:17.229 and will not happen again. It leaves no residue. By the time you have a dump, the machine has forgotten the ordering that caused the problem.

Which is why the reflex that works everywhere else — stop it and look — is the one reflex that cannot work here. Stopping is a change to the schedule, and the schedule is the thing under investigation.

So the tools divide sharply into ones that tell you where everyone ended up and ones that tell you how they got there, and the second kind has to have been running before the incident to be of any use.

Here are four diagnoses. Every one was produced by a correct tool, run correctly, on a program that was genuinely broken. All four are wrong.

snippet_19_x_1.go
// Illustrative snippet — not a complete program
// ✗ "No blocking detected." The profile is empty because
// nothing ever enabled it -- not because nothing blocked.
resp, _ := http.Get(base + "/debug/pprof/block")
// 0 samples. The service is blocking constantly.
Terminal
# ✗ "The panic is in the HTTP handler."
# GOTRACEBACK defaults to `single`: you were shown ONE
# goroutine. The one holding the lock was not printed.
GOTRACEBACK=single -> 1 goroutine stack <- the default
GOTRACEBACK=all -> 2 goroutine stacks
GOTRACEBACK=system -> 6 goroutine stacks
n_19_x_1.go
// Illustrative snippet — not a complete program
// ✗ "Only 4 contention events." Profile records are unique
// STACKS, not events. There were tens of thousands.
n := pprof.Lookup("mutex").Count()
Terminal
# ✗ "It stopped failing under the debugger, so it must be
# a load-related timing issue that resolves under load."
# You did not observe the bug. You prevented it.
$ dlv attach 4021
(dlv) break worker.go:88

None of these is a misuse. The endpoint call is right, Count is the documented API, and attaching a debugger to a misbehaving process is what a careful engineer does. Each produces a confident, specific, wrong answer — which is worse than no answer, because you act on it. And there is no analyzer for “asked the wrong instrument”, which is why this chapter leans harder on measurement than any before it: nearly every claim here is about what a tool does not tell you, and the only way to establish that is to run it. Measured: a program performing 32,000 contended lock operations and 200 blocking channel sends reports zero block records and zero mutex records under default settings (§19.3.1).

That is the shape this chapter is about, and it is the shape §14.1 named and §15.4 met at the process boundary, arriving a third time: not a crash, but a confident report of health.

WHAT SURVIVES THE INCIDENT

Four things exist during an incident and three of them survive it. The interleaving is gone the instant it happened, unless something was already recording. The state survives as a dump -- where everyone ended up. The aggregate survives as a profile -- where time accumulated. The counters survive as metrics -- whether it was happening at all. The one that would have told you why is the one you were not running.

What you’ll learn
  • Why a concurrent bug cannot be found by stopping and looking, and why the debugger is the weakest tool here rather than the strongest
  • The four views of a running program, what each one costs to take, and a triage that picks one
  • Why the goroutine dump you get in an incident is missing the labels you carefully added — until go 1.27, and why that is a decision from then on
  • Why two of the seven standard profiles are empty until you say otherwise, and report health when they are
  • Which profile blames the goroutine that suffered and which blames the one that caused it — measured, and they disagree by design
  • The one contention number you can read with nothing enabled at all
  • What the execution tracer shows that no profile can, and what it actually costs on a workload that blocks on something real
  • How to have been recording the trace you needed, and what that costs
What we’re not covering
  • Making a bug reproducible — Chapter 16, and specifically §16.7, which owns -count, -cpu, -shuffle and the statistics of repetition. This chapter begins after capture
  • goleak in a test suite — §16.5. Diagnosing a leak in production is here
  • The race detector as a subject — §8.3 and §16.3
  • Taking a dump, stack-trace anatomy, and why the deadlock detector stays quiet — §10.4, cited throughout §19.2
  • What to change once you have read a profile — Chapter 20. This chapter owns the instruments; Chapter 20 owns the decisions
  • Distributed tracing across service boundaries, and APM products
Building toward

Chapter 16 made a failure reproducible inside a test suite and stopped there, deliberately, handing this chapter the failures that never became reproducible. Chapter 20 takes the profiles this chapter teaches you to read and decides what to change. Chapter 18's bug catalogue is the other end of §19.1's triage table: it names the bugs whose symptoms you are matching.

Prerequisites

The goroutine dump and the state vocabulary from §2.4, whose table this chapter corrects. Data races and the detector’s blind spot from §8.3. The mutex profile as introduced in §9.4. Chapter 10 throughout, and §10.4 in particular — this chapter cites its stack-trace anatomy rather than repeating it, and assumes you have read the part about why the runtime detector almost never fires in production. Goroutine leaks from §2.4 and §16.5. And Chapter 17's four numbers (§17.7.5), which are the always-on end of the same argument §19.4 makes.

Which Go are we on?

Every listing and figure was run on Go 1.25 or later, with Delve 1.26.0 (Delve tracks Go releases: a binary built by Go 1.27 needs Delve 1.27.x or later — v1.27.2 at the time of this revision — and the §19.2.7 session reads the same on it). Three things matter. Go 1.22 rewrote the execution tracer, which is why its overhead is now low enough to argue about leaving on. Go 1.25 added runtime/trace.FlightRecorder, which is the whole of §19.7 and the closest thing this book has to an answer for “it already happened”. And runtime/metrics has grown a per-state goroutine breakdown — /sched/goroutines/runnable and its siblings — which is §19.4's best material. Go 1.27 changes two things this chapter measures. Modules declaring go 1.27 or later print runtime/pprof labels in the goroutine header of every runtime traceback — panics, SIGQUIT, and the debug=2 dump — which retires the trap §19.2.3 measured on go1.26.1; and the goroutineleak profile, experimental in 1.26, is on by default, which §19.3.6 now uses. Both are noted where they land. The metric set is versioned with the runtime and grows between releases, and the package does not carry its own history, so treat metrics.All() on your own toolchain as the authority rather than any list printed here. Where a name or a default matters, check it against your own toolchain rather than against this page; that is the discipline §19.2 applies to a table Chapter 2 shipped four years of Go releases ago.

Measured go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16, Delve 1.26.0. Figures come from running the programs as printed, and each one is reported with the parameters that produced it in the same block rather than in the surrounding prose.

Two cautions specific to this chapter, because it is more measurement-dense than any before it. Ratios here are workload-dependent, and the text says which end of the range it is quoting. Tracer overhead measured 2.3× on a channel ping-pong chosen to maximise scheduler events and 1.10× on a workload that blocks on a socket; both are in §19.5, and only one of them describes a real service. And profile record counts are not event counts and are not stable across runs — the same program produced 2 and 6 records on one run and 4 and 2 on the next. Where a count appears below it is reported as zero versus non-zero, which is the part that reproduces.

19.1 Four Views of a Running Program

19.1.1 What You Already Have

You are not starting from nothing. Six chapters have handed you pieces of a debugging toolkit, and none of them has said how to choose between the pieces.

The toolkit you already own
Goroutine dumps, SIGQUIT, stack-trace anatomy
§2.4, §10.4
The race detector, and what it cannot see
§8.3, §16.3
The mutex profile, introduced
§9.4
goleak and leak detection in tests
§2.4, §16.5
Reproduction, stress, -count and -cpu
§16.7
Four numbers worth exporting always
§17.7.5

What is missing is the part that decides which one to reach for, and reaching for the wrong one is how an engineer spends a day finding a symptom.

19.1.2 The Four Views

Every tool in this chapter produces one of four kinds of information about a running program. They answer different questions and they cost different amounts to obtain.

THE FOUR VIEWS, AND WHAT EACH COSTS

Every tool in this chapter produces one of four views. A snapshot answers where everyone is now and costs a freeze you ask for. An aggregate answers where time accumulates and costs sampling overhead. A timeline answers what happened in order, is heavy, and is the only view that keeps order. A single execution answers what one goroutine is doing right now, at maximum perturbation.

A snapshot is a goroutine dump. It tells you that two hundred goroutines are blocked in semaphore.Acquire — which is often the entire answer, and never tells you which one arrived first.

An aggregate is a profile. It tells you that 60% of blocked time accumulates on one mutex. It cannot tell you the order of any two events, because it has thrown the order away in exchange for being cheap enough to leave on.

A timeline is an execution trace. It is the only view that preserves ordering, which is the only view that can answer questions about interleavings, which is what a concurrent bug is. It is also the most expensive, and §19.5 measures how much.

A single execution is a debugger. It answers questions about one goroutine in exhaustive detail, and it obtains that detail by stopping the program.

There is a fifth thing that fits none of these cleanly, and §19.4 is about it: a continuous counterruntime/metrics — which is neither sampled nor snapshotted but incremented as the program runs, and is therefore free to read and always correct.

The sections do not divide evenly between the four, deliberately.

Snapshot gets one section, aggregate two, timeline three, and single execution a subsection. The weighting tracks how much each view repays in practice — and the view that gets least room is the one that perturbs most, which is the chapter’s thesis stated as a table of contents.

19.1.3 The Perturbation Ladder

Every tool trades fidelity against perturbation, and they order cleanly:

THE PERTURBATION LADDER

Diagnostic tools ordered by how much they disturb the program. runtime/metrics perturbs nothing and costs a counter read. A goroutine dump varies, from about 0.2 ms to about a second. A CPU profile is low, at roughly a hundred samples a second. The block and mutex profiles are moderate, sampling on each event. The execution tracer is high, between 1.1x and 2.3x. A debugger breakpoint is total: it stops the schedule, which is the thing under investigation.

19.1.4 Measured: What Each View Costs to Take

That ladder is usually asserted. Here it is measured, against a process holding a varying number of blocked goroutines:

Terminal
cost of ONE observation, go1.26.1, GOMAXPROCS=16
metrics.Read = one /sched/goroutines sample, mean of 100
  goroutines metrics.Read profile debug=1 dump debug=2
       100 ~120 ns 168 us 169 us
    10,000 ~290 ns 15.1 ms 38.6 ms
   100,000 ~200 ns 112.9 ms 913.3 ms
  goroutineleak (Go 1.27, not in this run): one GC
  reachability pass, on request; zero overhead otherwise.
  metrics.Read: steady-state of three trials of 200 reads.
  The FIRST trial at every size runs 2-4x slower (warm-up);
  taking one reading per size manufactures a clean upward
  trend that does not survive repetition.

Read the last cell first. At a hundred thousand goroutines — an ordinary number for a service holding connections — one full goroutine dump stops the process for nearly a second. Every in-flight request pauses. Every health check ticks toward its timeout.

Derived the dump cost is close to linear in goroutine count, so you can predict your own without running the experiment: from 913 ms at 100,000, roughly 9 µs per goroutine — equivalently 9 ms per thousand. A process holding 30,000 pays about 270 ms; one holding 5,000 pays about 45 ms. Scale by your hardware’s ratio if you want a better number — the shape is what decides the policy, and the shape is that the cost belongs to how many goroutines you hold rather than to anything about the tool.

Two more things in that table. The debug=1 census pulls ahead of the full dump as the count grows — 112.9 ms against 913.3 ms at 100,000 — because it aggregates identical stacks instead of printing each one, which is a second reason to reach for it first. And metrics.Read is sub-microsecond and effectively independent of goroutine count — the residual spread across rows is measurement noise rather than a trend, which is why the block above prints steady-state values and says so. That distinction is not pedantry: a single reading at each size produces a tidy monotonic rise that looks exactly like a scaling law and is entirely warm-up. That gap — six orders of magnitude in the bottom row — is the entire argument for §19.4.

On a large process, an unauthenticated dump endpoint is a denial-of-service primitive.

net/http/pprof registers /debug/pprof/goroutine on the default mux, and ?debug=2 on a big process is a near-second stop-the-world pause that anyone who can reach the port may trigger as often as they like. §19.2.6 covers binding it privately. And if your platform’s liveness probe has a one-second timeout, a routine diagnostic dump is indistinguishable from a crash — §15.3.3's argument about failing readiness, arriving with the roles reversed.

19.1.5 The Decision Procedure

From which the chapter’s decision procedure follows:

Use the least invasive tool that can answer your question.

That is not a counsel of caution. It is a correctness rule, because the tools at the bottom of the ladder change the thing they measure.

19.1.6 The Observer Effect Is Not a Metaphor

In a sequential program, a breakpoint is free: the program has one thread of control, you stop it, you look, you continue, and nothing about the answer changes.

In a concurrent program, stopping is an intervention. The bug you are chasing is an ordering, and a breakpoint is a device for changing orderings.

This has three practical consequences, in increasing order of how often they bite.

A debugger cannot show you a race. When you stop at a breakpoint, the goroutine you stopped is not racing with anything, because it is not running. Stepping through a data race is close to hopeless: the interleaving that produced the bug requires two goroutines to be inside the same window simultaneously, and the debugger’s entire function is to prevent that.

Instrumentation changes timing. A log line is a lock, a syscall, a timestamp, and an allocation. Adding one to a racy path narrows or widens the window. “I added a print statement and it went away” is not a mystery — it is a measurement, and what it measured is that your bug is timing-dependent.

Even the race detector distorts. §16.3 covers what it catches; what matters here is that a -race binary runs several times slower with different timing, so a bug that depends on a narrow window may not occur under the detector at all.

This is why Delve gets a subsection rather than a section.

It is the most powerful tool in the chapter and the one you should reach for last, and both of those are true for the same reason. §19.2.7 covers its narrow legitimate uses — including the one case where it perturbs nothing at all, because the process is already dead.

19.1.7 Triage: Stuck, Slow, or Rare

Three symptoms, three starting points.

Stuck — requests hang, the process is idle, nothing progresses. Start with a snapshot. A goroutine dump names what everyone is waiting for, and for a hang that is usually the whole answer. §19.2.

Slow — everything works and takes too long. Start with aggregates: the always-on counters first to establish whether there is contention at all (§19.4), then the profiles to find where (§19.3). Reach for the timeline only when the aggregates say the time is going somewhere they cannot see.

Rare — it happened once, at 03:14, and everything is fine now. This is the hard one, and the honest answer is that nothing you can run now will help. The only useful question is what should have been recording. §19.4 for the counters that always are, and §19.7 for the trace that can be.

Symptom to first tool:
Symptom
Requests hang; CPU near zero
Goroutine count climbing
Throughput fell, CPU is fine
Latency p99 is bad, p50 is fine
A handler is slow and you cannot see why
It happened once and it is over

19.1.8 Common Mistakes

Reaching for a debugger first
Problem

Hours spent, the bug never occurs under it

Fix

Start at the top of the ladder

Stepping through a suspected race
Problem

The race never reproduces

Fix

A breakpoint prevents the interleaving

Adding a log line to “see what happens”
Problem

The bug goes away and stays away

Fix

You moved the window; it is still there

Trying to diagnose “rare” with a live tool
Problem

Nothing to see; the incident is over

Fix

Ask what should have been recording

Reading a profile to answer an ordering question
Problem

Plausible-looking, unrelated answer

Fix

Aggregates discard order by construction

Treating a dump as proof of a deadlock
Problem

A hang that is really slow progress

Fix

A dump is one instant; take two

Summary: Four Views of a Running Program

A sequential bug lives in a state and a concurrent bug lives in an interleaving, which is why the tools split into ones that report where everyone ended up and ones that report how they got there.

There are four views. A snapshot says where everyone is now; an aggregate says where time accumulates and has discarded ordering to be cheap; a timeline preserves ordering and is the only view that can answer a question about an interleaving; a single execution answers in exhaustive detail about one goroutine by stopping the program. A fifth thing, the continuous counter, is free to read and always correct.

They form a perturbation ladder, from a counter read that costs nothing to a breakpoint that destroys the schedule you were studying, and the rule is to use the least invasive tool that can answer the question. That ladder is measured rather than asserted: a full dump costs 169 µs at a hundred goroutines and 913 ms at a hundred thousand, while metrics.Read stays sub-microsecond throughout. That is a correctness rule rather than a frugal one: a debugger cannot show you a race, because its function is to prevent the simultaneity the race requires.

Triage is stuck, slow, or rare. Stuck starts at a snapshot, slow starts at the always-on counters, and rare cannot be answered by anything you run afterwards — only by what was already recording.

Self-Check Questions: Four Views of a Running Program

A colleague reports that adding a log.Printf to a handler makes an intermittent bug disappear, and proposes shipping the log line as the fix. What has actually been learned, and what would you say?

You have learned something real and specific: the bug is timing-dependent, and its window is narrow enough to be closed by roughly the cost of one log call.

That is a diagnosis, not a fix. log.Printf takes a mutex, formats, writes, and usually makes a syscall. Inserted into a racy path it changes when each goroutine arrives at the contended region — and if the race needed two goroutines inside a window of a few hundred nanoseconds, adding a few microseconds to one of them makes the collision much less likely.

Much less likely is not never. The interleaving is still legal, and the bug will return under a different load, on a different machine, or when the logger’s output is redirected somewhere faster. It will return having been marked fixed, which is worse than where you started.

What is worth doing with the finding is using it to locate the window. The log line is a probe: move it earlier and later, and see where it stops mattering. That brackets the region where the two goroutines interact, which is exactly what you need to reason about the ordering — and then fix it with synchronization rather than with delay.

Why can a profile never answer the question “did A happen before B?”

Because a profile is an aggregate, and aggregation is precisely the operation that discards ordering.

A profile works by sampling: periodically, or on some fraction of events, it records a stack and adds it to a running tally. What comes out is “this stack accounts for 43% of blocked time” — a sum over the whole run. Two events at the same stack are indistinguishable in the result, and two events at different stacks carry nothing about which came first.

That is not a shortcoming to be fixed; it is what makes profiles cheap enough to run in production. Keeping ordering means keeping every event with a timestamp, which is what an execution trace does and why it costs enough that §19.5 has to argue for it.

The consequence for debugging is the chapter’s organising point. Questions of the form “where does time go” are aggregate questions and a profile answers them well. Questions of the form “what happened in what order”, which is every question about an interleaving, need the timeline — and if you were not recording one, the answer is gone.

Your service hangs once a week for thirty seconds and recovers on its own. You have a goroutine dump captured during the last occurrence. What can it tell you, and what can it not?

It can tell you what every goroutine was waiting for at one instant, which for a hang is often the whole answer: two hundred stacks converging on one Acquire names the resource immediately.

It cannot tell you three things, and they are the ones that decide whether you have found the cause.

It cannot distinguish a deadlock from slow progress. One dump is a single frame. Goroutines blocked on a mutex look identical whether the holder is stuck forever or merely slow, and the fix differs completely. Take two dumps thirty seconds apart: if the same goroutine IDs are in the same states, it is stuck; if the IDs have turned over, it is slow.

It cannot tell you the order in which they arrived, so it cannot tell you which goroutine caused the pile-up. Everyone waiting on a lock looks the same; the one that matters is whoever holds it, and the dump shows that one goroutine running, indistinguishable from healthy.

And it cannot tell you what happened in the thirty seconds before it was captured, which is where the cause is. That is §19.7's subject and the reason the chapter ends there.

Key Takeaways

  • A sequential bug lives in a state; a concurrent bug lives in an interleaving, and an interleaving leaves no residue
  • Four views: snapshot, aggregate, timeline, single execution — plus continuous counters, which are free
  • Aggregates discard ordering by construction, which is what makes them cheap enough to leave on
  • The perturbation ladder runs from a free counter read to a breakpoint that destroys the schedule
  • Measured: a full debug=2 dump costs 169 µs at 100 goroutines and 913 ms at 100,000 — roughly 9 µs per goroutine, or 9 ms per thousand
  • metrics.Read is sub-microsecond and independent of goroutine count: six orders of magnitude cheaper at scale
  • Use the least invasive tool that can answer the question — a correctness rule, not a frugal one
  • A debugger cannot show you a race, because preventing simultaneity is what it does
  • “I added a log line and it went away” is a measurement, not a fix
  • Stuck starts at a dump, slow starts at counters, and rare can only be answered by what was already recording
Section 19.1 — in one line

A concurrent bug lives in an interleaving, not in a state — so the tool that would show you how it happened is the one you were not running.

19.2 The Dump, Re-read

The goroutine dump is the snapshot view, and Chapter 10 already taught you to take one. §10.4 covers SIGQUIT, the pprof endpoint, the anatomy of a stack frame, and — importantly — why the runtime’s own deadlock detector almost never fires in production. None of that is repeated here.

What this section owns is the reading: what the dump actually says on a modern toolchain, what decides how much of it you get, and why the labels you carefully added are missing from the one you receive in an incident on any module below go 1.27.

19.2.1 A Debt From Chapter 2

Chapter 2's §2.4 ships a goroutine-state table for exactly this purpose. One of its rows is now wrong:

State
semacquire
Measured a live pprof.Lookup("goroutine") dump on go1.26.1, with one goroutine blocked on a held mutex and one on a channel, reports these wait reasons:
Terminal
[sync.Mutex.Lock] [chan receive] [chan send] [running]

semacquire does not appear. The runtime now names the primitive rather than the generic semaphore acquire, and runtime2.go's waitReasonStrings carries dozens of distinct entries where the old vocabulary had one. That is a large part of why reading dumps got easier: a goroutine that used to be “waiting on something” now says sync.RWMutex.RLock or sync.WaitGroup.Wait, and you can tell which synchronisation object it is queued behind without reading a single frame.

Scope this claim precisely, because it is easy to overstate.

The change is to the wait reason the runtime prints in brackets. The symbol has not vanished from the toolchain: measured, the stack frames in the same dump still contain internal/sync.runtime_SemacquireMutex, because that is genuinely the function the goroutine is parked in. If you grep a dump for Semacquire you will find it. The bracket is the part that changed, and the bracket is the part you read first.

19.2.2 debug=1 and debug=2 Are Different Tools

Both Chapter 2 and Chapter 12 print goroutine profiles, at different debug levels, and neither explains the difference. It matters more than the name suggests.

snippet_192.go
// Illustrative snippet — not a complete program
pprof.Lookup("goroutine").WriteTo(w, 1) // aggregated census
pprof.Lookup("goroutine").WriteTo(w, 2) // every stack, in full

debug=1 is a census. Identical stacks are collapsed and counted, so the output is short and the shape is immediately visible:

Terminal
goroutine profile: total 1823
1820 @ 0x43e5c5 0x40b1ce 0x6f2a11
# 0x6f2a10 main.handleSearch+0x110 /app/search.go:52

One line tells you that 1,820 goroutines are stuck at the same place, which for a leak is the entire diagnosis.

debug=2 is every stack, unaggregated, in the same format the runtime prints on a panic. It is what you need when the goroutines are not identical — when you have to find the one holding the lock among two hundred waiting for it.

The rule of thumb: debug=1 to see the shape, debug=2 to find the individual. Reach for the census first; it is smaller, faster to read, and answers most questions about a hang.

19.2.3 The Labels You Added Were Not in the Dump You Got — Until Go 1.27

runtime/pprof lets you attach key-value labels to goroutines, and they are the best available answer to “which of these ten thousand goroutines is which”:

pprof_192.go
// Illustrative snippet — not a complete program
pprof.Do(ctx, pprof.Labels("job", "resize", "tenant", tenantID),
    func(ctx context.Context) {
        // everything this spawns inherits the labels
        process(ctx, item)
    })

Labels propagate to goroutines started inside Do, which means one call at the top of a request wraps everything the request creates. Measured: runtime/metrics, pprof.Labels, SetGoroutineLabels and pprof.Do appear in zero of the preceding chapters — this is unspent territory, and it is the single highest-leverage thing in this section.

Now the trap.

Measured the same labelled goroutine, dumped at both debug levels:
Terminal
debug=1 contains "job":true "resize":true
debug=2 contains "job":false "resize":false

Labels appear at debug=1 and are absent from debug=2. The census carries them as a # labels: {"job":"resize"} line above each group; the full-stack dump does not carry them at all.

That inverts the natural assumption — debug=2 is the more verbose format, so it should contain a superset — and it has a sharp practical consequence. SIGQUIT prints the debug=2 format. So does an unrecovered panic. The dump you get automatically in an incident is the one without your labels in it.

Which means that below go 1.27, labels are for the profile you fetch, not the dump you are handed.

Below go 1.27, if you want labelled output during an incident you have to fetch /debug/pprof/goroutine?debug=1 yourself while the process is alive — which is an argument for the pprof endpoint over SIGQUIT that §10.4 makes on other grounds and this reinforces. Label anyway: the census is where you diagnose a leak, and §19.3.6's profile diff is unreadable without them.

That was true on go1.26.1, and it stays true for any module whose go.mod declares a language version below 1.27. Go 1.27 closes the gap: for modules declaring go 1.27 or later the runtime prints labels in the goroutine header of every traceback it produces — panics, SIGQUIT, GOTRACEBACK=all, and the debug=2 dump, which is the same printer — after the state bracket, in the form

Terminal
goroutine 8 [running] {job: resize, tenant: acme}:

(keys sorted, values quoted when they need to be; the runtime documents the format as subject to change, so grep for the goroutine number or the wait reason rather than the braces). The switch is the tracebacklabels GODEBUG, added in 1.26 as an opt-in and defaulted to 1 in 1.27, and the opt-out is one the Go team say they will keep indefinitely, for the reason §19.2.6 makes about the pprof endpoint: a label is whatever you put in it, and a tenant identifier that was harmless in a profile you fetch over a private port is now written to stderr by every panic. Label anyway — but decide, per service, whether GODEBUG=tracebacklabels=0 belongs in the deployment.

One ordering detail decides whether the panic case gets them. A traceback prints the labels the goroutine holds after its deferred functions have run, and pprof.Do defers a SetGoroutineLabels back to the parent's set — so a panic that unwinds through pprof.Do's own frame prints goroutine 1 [running]: with nothing after the bracket, while a goroutine started inside pprof.Do (it inherits the labels and has no such defer) prints them, and so does a SIGQUIT dump taken while pprof.Do is still on the stack. If the panic path matters to you, set the labels with SetGoroutineLabels on the goroutine that can panic, or accept that the created by line and the spawned goroutines carry the context.

One more field can appear in the same header. Once anything has taken the goroutineleak profile (§19.3.6), the goroutines it proved dead also carry (leaked) on their wait reason in this dump and every later one — so pull that profile first and the dump you are handed is annotated; and read its absence correctly, because no (leaked) means nobody asked.

19.2.4 GOTRACEBACK Decides How Much You Get

There is one environment variable that controls how much of the program appears in a crash dump, it has five settings, and it is barely mentioned in the preceding seventeen chapters — measured, Chapter 2 mentions it zero times and Chapters 10 and 15 have one clause each.

Measured the same program — a goroutine blocked on a held mutex, then a panic — under each level:
Terminal
GOTRACEBACK=none 0 goroutine stacks
GOTRACEBACK=single 1 goroutine stack <- the default
GOTRACEBACK=all 2 goroutine stacks
GOTRACEBACK=system 6 goroutine stacks

Six times the stacks between the default and the most verbose level, decided by one variable, and the default is single.

(The byte counts vary with the length of your source paths, since every frame prints a filename — two machines running the identical program reported 177 and 193 bytes for single. The stack counts are the durable figure and are what the levels actually control.)

That default is the finding. When a Go service panics, you are shown the stack of the panicking goroutine and nothing else. For a sequential bug that is exactly right and admirably concise. For a concurrent bug it is close to useless, because the panicking goroutine is frequently the victim — it found the corrupted state, or it timed out — and the goroutine that caused the problem is not printed at all.

crash is the fifth level: it behaves like system and then raises SIGABRT to produce a core dump, which §19.2.7 uses.

Set GOTRACEBACK=all in production and know what it costs.

The cost is dump size and the seconds spent writing it, which on a process with 100,000 goroutines is not nothing. The benefit is that a panic shows you the other participants. For most services this is a clear trade and the default is a poor fit, which is a reasonable thing for a default to be — it was chosen for programs with few goroutines.

19.2.5 Reading a Dump That Is Mostly Waiting

READING A DUMP OF 2,000 GOROUTINES

A census of two thousand goroutines: 1,940 in chan receive, 52 in sync.Mutex.Lock, 7 in IO wait, and 1 running. The first three are the crowd and they are symptoms rather than causes. Read the biggest wait duration first, then the running goroutine: the crowd tells you what is blocked, and the one that is running usually tells you why.

Two techniques do most of the work on a real dump, and neither is obvious.

The duration annotation tells you what is stuck versus what is slow. A goroutine blocked longer than a minute has its wait time printed:

Terminal
goroutine 42 [chan send, 18 minutes]:

The runtime only reports this over a threshold, so its absence means “recently” and its presence means “long enough that you should ask why”. In a dump of a hung service, sorting by that number separates the original victim from everyone who piled up behind it — the largest number is usually closest to the cause.

Two dumps beat one. A single dump cannot distinguish a deadlock from slow progress, because both look like goroutines blocked on the same thing. Take two, thirty seconds apart, and compare goroutine IDs. Same IDs in the same states means nothing moved. Different IDs at the same stack means work is flowing and the queue is just deep — a completely different problem with a completely different fix.

There is a sibling to that, and it is the one place this chapter can extend §10.4 rather than cite it. §10.4 is right that a goroutine still doing something suppresses the runtime’s deadlock detector, and chapter_10.md:158 lists the cases: “a time.Sleep loop, a ticker, an idle HTTP server”. Its word is loose, though — it attributes the suppression to a goroutine being “still runnable”, and a goroutine parked in time.Sleep(time.Hour) is not runnable. It is asleep on a timer.

measured, isolating one variable at a time:

Terminal
all goroutines blocked, nothing else
    -> fatal error: all goroutines are asleep - deadlock!
+ one goroutine in time.Sleep(time.Hour)
    -> no error; still running after 4s
+ one goroutine spinning on the CPU
    -> no error; still running after 4s

The sleeper suppresses detection while being no more runnable than the deadlocked goroutines are. The mechanism is that checkdead looks for a pending timer and, finding one, wakes a thread to wait for it rather than declaring deadlock — because a timer is future progress that requires no other goroutine. So the rule is not “something is runnable” but “the runtime can see a reason to expect progress”, and a single time.Ticker anywhere in your process is such a reason. That is why §10.4 is right that production almost never reports a deadlock, and it is a slightly different reason than the one given.

And the one thing a dump systematically hides: a goroutine that is running does not look interesting. It appears as [running], one line, usually near the top, indistinguishable from healthy. When two hundred goroutines are blocked on a mutex, the one you need is the single [running] goroutine that holds it — and the dump gives it no more prominence than any other.

19.2.6 Getting One Out of Production At All

Everything above assumes you have a dump. On a development machine that is trivial. On a running service it is a decision with consequences, and there are three ways to do it that differ in ways worth knowing before the incident.

SIGQUIT prints a dump and then kills the process. §15.7.5 recommends it for a shutdown that has hung, and §10.4 says the same for a deadlock, and both are right — because in those situations the process is already useless. Chapter 15 measured the consequence: exit status 2, not the 131 you would expect from 128 + 3, because the runtime intercepts the signal. What matters here is the part that is easy to forget under pressure: this is a one-shot, destructive capture. You get one dump, in debug=2 format, without your labels on a pre-1.27 module (§19.2.3), and then the service is gone. If it was serving traffic, it is not any more.

The pprof endpoint is the non-destructive path, and it is the one to have wired up in advance:

snippet_192_2.go
// Illustrative snippet — not a complete program
import _ "net/http/pprof" // registers on DefaultServeMux

go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()

That gives you /debug/pprof/goroutine?debug=1 — the census, with labels — plus every other profile, on a live process, as many times as you like. Taking two dumps thirty seconds apart to distinguish a deadlock from a queue (§19.2.5) is only possible this way.

Exposing it is a security decision, and the default is wrong. The blank import registers handlers on http.DefaultServeMux, so if your service also serves user traffic on that mux, /debug/pprof/ is on the public internet. It exposes stack traces, function names, and — via the CPU profile and the heap — a great deal about what your process is doing. The endpoints are also a denial-of-service surface: /debug/pprof/profile blocks for thirty seconds by default and /debug/pprof/trace can produce an enormous response.

Bind the debug endpoint separately, and never to a public interface.

A second http.Server on localhost:6060, or on a port reachable only from inside the cluster, on its own ServeMux. If your service uses http.DefaultServeMux for real traffic, the blank import has already published your diagnostics — that is not a hypothetical, it is the documented behaviour of the import. The same import registers /debug/pprof/goroutineleak on Go 1.27 (§19.3.6), so the surface grew by one endpoint that forces a garbage-collection cycle when fetched. From Go 1.27 the tools apply this rule themselves: go tool trace -http=:6060 listens on localhost only, matching go tool pprof, so a reader reaching the tool on a remote box has to pass an explicit address.

The third option is worth knowing for constrained environments: a SIGUSR1 handler that writes a dump and keeps running. It is fifteen lines, it needs no open port, and it gives you the format and debug level you choose:

c_192.go
// Illustrative snippet — not a complete program
c := make(chan os.Signal, 1)          // buffered -- Chapter 5
signal.Notify(c, syscall.SIGUSR1)
go func() {
    for range c {
        name := fmt.Sprintf("/tmp/dump-%d.txt", time.Now().Unix())
        f, err := os.Create(name)
        if err != nil {
            continue
        }
        pprof.Lookup("goroutine").WriteTo(f, 1) // debug=1: with labels
        f.Close()
    }
}()

That is SIGQUIT's usefulness without its lethality, and it is the pattern to reach for when a debug port is not available.

19.2.7 Delve, and the One Time It Perturbs Nothing

Delve is the single-execution view, and §19.1.6 already made the argument that it is the wrong default. This subsection is about its narrow legitimate uses.

Attaching to a hung process is the first. When a service is wedged and a dump has told you where but not what with, Delve can read values a dump cannot:

Terminal
dlv attach 12345
(dlv) goroutines # every goroutine, like debug=1
(dlv) goroutine 42 bt # one goroutine's stack
(dlv) goroutine 42 frame 3 locals # its variables: what a dump lacks

That is the real gap it fills. A dump shows you that handleSearch is blocked on line 52; Delve shows you what tenantID was. For a hang, stopping the process costs nothing, because nothing is progressing anyway — the objection in §19.1.4 does not apply to a program that has already stopped.

The core dump is the second, and it perturbs nothing at all. Set GOTRACEBACK=crash and the process writes a core file on panic, which you can inspect afterwards at leisure:

Terminal
$ GOTRACEBACK=crash ./myservice # core dumped on panic
$ dlv core ./myservice /cores/core.12345

This is the one use of a debugger in this chapter that is free of the observer effect, for a simple reason: the world has already stopped. You are not changing an interleaving; you are reading the remains of one. It is also the only technique in the chapter that gets you a debugger’s fidelity on a production incident you were not watching — which makes it a quieter cousin of §19.7.

What Delve is still bad at is the case people reach for it first: a data race. Stepping through requires two goroutines inside a window simultaneously, and a breakpoint exists to prevent exactly that.

19.2.8 Common Mistakes

Expecting semacquire in a modern dump
Problem

Grep finds nothing; you assume no lock waits

Fix

The bracket names the primitive now

Assuming debug=2 is a superset of debug=1
Problem

Labels missing from the dump you received

Fix

Below go 1.27 they appear only at debug=1; check the module's go line

Labelling goroutines and reading SIGQUIT output
Problem

Careful labels never appear

Fix

Fetch ?debug=1, or move the module to go 1.27, where tracebacks carry them

Shipping labels to stderr without deciding to
Problem

Tenant ids in every panic on go 1.27

Fix

GODEBUG=tracebacklabels=0 is the documented, permanent opt-out

Leaving GOTRACEBACK at its default
Problem

A panic shows one goroutine; the culprit is absent

Fix

all in production, knowing the size cost

Diagnosing a hang from one dump
Problem

“Deadlock” that was really a deep queue

Fix

Two dumps; compare goroutine IDs

Scanning the blocked goroutines for the cause
Problem

Two hundred identical stacks, no culprit

Fix

The culprit is the [running] one

Reaching for Delve on a race
Problem

Never reproduces under the debugger

Fix

Delve is for hangs and cores, not races

SIGQUIT on a service you need alive
Problem

One dump, then the process is gone

Fix

The endpoint, or a SIGUSR1 handler

net/http/pprof on the public mux
Problem

Diagnostics served to the internet

Fix

A separate server on a private interface

Summary: The Dump, Re-read

Chapter 10 taught you to take a dump; this section is about reading one. Chapter 2's state table is now wrong in one row — the runtime names the primitive, sync.Mutex.Lock rather than semacquire, though the symbol survives as a stack frame and the claim is about the bracket.

debug=1 and debug=2 are different tools: a census that collapses identical stacks and shows the shape, and a full dump that finds the individual. Labels attach to goroutines and propagate to everything they spawn, and — measured — appear only at debug=1. Since SIGQUIT and panics print debug=2, the dump you are handed in an incident is the one without your labels — on modules below go 1.27; from 1.27 the traceback header carries them, behind an opt-out.

GOTRACEBACK decides how much you get, across a sixfold range of goroutine stacks, and defaults to single — one goroutine, which for a concurrent bug is usually the victim rather than the cause.

Two dumps beat one, because a single frame cannot distinguish a deadlock from a deep queue. And the goroutine you need is often the one that looks healthiest: [running], one line, holding the lock everyone else is waiting for.

Getting a dump out of production is its own decision: SIGQUIT is one-shot and destructive, the pprof endpoint is repeatable and is what two dumps thirty seconds apart require, and the blank import publishes it on whatever mux your service already uses. A SIGUSR1 handler is the fifteen-line middle path.

Delve earns its place for hangs, where stopping a stopped program costs nothing, and for dlv core on a GOTRACEBACK=crash dump — the one use in this chapter with no observer effect at all, because the world has already stopped.

Self-Check Questions: The Dump, Re-read

You add pprof.Do labels throughout a service, deploy, and the next incident produces a SIGQUIT dump with no labels anywhere. What happened, and what should you do differently?

SIGQUIT prints the debug=2 format, and labels only appear at debug=1.

That is genuinely counter-intuitive, because debug=2 is the more verbose format in every other respect — every stack in full rather than a collapsed census — so the reasonable expectation is that it contains everything debug=1 does and more. Measured, it does not: the census carries a # labels: line per group and the full dump carries none.

What to do differently has two parts. Operationally, fetch /debug/pprof/goroutine?debug=1 from the running process rather than relying on the signal — which needs the process alive, and is one more reason §10.4 prefers the endpoint to SIGQUIT, since SIGQUIT also kills what you are debugging.

Strategically, keep the labels. They are not wasted: they are what makes the census readable, and §19.3.6's profile diff — the standard way to find a leak in production — is nearly unusable without them, because an unlabelled diff tells you a stack grew and a labelled one tells you which tenant did it.

The general lesson is that “verbose” and “superset” are different properties, and it is worth checking which one a tool actually offers before depending on it during an incident.

One more thing to check before changing anything: the module's go line. On go 1.27 or later the runtime prints labels in every traceback header unless GODEBUG=tracebacklabels=0 is set, so a labelless SIGQUIT dump from a 1.27 module means the opt-out is in the deployment — which is a different finding.

Your service panics in production. The stack shows a nil map write in a metrics handler that has not changed in a year. Why is the default GOTRACEBACK likely to be hiding the cause, and what would you change?

Because the default is single, so you were shown one goroutine — and for a concurrent bug the goroutine that panics is frequently the victim rather than the cause.

A nil map write in unchanged code is almost always a synchronization symptom: some other goroutine replaced or cleared the map, or the map was published before it was finished being built, and this handler is simply the one that arrived afterwards. The stack you were given describes who found the damage. Whoever did it is a different goroutine, and it was not printed.

Measured, that difference is large: the same program printed one goroutine stack under single, two under all, and six under system. (Byte counts vary with source-path length and are not the durable figure; the stack counts reproduced identically on two machines.) The goroutine you need is in the ones you did not get.

I would set GOTRACEBACK=all in production, knowing the cost is dump size and the time to write it — which on a process with a very large number of goroutines is real, and is why the default is what it is.

I would also treat this as a -race finding waiting to happen (§8.3, §16.3) rather than a panic to be guarded with a nil check. Adding if m == nil makes the panic stop and leaves the data race in place, which converts a loud failure into a silent one — the trade this book has argued against since §14.1.

When is a debugger free of the observer effect, and why does that case exist?

When the program has already stopped — which in practice means a core dump, and to a lesser extent a genuinely hung process.

The observer effect in §19.1.6 is specific: a breakpoint changes when goroutines run relative to each other, so it can destroy the interleaving you are studying. That mechanism needs the program to still be running. If it is not, there is no schedule left to disturb.

A core dump is the clean case. Set GOTRACEBACK=crash, the process aborts and writes a core on panic, and dlv core gives you a debugger’s full fidelity — every goroutine, every frame, every local — over a program state that is frozen and cannot be perturbed because nothing in it will ever execute again. You get the debugger’s power with none of its cost.

A hung process is the weaker version. Attaching to it does stop goroutines, but if nothing is progressing then stopping progress costs nothing, and reading the locals of a goroutine blocked on line 52 tells you what a dump cannot.

The case that remains hopeless is the live race, and for the same reason stated positively: the technique works exactly to the extent that there is no concurrency left to disturb.

Key Takeaways

  • Chapter 2's semacquire row is stale: the bracket names the primitive now — though the symbol survives as a stack frame, so scope the claim to the bracket
  • debug=1 is a census for seeing shape; debug=2 is every stack for finding an individual
  • Measured: labels appear at debug=1 and not at debug=2, and SIGQUIT prints debug=2 — so the incident dump lacks them below go 1.27
  • Measured: GOTRACEBACK spans zero to six goroutine stacks across its levels and defaults to single, one goroutine
  • For a concurrent bug the panicking goroutine is usually the victim; the cause is in the stacks you did not get
  • The duration annotation separates the original victim from the pile-up behind it
  • Two dumps distinguish a deadlock from a deep queue; one dump cannot
  • The goroutine you need often looks healthiest: [running], holding the lock
  • SIGQUIT is a one-shot destructive capture; the pprof endpoint is the repeatable one, and two dumps need it
  • The net/http/pprof blank import registers on DefaultServeMux — bind diagnostics to a private interface
  • dlv core on a GOTRACEBACK=crash dump is the one debugger use with no observer effect
Section 19.2 — in one line

A dump tells you where everyone ended up, in a format decided by a variable you did not set, and, below go 1.27, without the labels you added.

19.3 Profiles, and the Two That Are Empty

A profile is the aggregate view: where does time accumulate, summed over the whole run, with ordering discarded in exchange for being cheap. Go ships seven of them and two are concurrency-specific — and those two are the ones that lie to you first.

19.3.1 Two of Them Are Empty Until You Say Otherwise

Measured a program performing 32,000 contended lock operations across sixteen goroutines and 200 blocking channel sends, with default settings:
Terminal
DEFAULT block records = 0 mutex records = 0
ENABLED block records > 0 mutex records > 0
   (after SetBlockProfileRate(1) and SetMutexProfileFraction(1))

Zero. Not “few” — zero. The block and mutex profiles are disabled by default and report an empty profile rather than an error, which means the diagnostic path looks like this:

Terminal
$ go tool pprof http://svc:6060/debug/pprof/block
Type: delay
Showing nodes accounting for 0, 0% of 0 total

You fetched the right profile from the right endpoint with the right tool, got a well-formed answer, and the answer says there is no blocking. There is a great deal of blocking. Nothing anywhere reported an error, because nothing went wrong: you asked a profiler that was switched off what it had recorded, and it correctly told you “nothing”.

This is §14.1's thesis in its third costume. The failure mode is not a crash; it is a confident report of health.

snippet_193.go
// Illustrative snippet — not a complete program
// Enable at startup. Both default to off.
runtime.SetBlockProfileRate(1)      // every blocking event
runtime.SetMutexProfileFraction(1)  // every contention event
An empty block profile is not evidence of no blocking.

It is evidence of one of two things, and you cannot tell which from the profile itself: either nothing blocked, or nothing was recording. Check SetBlockProfileRate before you conclude anything from an empty result — and see §19.4 for the number that is always correct and needs nothing enabled.

THE SEVEN PROFILES, BY DEFAULT

Of the seven standard profiles, five need nothing: cpu is on when you ask for it and samples what is running, heap is always collecting, and goroutine, goroutineleak (Go 1.27, leaked goroutines only) and threadcreate are always available as snapshots. Below the line, block and mutex are OFF by default and return an empty profile rather than an error. Those two are exactly the ones a concurrency bug sends you to, and they report health when they are off.

The CPU, heap, goroutine, goroutineleak and threadcreate profiles are not like this. They work out of the box. It is specifically the two concurrency profiles that require opt-in, which is precisely the pair a reader of this book will reach for. The newest of the five, goroutineleak (Go 1.27, §19.3.6), needs no knob either, but its cost is not a snapshot's: it is a garbage-collector reachability pass, taken on request and costing nothing until then — a third cost model next to the two knob-gated ones — and taking it has a visible side effect: the goroutines it marks read (leaked) in every later goroutine dump.

19.3.2 A Record Is a Stack, Not an Event

The second way these profiles mislead is arithmetic.

Measured the 200 blocking channel sends above, with profiling enabled, produce a single-digit number of records. Not two hundred.
n_193.go
// Illustrative snippet — not a complete program
n := pprof.Lookup("mutex").Count() // records, not events

Count() returns the number of unique stacks in the profile, not the number of events sampled into it. Two hundred sends from the same line of code collapse into one record with a large accumulated value. So a service with catastrophic contention on one lock reports a very small number, and a reader who treats it as an event count concludes the contention is negligible.

The values are in the samples, not the count. go tool pprof shows them correctly by default because it displays the accumulated delay; it is only the record count that misleads, and the record count is what an in-process health check is most likely to read.

Report counts as zero-versus-non-zero and nothing more.

Even the record counts are unstable: measured, the same program produced 2 and 6 records on one run and 4 and 2 on the next, because which stacks get sampled depends on timing. “Is anything being recorded” reproduces. “How much” does not, at that granularity — use the accumulated delay for that.

19.3.3 One Blames the Waiter, the Other Blames the Holder

The block and mutex profiles are routinely described as overlapping, and the usual summary — that one covers channels and the other covers mutexes — is wrong. Both record mutex contention. They differ in something more useful.

Measured eight goroutines contending on a single sync.Mutex, holding it briefly, with no channels anywhere in the program:
Terminal
block profile -> leaf frame: sync.(*Mutex).Lock (mutex.go:46)
mutex profile -> leaf frame: sync.(*Mutex).Unlock (mutex.go:65)

Same contention. Opposite attribution.

The block profile samples the goroutine that waited, and charges the sample where it went to sleep — at Lock. It answers who suffered.

The mutex profile samples on the unlock that releases a waiter, and charges it to the holder — at Unlock. It answers who caused it.

That distinction is the reason to have both, and it decides which one to fetch:

Choosing between them:
Question
Which of my handlers is slow?
Which lock is the bottleneck?
Is this a channel or a mutex?
Who is holding it too long?
This is a claim about the attribution point, not about which symbols appear.

The profile charges its samples to a leaf, and that leaf is Lock in one and Unlock in the other. Stack depth and inlining decide what else shows up further down; if you grep your own mutex profile and find a Lock frame somewhere in a stack, that is not a contradiction. The leaf is the part that carries the number.

19.3.4 What Each One Cannot See

Every profile has a shape of blindness, and knowing it is most of what separates a useful reading from a plausible one.

The block profile is a sampler with a time bias. SetBlockProfileRate(n) aims to record one event per n nanoseconds spent blocked, so long waits are almost certain to be sampled and very short ones are almost certain to be missed. It is therefore honest about lock convoys and systematically quiet about high-frequency, short-duration handoffs — a channel passing a million small items may barely appear.

The mutex profile only sees contended unlocks. An uncontended Lock/Unlock pair costs a few nanoseconds and is never sampled, correctly. But it means the profile cannot tell you a lock is hot, only that it is contended. A mutex taken ten million times with no waiter is invisible, and that is exactly the lock you would want to make lock-free.

The goroutine profile is a snapshot with the ordering discarded twice over. It says what everyone is doing now, aggregated.

The goroutineleak profile (Go 1.27) only sees what the collector can prove. It reports a goroutine as leaked when the channel, mutex or condition variable it is parked on is unreachable from every runnable goroutine; a primitive still reachable through a global variable, or through a local of a goroutine that is merely slow, is not a leak to it however leaked it looks to you, and a goroutine parked on I/O or a syscall is not its business at all.

And no profile can see a goroutine that is running. The blocking profiles record waiting. A goroutine spinning in a busy loop, or holding a lock while doing slow work, contributes nothing to either — it is contributing to everyone else’s block profile, which is the indirection you have to reason through.

19.3.5 The Two Knobs Are Not Symmetric

Turning these on has a cost, and turning them off again has a surprise.

set_mutex_193.go
// Illustrative snippet — not a complete program
func SetMutexProfileFraction(rate int) int  // returns PREVIOUS rate
func SetBlockProfileRate(rate int)           // returns nothing

SetMutexProfileFraction returns the previous rate, and reads without setting when passed a negative number. So it can be saved and restored:

prev_193.go
// Illustrative snippet — not a complete program
prev := runtime.SetMutexProfileFraction(1)
defer runtime.SetMutexProfileFraction(prev)

SetBlockProfileRate returns nothing and there is no getter. The block rate cannot be read or restored through the public API at all. Two sibling knobs, introduced together, in the same package, for the same purpose — and one of them is one-way.

That asymmetry matters as soon as you write anything that enables profiling temporarily: a diagnostic endpoint, a debug handler, a test. You can restore half of what you changed, and for the other half you have to decide a policy — usually “the process owns one setting for its lifetime, set it at startup, never touch it” — and write it down. The exercise at the end of this chapter is built on exactly this.

On cost: both are cheap at low rates and neither is free. SetBlockProfileRate(1) records every blocking event, which on a channel-heavy service is a great deal of events; production settings usually use a rate in the microseconds so that only substantial waits are sampled. SetMutexProfileFraction(n) records one in n.

19.3.6 Diffing Two Profiles, and Why It Needs §19.2's Labels

The standard way to find a leak in production is to take a profile, wait, take another, and subtract. §10.6 already prints the command:

Terminal
$ curl http://localhost:6060/debug/pprof/goroutine > baseline.pprof
$ sleep 300
$ curl http://localhost:6060/debug/pprof/goroutine > current.pprof
$ go tool pprof -base baseline.pprof current.pprof

What §10.6 does not say is what the result means, and there are two limits worth knowing before you act on one.

A diff shows growth, never causation. The stack that grew is where the goroutines are parked, which is the place they could not leave — not the place that created them or the reason they were created. A diff showing 4,000 new goroutines in handleSearch tells you they are blocked in handleSearch; the bug is upstream, in whatever stopped cancelling them.

A diff of an unlabelled profile is often unreadable. Real services have one or two stacks that account for most goroutines, and they grow under load whether or not anything is leaking. Distinguishing “grew because traffic doubled” from “grew because they never exit” needs a second dimension — which is what §19.2.3's labels provide. A diff by stack says handleSearch grew; a diff of a labelled profile says handleSearch grew for one tenant, which is a different investigation and usually the right one.

This is where the chapter’s two halves meet: the labels are added at deploy time, months before the incident, and they are the difference between a diff that names the cause and a diff that names the symptom.

Go 1.27 adds a third profile to this workflow, and it answers the question the diff cannot. /debug/pprof/goroutineleakpprof.Lookup("goroutineleak") in code — reports only goroutines the runtime has proved can never wake: parked on a channel, mutex or condition variable that is unreachable from every runnable goroutine, and from everything those could unblock. The proof is the collector's reachability walk, so the profile is taken rather than always on (WriteTo runs that collection itself; you do not call runtime.GC first), it takes ?seconds= for a delta like the others, and it carries the same # labels: line the census does; at debug=2 the state reads [chan receive (leaked)]. Its blind spot is stated in the release notes and worth remembering: a primitive still reachable through a global, or through a local of a goroutine that is merely slow, is not a leak to the collector however leaked it looks to you. So the diff still finds growth, and the labels still say whose; goroutineleak says which of the grown stacks are provably dead. Experimental behind GOEXPERIMENT=goroutineleakprofile in 1.26, on by default from 1.27.

Its Count() is not a metric: it reports the last collection, and a process nobody has profiled reports zero leaks forever — the block-profile shape from §19.3.1, one section later.

19.3.7 CPU, Heap, and Flame Graphs

Chapter 10 promised this section by name — “for general profiling techniques including CPU profiling, memory profiling, flame graphs, and advanced pprof analysis, see Chapter 19” — so here is the part of it that is about concurrency.

The CPU profile is on by default and samples at 100 Hz. For concurrent code its most useful property is the one people forget: it samples running goroutines, so it is exactly complementary to the block and mutex profiles, which sample waiting ones. A service that is slow with idle CPUs has its answer in the blocking profiles; a service that is slow with saturated CPUs has it here.

The heap profile matters here for one reason: goroutine stacks are heap-allocated, so a goroutine leak shows up as memory growth, and §14.4.4's 2.7 KB per parked goroutine — 2,081 bytes of stack plus 606 of heap — is the conversion factor. Note which number that is: §2.5 quotes the familiar ~2 KB, and §14.4.4 measured the rest, warning in the same breath that “the commonly quoted 2 KB per goroutine is the stack half only”. Use the larger figure when converting a goroutine count into expected memory, or you will under-predict by a third. If inuse_space is climbing and the goroutine count is climbing proportionally, you have a leak rather than a memory bug, and the goroutine profile is the better tool.

Flame graphs are a rendering, not a profile: go tool pprof -http=:8080 profile.pprof opens a browser with a flame graph view of whichever profile you fed it. Width is accumulated value, stacking is call depth. For a block profile the flame graph reads as “where we wait”, which is the view most people have never looked at and the fastest way to see a convoy.

The interpretation question — what to change once you have read one — is Chapter 20's.

19.3.8 The Number You Should Have Read First

Everything in this section requires you to have enabled something, in advance, and to have chosen correctly which thing.

There is one contention figure that requires none of that, is always correct, and costs a counter read. It is in the next section, and it is what should send you here in the first place.

19.3.9 Common Mistakes

Reading an empty block profile as “no blocking”
Problem

A confident, wrong all-clear

Fix

Check the rate; an empty profile is ambiguous

Using Count() as an event count
Problem

Contention under-reported by orders of magnitude

Fix

Records are unique stacks; read the values

Quoting record counts as measurements
Problem

Numbers that change between runs

Fix

Zero versus non-zero; delay for magnitude

Expecting block to cover channels only
Problem

Missing mutex data that was there

Fix

Both record mutexes; they differ in attribution

Using the mutex profile to find a hot lock
Problem

An uncontended, million-times lock is invisible

Fix

It samples contended unlocks only

Expecting to restore both profile rates
Problem

Half the API has no getter

Fix

Restore the mutex rate; policy for the block rate

Acting on a goroutine diff’s top stack
Problem

Fixing where they park, not what leaked them

Fix

The diff shows growth, never causation

Diffing an unlabelled profile under load
Problem

Cannot separate traffic growth from a leak

Fix

Labels (§19.2.3) give the second dimension

Summary: Profiles, and the Two That Are Empty

Two of the seven standard profiles are disabled by default and report an empty profile rather than an error. Measured, 32,000 contended lock operations and 200 blocking sends produce zero block records and zero mutex records until SetBlockProfileRate and SetMutexProfileFraction are called — so an empty block profile means either nothing blocked or nothing was recording, and the profile cannot tell you which.

A record is a unique stack, not an event: 200 blocking sends collapse to single digits, and the counts are not even stable between runs. Read the accumulated delay, and report counts only as zero versus non-zero.

Both profiles record mutex contention, and the real distinction is attribution — measured, the block profile’s leaf is Mutex.Lock and the mutex profile’s is Mutex.Unlock. One blames the goroutine that suffered; the other blames the one that caused it.

Each has a shape of blindness: block is biased toward long waits, mutex sees only contended unlocks so a hot uncontended lock is invisible, and no blocking profile can see a goroutine that is running.

The two knobs are asymmetric — SetMutexProfileFraction returns the previous rate and reads with -1; SetBlockProfileRate returns nothing and has no getter — so anything that enables profiling temporarily can restore only half of what it changed.

And a profile diff shows growth rather than causation, and needs §19.2's labels to separate a leak from load.

Self-Check Questions: Profiles, and the Two That Are Empty

Your service is slow. You fetch the block profile, it is empty, and you conclude the problem is CPU-bound. What is wrong with that reasoning, and what would make it sound?

An empty block profile has two possible causes and the profile cannot distinguish them: nothing blocked, or nothing was recording. The block profile is off by default, so the second is the more likely explanation on a service where nobody deliberately turned it on.

Measured, a program doing 32,000 contended lock operations and 200 blocking sends reports exactly zero block records under default settings — a well-formed profile, fetched correctly, saying nothing happened.

To make the reasoning sound you need one more piece of evidence, and there are two cheap ways to get it. Confirm the profiler is actually enabled — the rate is a process-level setting somebody must have called. Or, better, read /sync/mutex/wait/total from runtime/metrics (§19.4), which is always on and needs nothing enabled: if it reports meaningful accumulated wait, the empty profile is a configuration problem and not an observation.

The general form is worth internalising, because it recurs throughout this chapter: an instrument that is switched off does not report an error, it reports zero, and zero from a switched-off instrument is indistinguishable from zero from a healthy system. Always establish that the instrument is live before drawing a conclusion from a negative result.

You want to know which lock in your service is the bottleneck. Which profile, and why does the other one give a misleading answer?

The mutex profile, because it attributes to the holder.

Measured on a mutex-only workload, the two profiles charge the same contention to opposite places: the block profile’s leaf frame is sync.(*Mutex).Lock and the mutex profile’s is sync.(*Mutex).Unlock. The block profile samples the goroutine that went to sleep and charges it where it waited; the mutex profile samples the unlock that releases a waiter and charges the goroutine that was holding.

For “which lock is the bottleneck” you want the holder, because the fix is to hold it for less time or to hold it less often — both changes to the holder’s code. The mutex profile points straight at it.

The block profile answers a different and also useful question, “which of my handlers is suffering”, and it is misleading for this one because it distributes the blame across every waiter. A single slow holder shows up as contention spread over twenty handlers that were unlucky enough to want the lock, none of which contains the bug. You would go and optimise the victims.

The one caveat is §19.3.4's: the mutex profile records only contended unlocks. A lock taken ten million times with no waiter never appears, and that may still be the lock worth removing — which is a CPU profile question rather than a contention one.

A goroutine profile diff over five minutes shows 4,000 new goroutines, all parked in handleSearch. Why is that not yet a diagnosis?

Because the diff tells you where the goroutines are parked, and the bug is whatever stopped them leaving.

handleSearch is where they could not proceed — blocked on a channel receive, a lock, a network call. It is the destination, not the cause. The cause is upstream: a context that is never cancelled, a response channel nobody reads any more, a downstream that stopped answering. Rewriting handleSearch addresses the address rather than the problem.

There is a second reason it is not yet a diagnosis, and it is the one that leads people astray on a busy service. Goroutine counts at a given stack grow under load whether or not anything is leaking. Four thousand new goroutines in five minutes is a leak if traffic was flat and is ordinary if traffic doubled, and the diff alone does not say which.

Separating those needs a second dimension, which is what §19.2.3's labels give you. A diff by stack says handleSearch grew. A diff of a labelled profile says it grew for one tenant, or one job type — and a growth confined to one label while traffic rose across all of them is a leak, unambiguously.

The follow-up that settles it either way is the duration annotation from §19.2.5: goroutines that have been parked for eighteen minutes on a service whose p99 is 200 ms are not busy, they are stuck.

Key Takeaways

  • Measured: the block and mutex profiles report zero, not an error, until explicitly enabled — 32,000 contended operations produced zero records
  • An empty profile is ambiguous between “nothing happened” and “nothing was recording”
  • A record is a unique stack, not an event; counts are unstable, so read the accumulated delay
  • Measured: block charges the waiter at Mutex.Lock, mutex charges the holder at Mutex.Unlock — same contention, opposite attribution
  • Block is biased toward long waits; mutex sees only contended unlocks; neither can see a goroutine that is running
  • SetMutexProfileFraction returns the previous rate and reads with -1; SetBlockProfileRate has no getter at all
  • A profile diff shows growth rather than causation, and needs labels to separate a leak from load
  • The CPU profile samples running goroutines, which makes it exactly complementary to the two that sample waiting ones
Section 19.3 — in one line

Two of the profiles a concurrency bug sends you to are switched off by default, and a switched-off instrument reports health rather than an error.

19.4 Always-On, and What It Costs You

Everything in §19.3 required a decision made in advance: enable this profiler, at this rate, before the thing you want to observe happens. Get that wrong and you have an empty profile and an incident that is over.

There is a whole class of instrument with no such requirement. runtime/metrics exposes counters the runtime maintains anyway, as part of doing its job. Reading them costs a function call. They are always correct, they were correct during the incident you missed, and — measuredruntime/metrics, like the goroutine labels in §19.2.3, appears in zero of the preceding eighteen chapters.

19.4.1 The Contention Number That Needs Nothing

s_194.go
// Illustrative snippet — not a complete program
s := []metrics.Sample{{Name: "/sync/mutex/wait/total:seconds"}}
metrics.Read(s)
total := s[0].Value.Float64()
Measured eight goroutines contending on one mutex, with no profiling enabled at all, and /sync/mutex/wait/total:seconds reports the accumulated wait — a real, non-zero figure, growing with contention.

That is the number §19.3.8 was pointing at. It is cumulative across the process lifetime, so the useful form is a rate: sample it twice and divide by the interval, and you have seconds of mutex wait per second, which is a directly interpretable saturation figure. Four seconds of accumulated wait per wall-clock second means roughly four goroutines are blocked on locks at any moment.

It cannot tell you which lock. That is exactly the division of labour this chapter keeps arriving at:

Cheap always-on metrics tell you whether; expensive on-demand profiles tell you where.

The metric is a smoke detector: it costs nothing, it runs permanently, and it cannot find the fire. The profile is the search: it costs something, you switch it on deliberately, and it names the room. Running the search continuously is how you end up with an observability bill; running no detector at all is how you end up fetching an empty profile after the incident.

DETECT CHEAPLY, SEARCH EXPENSIVELY

Two always-on metrics point at two expensive searches. /sync/mutex/wait/total answers whether there is contention, and on that evidence you reach for the mutex profile, which says which lock, or the block profile, which says who is waiting and on what. /sched/goroutines/runnable answers whether you are saturated, and on that evidence you reach for the execution trace, which says what order it happened in. A smoke detector cannot find the fire, and a search you run continuously is not a search -- it is overhead.

The practical consequence is a two-tier design. Export the metric always. Enable the profiler when the metric says something is wrong — or, better, keep it enabled at a low sampling rate so it is there when you need it, which is a decision the metric lets you make with evidence.

19.4.2 Goroutines, Split by State

Recent runtimes split the goroutine count by scheduler state, and this is §19.4's best material. Confirm the exact set with metrics.All() on your toolchain — the list grows between releases:

Terminal
/sched/goroutines:goroutines total
/sched/goroutines/running:goroutines executing right now
/sched/goroutines/runnable:goroutines READY, waiting for a P
/sched/goroutines/waiting:goroutines blocked on something
/sched/goroutines/not-in-go:goroutines in a syscall or cgo call
/sched/goroutines-created:goroutines cumulative

runnable is the one that matters, and it is the cheapest possible signal for a saturated scheduler. A goroutine in that state has work to do, is not blocked on anything, and is not running — the only thing it lacks is a processor.

Measured the same process at idle, then with 128 spinning goroutines on GOMAXPROCS=16:
Terminal
                  runnable running waiting
  idle 0 1 7
  128 spinners 113 16 7
  after 0 2 6
  mean scheduling latency under saturation: 13.66 ms

Two things are legible at a glance. running pins at exactly GOMAXPROCS — 16, because that is all the processors there are. And runnable is the queue: 113 goroutines that could run and cannot.

Derived runnable / GOMAXPROCS is a directly interpretable saturation ratio — here 113/16 ≈ 7 goroutines queued per processor, which means a newly-runnable goroutine waits behind roughly seven others before it runs. A ratio below 1 is headroom; sustained above about 2 is a service whose latency is being set by the scheduler rather than by its own code.

That distinction is what the total goroutine count hides. A service with 10,000 goroutines is completely healthy if they are all in waiting — blocked on network I/O, which is what a server does. The same 10,000 with a large runnable figure is CPU-saturated and every one of those goroutines is accruing latency for a request that has already arrived.

/sched/latencies:seconds is the histogram behind it: how long goroutines wait between becoming runnable and actually running. Under the saturation above, the mean was 13.66 ms.

Derived on a service with a 200 ms p99 latency budget, that is 13.66 / 200 ≈ 7% of the entire budget spent runnable and not running, before a single line of handler code executes. The same arithmetic on your own numbers is the fastest way to decide whether scheduling delay is worth attention: divide the mean scheduling latency by your latency budget, and if the answer is a percentage you would not accept as a line item, it is a problem.
This is the always-on counterpart to the one thing §19.5 says only the tracer can show.

The tracer can tell you a specific goroutine was runnable but not running for 40 ms during one request, with the surrounding context. The metric tells you, continuously and for nothing, whether that is happening at all. Which is the argument for having both, and the reason this is a section rather than a footnote to §19.3.

19.4.3 The Version That Needs No Code At All

runtime/metrics requires you to have written a reader. There is a cruder view that requires nothing but an environment variable, and it shows one thing the metrics do not.

Measured 200 CPU-bound goroutines on GOMAXPROCS=16, under GODEBUG=schedtrace=100:
Terminal
SCHED 0ms: gomaxprocs=16 idleprocs=13 ... runqueue=0
             [ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ]
SCHED 103ms: gomaxprocs=16 idleprocs=0 ... runqueue=60
             [ 14 3 14 15 2 4 0 35 34 0 1 1 1 0 0 0 ]
SCHED 244ms: gomaxprocs=16 idleprocs=0 ... runqueue=67
             [ 11 1 11 12 4 1 2 33 32 3 1 0 2 1 2 1 ]

One line every 100 ms, to standard error, with no code change and no recompilation. idleprocs falling to 0 and runqueue climbing to 60 is §19.4.2's saturation seen from the runtime’s side.

The bracketed array is the part runtime/metrics does not give you: the per-processor run queue depths. In the second line, processors 7 and 8 have 35 and 34 goroutines queued while processor 6 has none — the work is real and it is not evenly spread. Persistent imbalance like that points at work that is not being stolen effectively, usually because goroutines are being created in a pattern the scheduler cannot rebalance.

schedtrace is a development and staging tool, not a production one.

It writes to standard error on a fixed interval forever, which pollutes logs and cannot be turned off without a restart. Its value is that it needs nothing: no import, no handler, no endpoint. When you have a binary and a reproduction and no instrumentation, it is the fastest scheduler view available. scheddetail=1 adds a block per P and per M and is worth exactly one look.

19.4.4 Pauses, and the One You Caused Yourself

The runtime also measures its own stop-the-world pauses, and it does so with nothing enabled:

Terminal
/sched/pauses/total/gc the whole GC pause
/sched/pauses/stopping/gc the part spent getting goroutines to stop
/sched/pauses/total/other non-GC stop-the-world events
/sched/pauses/stopping/other

Two of these repay knowing.

stopping is a sub-phase of total, and their ratio is the interesting part. stopping is time spent waiting for every goroutine to actually reach a point where it can be stopped; total is the whole pause. When stopping approaches total, the collector is not slow — it is waiting on a goroutine that will not yield, which points at a tight loop with no preemption point rather than at the garbage collector. “It must be GC pauses” is a hypothesis, and this is how to check it for free before capturing anything.

other is where you find the pauses you inflicted on yourself. Non-GC stop-the-world events include goroutine profile collection — which is to say, §19.1.4's debug=2 dump appearing as a metric.

Measured /sched/pauses/total/other on an idle process, then after taking exactly five full goroutine dumps:
Terminal
before 5 dumps: n=0
after 5 dumps: n=5 p50=57.3us p99=196.6us

Five dumps, five pause events. The correspondence is exact — though not in the way the obvious follow-up experiment suggests. measured, the same five calls at debug=1 produce ten pause events, because the census stops the world twice per call rather than once. That does not reverse §19.2.2's advice: debug=1 is still far cheaper in wall time (112.9 ms against 913.3 ms at a hundred thousand goroutines) and still the one to reach for. It simply is not free, and a reader who repeats this measurement at the other debug level should get ten rather than conclude the chapter is wrong.

It closes a loop this chapter has been drawing since §19.1.4: your monitoring system’s periodic goroutine dumps are stop-the-world pauses that appear in your own pause metrics — and on a process holding a hundred thousand goroutines, §19.1.4 measured each one at 913 ms rather than the 57 µs above.

A rising pauses/total/other on a service nobody is debugging is worth chasing.

Something is stopping your process on a schedule, and the usual culprit is a monitoring agent or a dashboard scraping /debug/pprof/goroutine?debug=2 at an interval. That is a diagnostic tool causing the latency it was installed to observe, which is §19.1.6's observer effect arriving through the operations team rather than through the debugger.

19.4.5 Reading Metrics Without Getting Them Wrong

Three mechanical points, each of which produces wrong numbers when missed.

Most are cumulative, not instantaneous. /sync/mutex/wait/total only increases. Reading it once tells you nothing; the information is in the difference between two reads and the time between them.

Check Kind before reading the value. metrics.Read fills a union, and a metric that does not exist on your build comes back as KindBad rather than an error:

snippet_194.go
// Illustrative snippet — not a complete program
if s[0].Value.Kind() == metrics.KindBad {
    // not supported on this toolchain -- do not read it
}

That matters here because the per-state goroutine breakdown is recent. Code that reads it unconditionally will get a zero on an older runtime and report a perfectly idle scheduler.

Read them in one batch. metrics.Read takes a slice and fills all of it from a consistent view. Separate calls for related metrics can straddle a change and produce a set that never simultaneously existed.

19.4.6 What This Adds to Chapter 17's Four Numbers

§17.7.5 argued for exporting four numbers about your limiter — admitted, rejected by reason, queue depth, wait-time p99 — on the grounds that a limiter reporting only its permitted rate cannot show you what it is holding.

This is the same argument one level down. Those four are about your admission control; these are about the runtime underneath it. And they compose in a specific and useful way: a rising /sched/goroutines/runnable with a healthy limiter says your admission control is working and the machine is simply out of processors, which is a capacity decision. A healthy runnable with a growing limiter queue says the opposite — the machine is fine and your own bound is the constraint.

Neither number can distinguish those cases alone. Together they do, for the cost of two counter reads.

19.4.7 Common Mistakes

Reading a cumulative metric once
Problem

A large number that means nothing

Fix

Sample twice; report the rate

Not checking Kind
Problem

Zeros reported as a healthy scheduler

Fix

KindBad means unsupported, not idle

Separate Read calls for related metrics
Problem

A combination that never existed

Fix

One batched call, one consistent view

Watching total goroutines only
Problem

10,000 healthy waiters look like 10,000 stuck ones

Fix

Split by state; runnable is the signal

Enabling profilers permanently “to be safe”
Problem

Overhead everywhere, all the time

Fix

Metrics detect; profiles search

Exporting no runtime metrics at all
Problem

Every incident starts with an empty profile

Fix

These cost a counter read

GODEBUG=schedtrace left on in production
Problem

Logs polluted forever; no way to stop it

Fix

Development and staging only

Assuming a stop-the-world pause means GC
Problem

Chasing the collector; the cause is a dump

Fix

Split pauses/*/gc from pauses/*/other

Scraping ?debug=2 on a schedule
Problem

Self-inflicted pauses, ~1 s each at scale

Fix

Scrape debug=1, or scrape less often

Summary: Always-On, and What It Costs You

runtime/metrics exposes counters the runtime maintains anyway, so reading them costs a function call and they were correct during the incident you missed. Measured, they appear in zero preceding chapters.

/sync/mutex/wait/total reports accumulated mutex wait with no profiling enabled at all — the figure §19.3 kept pointing at. It cannot say which lock, which is the division of labour: cheap always-on metrics tell you whether, expensive on-demand profiles tell you where.

Recent runtimes split goroutines by scheduler state, and runnable — ready, not blocked, and not running — is the cheapest saturation signal available. Measured, 128 spinners on 16 processors gave running pinned at exactly 16 and runnable at 113, with a mean scheduling latency of 13.66 ms. The total count hides this: 10,000 goroutines in waiting is a healthy server, and the same 10,000 with a large runnable is a saturated one.

Read them as rates, check Kind before trusting a value, and batch the read. Together with §17.7.5's four numbers they separate “the machine is out of processors” from “my own limiter is the constraint” — a distinction neither can make alone.

Self-Check Questions: Always-On, and What It Costs You

Your dashboard shows goroutine count climbing from 2,000 to 40,000 over an hour. What single additional metric would most change your diagnosis, and what would each outcome mean?

The per-state split, and specifically the ratio between waiting and runnable.

If nearly all 40,000 are in /sched/goroutines/waiting, they are blocked on something — network reads, channel receives, locks. That is what a busy server looks like and may be entirely healthy; a server holding 40,000 idle keep-alive connections has 40,000 goroutines parked in a read, costing memory and nothing else. The follow-up question is whether the count comes back down, which distinguishes load from a leak (§19.3.6).

If a large fraction are in /sched/goroutines/runnable, the diagnosis is completely different: those goroutines have work, are not blocked, and cannot get a processor. The machine is CPU-saturated, every one of them is accruing latency before executing a single line, and adding goroutines will make it worse rather than better. Measured, 128 spinners on 16 processors produced runnable=113, running=16 and 13.66 ms of mean scheduling latency.

The reason this is the highest-value single addition is that the two cases call for opposite responses — more concurrency versus less — and the total count, which is the number almost everyone graphs, cannot tell them apart.

Why is an always-on metric described as a smoke detector rather than as a cheap profiler?

Because it answers a different kind of question, and the analogy is about which question rather than about cost.

/sync/mutex/wait/total gives one number for the whole process: seconds of accumulated mutex wait. It will tell you, continuously and for free, that goroutines are spending significant time blocked on locks. It cannot tell you which lock, which call site, or which handler, because it does not record stacks — recording stacks is what makes a profile expensive.

So it is not a cheaper version of the mutex profile; it is a detector for the condition under which fetching the mutex profile is worth doing. Alarm on the metric, then search with the profile.

That framing also settles the design question people get wrong in both directions. Running the profilers permanently, “to be safe”, buys you sampling overhead on every mutex operation forever and is the reason people distrust profiling in production. Running nothing at all means every incident begins with switching on an instrument for a failure that has already finished — which is §19.3.1's empty profile, and the reason that section exists.

The two-tier arrangement is the one that works: detectors always, searches on evidence.

/sync/mutex/wait/total reads 847.3 on your service. Is that bad?

Unanswerable as stated, and the reason is the most common mistake with this whole package: it is a cumulative counter, so a single reading carries almost no information.

847.3 seconds of accumulated mutex wait is alarming on a process that started a minute ago and unremarkable on one that has been up for a month serving heavy traffic. The number grows monotonically for the life of the process and is a function of uptime, load and concurrency as much as of any problem.

The information is in the derivative. Derived: sample twice, subtract, and divide by the elapsed wall-clock time, and you get seconds of wait per second — which is directly interpretable, because a goroutine blocked for a whole second contributes exactly one. A value near 4 therefore means roughly four goroutines are blocked on locks at any given moment, whatever the uptime, and dividing that by GOMAXPROCS tells you what fraction of your parallelism is spent waiting rather than working.

Then normalise by what you have. Four goroutines blocked on a machine running GOMAXPROCS=64 is noise; the same figure at GOMAXPROCS=4 means your capacity is largely spent waiting.

And the follow-up, when the rate does look bad, is §19.3's: the metric has told you whether, and now you enable the mutex profile to find out where — checking as you go that it was actually enabled, because an empty one will otherwise tell you the problem went away.

Key Takeaways

  • runtime/metrics counters are maintained anyway, cost a function call, and were correct during the incident you missed
  • Measured: /sync/mutex/wait/total reports real accumulated wait with no profiling enabled at all
  • Cheap always-on metrics tell you whether; expensive on-demand profiles tell you where
  • Measured: 128 spinners on 16 processors gave running=16 pinned at GOMAXPROCS and runnable=113 — the queue the total count hides
  • 10,000 goroutines in waiting is a healthy server; the same 10,000 in runnable is a saturated one
  • Mean scheduling latency under that saturation was 13.66 ms, spent before any handler code runs
  • Most of these are cumulative: sample twice and report the rate
  • Check Kind for KindBad, or an unsupported metric reads as a perfectly idle scheduler
  • Measured: five debug=2 dumps produced exactly five events in /sched/pauses/total/other — your own diagnostics are stop-the-world pauses
  • stopping approaching total means a goroutine will not yield, not that the collector is slow
  • Measured: GODEBUG=schedtrace=100 needs no code and shows per-P queue depths — [14 3 14 15 2 4 0 35 34 ...] — which runtime/metrics does not expose
Section 19.4 — in one line

The runtime is already counting; the only question is whether anyone reads it before the incident rather than after.

19.5 The Execution Tracer: Order, Finally

Everything so far has thrown away ordering. A dump is one instant; a profile is a sum; a counter is a total. None of them can answer a question of the form “what happened between the request arriving and the response leaving” — and that is the shape of every question about an interleaving.

The execution tracer is the only view that keeps ordering. It records goroutine creation, blocking, unblocking, syscall entry and exit, garbage collection, and processor start and stop, each with a nanosecond timestamp and a stack. What comes out is a timeline rather than a summary.

Terminal
$ go test -trace=trace.out ./... # from a test
$ go tool trace trace.out # open it

Or from a running program, over the standard endpoint:

Terminal
$ curl 'http://localhost:6060/debug/pprof/trace?seconds=5' > trace.out

19.5.1 Three Questions Only the Tracer Answers

The tracer’s interface is large and its views are numerous, and it is easy to spend an afternoon in it without learning anything. It repays being pointed at specific questions.

“Was this goroutine slow, or was it never scheduled?” This is the tracer’s signature question, and no profile can answer it. A handler that takes 40 ms might have spent 40 ms working, or 2 ms working and 38 ms sitting in the run queue while sixteen processors were busy with something else. The CPU profile attributes the same samples either way; the block profile sees no blocking, because being runnable is not blocking. Only the timeline shows the gap between “became runnable” and “started running”. §19.4's /sched/latencies says this is happening somewhere; the trace says it happened to this request.

SLOW, OR NEVER SCHEDULED?

A handler with 40 ms of wall time spends 38 ms runnable -- ready to run but given no processor -- and 2 ms actually running. The CPU profile sees only the 2 ms, because it samples what is running. The block profile sees zero, because runnable is not blocked. Only the execution trace sees both, and the gap between them.

“What is stealing time from my handler?” Garbage collection is the usual answer and the tracer shows it directly: stop-the-world pauses appear as gaps across every processor at once, and the mark phase appears as assist work charged to goroutines that were merely trying to allocate. A latency spike that correlates exactly with a GC cycle is a memory problem wearing a concurrency costume, and this is where you see the correlation rather than infer it.

“What actually happened during this one slow request?” Aggregates cannot answer questions about individuals. The trace, combined with §19.6's tasks, can show a single request’s whole life — which goroutines it spawned, what each blocked on, in what order, and where the time went.

Everything else the tracer displays is real and mostly not worth your afternoon. Start from a question.

19.5.2 Measured: The Profiles Hidden Inside go tool trace

The tracer’s most useful output for concurrency is not the visual timeline at all, and it is the least known thing in this chapter. go tool trace will convert a trace into pprof-format profiles, including one that measures the quantity §19.4 could only count:

Terminal
$ go tool trace -pprof=sched trace.out > sched.pprof
$ go tool pprof -top sched.pprof
Measured on 192 CPU-bound goroutines at GOMAXPROCS=16 — deliberate oversubscription:
Terminal
Type: delay
Showing nodes accounting for 28.65s, 100% of 28.65s total
      flat flat% sum% cum cum%
    28.65s 100% 100% 28.65s 100% runtime.Gosched

Twenty-eight seconds of scheduler delay, attributed to a call stack. That is time goroutines spent runnable and not running, broken down by the code that was waiting for a processor — and it is exactly the quantity the CPU, block and mutex profiles are all blind to (§19.5.1).

This closes the loop with §19.4. /sched/latencies gives you the distribution of that delay, always on, for nothing. -pprof=sched gives you the call stacks, on demand, for the price of a trace. The metric tells you scheduling delay is your problem; the trace tells you which code is paying it.

Four such profiles exist, and the other three matter:

Profiles derivable from a trace:
-pprof=
sched
sync
net
syscall

They separate kinds of waiting that a single “blocking profile” conflates, and net reporting exactly zero on a workload with no network is a useful demonstration that they are measuring what they claim.

-pprof=sync is the escape hatch for §19.3.1.

That section’s problem is that the block profile was switched off during the incident and cannot be switched on retroactively. A trace can be captured at any time without a deploy, and -pprof=sync extracts blocking attribution from it — the same question the block profile answers, from an instrument you did not have to enable in advance. It is the 2 a.m. answer to “nobody turned the profiler on”, and it is why §19.7's flight recorder is worth more than a timeline viewer.

19.5.3 What It Costs, and Why the Honest Answer Is Two Numbers

The tracer’s overhead is quoted constantly and almost always without conditions, which makes the figure useless. The overhead is a function of how many events your program generates, and programs differ by orders of magnitude in that respect.

Measured min of five runs at -benchtime 300ms, on two workloads chosen to bracket the range:
WHAT THE TRACER COSTS, ON TWO WORKLOADS

Execution tracer overhead on two workloads chosen to bracket the range. An unbuffered channel ping-pong goes from 182.2 ns to 426.1 ns, a ratio of 2.3x. TCP socket writes go from 20182 ns to 22153 ns, a ratio of 1.10x. An independent run of the same shape measured 1.03x, so the claim is a few percent rather than a specific ratio -- measure your own workload rather than quoting either number.

The first is a ceiling constructed on purpose: an unbuffered channel send between two goroutines is nearly pure scheduler activity, so almost every nanosecond of work generates a trace event. Real code does not look like this, and quoting 2.3× as “the cost of tracing” is quoting the worst case as the typical one.

The second is closer to a service: goroutines that block on a socket, do a little work, and block again. Ten percent. The syscall dominates, the event rate per unit of work is far lower, and the tracer’s cost largely disappears into the noise of doing actual I/O.

Which number applies to you depends on one thing: events per unit of work.

A pipeline of small channel operations is near the ceiling. A service that spends its time in network calls and database round trips is near the floor. If you need the real number, measure your own workload — it is one benchmark and the range between these two is a factor of twenty.

That range is why §19.7 can seriously propose leaving a tracer running in production. At 2.3× nobody would. At 10% it is a straightforward trade against the cost of never being able to explain an incident — and 10% is what a service that blocks on things actually pays.

19.5.4 Reading a Trace Without Drowning

Two habits make the difference.

Start from the goroutine analysis, not the timeline. The landing page’s “Goroutine analysis” link gives a per-function breakdown of where goroutines spent their time — execution, scheduler wait, block time, syscall, GC assist. That table is closer to a profile than a timeline and it is the fastest way to find the goroutine worth looking at. Go to the timeline afterwards, having decided what to look for.

The scheduler latency profile is the one to fetch first. go tool trace can emit synthetic profiles from a trace, and the scheduler-wait one is the answer to §19.5.1's first question in aggregate form: which call sites spent the most time runnable-but-not-running. It is a profile that no runtime/pprof endpoint can produce, because the information only exists in a timeline.

And a caution that saves an afternoon: a trace of a busy service for five seconds is enormous, and the tool loads it into memory. Trace for one second, not thirty. If the thing you are chasing is rare, that is not a sampling problem to be solved with a longer trace — it is §19.7's problem.

19.5.5 Common Mistakes

Opening a trace with no question
Problem

An afternoon in the UI, nothing learned

Fix

Pick one of §19.5.1's three questions

Quoting 2.3× as the cost of tracing
Problem

Tracing rejected for production on a worst case

Fix

Measure your own; the range is 20×

Tracing a busy service for 30 seconds
Problem

A file the tool cannot load

Fix

One second is usually plenty

Using a longer trace to catch a rare event
Problem

Enormous traces, still no event

Fix

Rarity is §19.7's problem, not duration’s

Reading the timeline first
Problem

Lost in detail

Fix

Goroutine analysis first, timeline second

Looking for a runnable-but-not-running gap in a profile
Problem

It is not there and cannot be

Fix

Only the timeline preserves that gap

Giving up because the block profile was off
Problem

The attribution was recoverable all along

Fix

go tool trace -pprof=sync from a fresh trace

Treating the trace viewer as the only output
Problem

Missing the four pprof profiles inside it

Fix

-pprof=sched, and sync, net, syscall

Summary: The Execution Tracer: Order, Finally

The tracer is the only view that preserves ordering, which makes it the only one that can answer a question about an interleaving. It records goroutine transitions, syscalls, GC and processor activity with timestamps and stacks.

Three questions repay it: whether a goroutine was slow or merely never scheduled, what is stealing time from a handler, and what happened during one specific slow request. Everything else it displays is real and mostly not worth the afternoon.

Its cost is two numbers, not one. Measured, a channel ping-pong chosen to maximise scheduler events cost 2.3×; a workload blocking on a TCP socket cost 1.10×. The first is a deliberate ceiling and the second is closer to a service, and the factor of twenty between them is why “the tracer is too expensive for production” is a claim about a benchmark rather than about tracing.

Its least-known output is the most useful: go tool trace -pprof= converts a trace into pprof-format profiles for sched, sync, net and syscall. Measured, -pprof=sched attributed 28.65 s of runnable-but-not-running time to a call stack, which no standard profile records — and -pprof=sync recovers blocking attribution from a trace even when the block profiler was never enabled, which is §19.3.1's escape hatch.

Start from the goroutine analysis rather than the timeline, and trace for one second rather than thirty.

Self-Check Questions: The Execution Tracer: Order, Finally

A handler’s p99 is 40 ms and its CPU profile shows almost no time in that handler at all. The block profile is empty and you have verified it was enabled. What is happening, and which tool shows it?

The goroutines are almost certainly runnable but not running — ready to execute, not blocked on anything, and waiting for a processor.

That state is invisible to both profiles you checked, and for opposite reasons. The CPU profile samples goroutines that are executing; a goroutine sitting in the run queue is not executing, so it contributes nothing. The block profile records goroutines that are blocked on a synchronisation event; being runnable is not blocked, so it contributes nothing there either. The time is real and falls in the gap between the two instruments — which is why an empty block profile and an idle CPU profile can coexist with a bad p99.

The execution tracer shows it directly, because it is the only view that records the transition timestamps: when the goroutine became runnable and when it actually started. The gap is the answer. The scheduler-latency profile derived from the trace gives it in aggregate, by call site.

runtime/metrics corroborates for free, before you take any trace: /sched/goroutines/runnable and the /sched/latencies histogram say whether the process is queueing at all. Measured under saturation, mean scheduling latency was 13.66 ms — which on a 40 ms handler is most of the problem.

The fix is usually fewer goroutines rather than more, or more processors — not anything inside the handler, which is doing nothing wrong.

Your team rejects the execution tracer for production on the grounds that it costs 2.3×. What is wrong with the reasoning?

The 2.3× was measured on a workload built to maximise trace events, and it is a ceiling rather than a typical cost.

Measured: an unbuffered channel ping-pong went from 182.2 ns/op to 426.1 ns/op — 2.3×. That workload is nearly pure scheduler activity, so essentially every unit of work produces a trace event. It is the most expensive shape a Go program can have from the tracer’s point of view, which is exactly why it is a useful ceiling and a poor estimate.

The same tracer on a workload writing to a TCP socket went from 20,182 ns/op to 22,153 ns/op — 1.10×. The syscall dominates, the event rate per unit of work is far lower, and the overhead largely vanishes into the cost of the I/O.

Real services are much closer to the second. They block on networks, databases and disks, and their event rate per millisecond of work is low. Ten percent is a real number and a very different conversation from 130 percent.

The right response is to measure your own workload rather than either figure — it is one benchmark, and the range between the two brackets is a factor of twenty. And it matters beyond this decision, because §19.7's flight recorder inherits exactly this overhead. A team that rejects tracing on the worst-case number also rejects the only mechanism that could have explained the incident they cannot reproduce.

Your bug appears roughly once an hour. Why is “trace for an hour” the wrong plan?

Because trace volume is a function of time and event rate, and an hour of a busy service produces a file measured in gigabytes that go tool trace has to load into memory to show you. You will not open it.

It also does not solve the problem it is meant to. Even with the file open, you would be looking for a few milliseconds of interest inside an hour of timeline with no index and no way to search for “the moment it went wrong”.

The failure is one of framing rather than of tooling. Duration is the wrong knob: you do not want an hour of trace, you want the thirty seconds around the failure, and you do not know when that is until it has happened.

That inversion is what §19.7 is about. A flight recorder keeps a rolling window in memory, continuously, and writes it out only when something tells it to — a latency breach, an error, a health-check failure. You get exactly the interval you wanted, at a size you can open, and you did not have to predict the moment in advance.

Which is the chapter’s spine arriving at its conclusion: the trace you need is the one you were not recording, and this is the technique for having been recording it.

Key Takeaways

  • The tracer is the only view that preserves ordering, which is the only view that can describe an interleaving
  • Three questions repay it: slow versus never-scheduled, what steals time from a handler, and what happened in one request
  • “Runnable but not running” is invisible to both the CPU and block profiles, and is the tracer’s signature finding
  • Measured: -pprof=sched attributed 28.65 s of runnable-but-not-running time to call stacks — the quantity no standard profile records
  • -pprof=sync recovers blocking attribution from a trace when the block profiler was never enabled
  • Measured: 2.3× on a scheduler-heavy ceiling, 1.10× on a workload that blocks on a socket — a factor of twenty between them
  • Quoting the ceiling as the cost of tracing is how teams reject the only tool that could explain an incident
  • Goroutine analysis first, timeline second; fetch the scheduler-latency profile no pprof endpoint can produce
  • Trace for one second; if the event is rare, duration is the wrong knob
Section 19.5 — in one line

Only the timeline remembers what order things happened in, and the argument against running it is usually a benchmark that looks nothing like your service.

19.6 Putting Your Own Events on the Timeline

A raw execution trace is a record of what the runtime did. It knows about goroutines, processors, syscalls and garbage collection, and it knows nothing whatsoever about requests, orders, tenants or retries. Which means that for a real service the timeline is precise, complete, and unreadable: thousands of goroutine transitions with no indication of which ones belong to the thing you are investigating.

runtime/trace fixes this with three functions, and they are the Go-native answer to what the original outline for this chapter called “logging and instrumentation strategies”.

19.6.1 Tasks, Regions, and Logs

snippet_196.go
// Illustrative snippet — not a complete program
ctx, task := trace.NewTask(ctx, "checkout")
defer task.End()

trace.Log(ctx, "order", orderID)

func() {
    defer trace.StartRegion(ctx, "validate").End()
    validate(ctx, order)
}()

func() {
    defer trace.StartRegion(ctx, "charge").End()
    charge(ctx, order)
}()

A task is a logical operation that may span goroutines. It is carried in the context, so anything that inherits the context inherits the task — including goroutines started downstream. That is what makes it the right tool for a request: the work does not stay on one goroutine, and a task follows it.

A region is an interval on one goroutine. It nests, it is cheap, and defer trace.StartRegion(ctx, name).End() is the whole idiom.

A log is a key-value pair attached to the task at an instant. It is how the identifier gets in.

Measured a trace containing one task with two regions and one log, parsed back with go tool trace -d=parsed, contains all four strings — checkout, validate, charge and the logged order identifier — in a 4,124-byte trace.

The payoff is the tool’s task-oriented views. go tool trace will show you the distribution of checkout durations, let you pick the slow one, and display that request’s goroutines, blocking, and regions against the scheduler’s own events on one axis. That is the difference between a trace you can use on a service and one you can only use on a toy.

19.6.2 What This Replaces

The instinct, when a handler is mysteriously slow, is to add log lines with timestamps and subtract them afterwards. Tasks and regions are better on four counts, and it is worth being explicit because the log-line habit is deeply ingrained.

They cost almost nothing when tracing is off. A region is a check of whether tracing is enabled and an early return. A log line is a lock, a format, a write, and usually a syscall — paid on every request forever, whether or not anyone is debugging.

They are on the same axis as the scheduler’s events. Your timestamps and the runtime’s are in one timeline, so “my handler took 40 ms” and “38 of those were spent runnable” appear together. Correlating a log file with a trace by wall-clock timestamps is an exercise in clock-domain frustration.

They survive concurrency. A task follows the context across goroutines. Log lines from a request that fans out to eight goroutines interleave with every other request’s lines and have to be re-associated by an identifier you remembered to include in all of them.

They do not change the timing they measure. This is §19.1.6's point, and it is the important one. A log line in a hot path is a lock: it serialises goroutines that were not previously serialised, and it can hide the very interleaving you added it to observe. Regions are designed to be nearly free and to be free entirely when tracing is off.

A log line is a synchronisation primitive.

log.Printf takes a mutex so that output from different goroutines does not interleave mid-line. That mutex is real, and adding one to a racy path narrows the window between two goroutines' accesses — which is exactly why “I added logging and it went away” happens often enough to be a folk phenomenon. The log did not fix anything; it serialised something.

19.6.3 The Same Idea, Twice

This is the second time the chapter has said decide in advance, and the symmetry is worth naming rather than leaving implicit.

TWO KINDS OF ADVANCE ANNOTATION

Two artifacts each take one kind of annotation that has to be added before the incident. A goroutine dump takes pprof labels, read back with a debug=1 census, or with any traceback from go 1.27. An execution trace takes tasks, regions and logs, read with go tool trace's task and region views. Neither can be added after the fact, and both turn “some goroutines” into “this tenant’s re-index, in its query phase”.

They are separate mechanisms with no overlap: a pprof label does not appear in a trace, and a trace task does not appear in a dump. A service that wants both artifacts readable has to add both — which is perhaps forty lines in one middleware, written once, and the difference between an incident you can describe and one you can only measure.

19.6.4 What to Instrument

The useful rule is that regions should map to decisions, not to functions.

Wrapping every function in a region produces a trace as unreadable as the raw one, with extra overhead. What you want are the boundaries where a request could plausibly stall: the acquire of a limiter or semaphore, a downstream call, a lock held across real work, a batch flush. Those are the places where a region’s start and end will differ by something interesting, and they are the same places Chapter 17 told you to bound and Chapter 15 told you to drain.

For a service, a workable default is one task per inbound request, one region per outbound dependency call, one region per contended resource acquisition, and a log for the identifiers you would need to correlate with anything else — request, tenant, job.

And they compose with §19.2.3's labels, which is worth noticing because they look redundant and are not. Labels attach to goroutines and are visible in profiles and the goroutine census. Tasks attach to contexts and are visible in traces. Setting both from the same place at the start of a request means an incident dump and an execution trace can be talked about in the same vocabulary — the same tenant, the same job type, named identically in two different instruments.

19.6.5 Common Mistakes

Timestamped log lines to time a handler
Problem

Overhead forever; clock-domain correlation pain

Fix

Tasks and regions; nearly free when off

A region per function
Problem

A trace as unreadable as the raw one

Fix

Regions map to decisions, not to call frames

Adding logging to a racy path to observe it
Problem

The bug disappears

Fix

A log line is a mutex; it serialises

Tasks created without the returned context
Problem

Nothing downstream inherits the task

Fix

Use the ctx NewTask returns

Forgetting task.End()
Problem

Tasks that never close; unusable duration views

Fix

defer task.End() at creation

Labels or tasks, but not both
Problem

Dump and trace cannot be discussed together

Fix

Set both from the same place

Summary: Putting Your Own Events on the Timeline

A raw trace records what the runtime did and knows nothing about requests, which makes it precise and unreadable on a real service. Tasks, regions and logs add your vocabulary to the runtime’s.

A task is a logical operation carried in the context, so it follows work across goroutines; a region is an interval on one goroutine; a log is a key-value pair at an instant. Measured, all three survive a round trip through go tool trace.

They replace timestamped log lines on four counts: near-zero cost when tracing is off, the same axis as scheduler events, survival across goroutine boundaries, and — most importantly — not changing the timing they measure. A log line takes a mutex, and adding one to a racy path serialises goroutines that were not previously serialised.

Instrument decisions rather than functions: limiter acquisitions, downstream calls, locks held across real work. And set goroutine labels from the same place you create the task, so that a dump and a trace can be discussed in one vocabulary.

Self-Check Questions: Putting Your Own Events on the Timeline

Why are trace.Region and a pair of timestamped log lines not equivalent, given that both record when something started and ended?

Four differences, and the last one is the one that matters for correctness rather than convenience.

Cost when disabled. A region checks whether tracing is on and returns; with tracing off it is close to free. Two log lines are two mutex acquisitions, two format operations and two writes, paid on every request forever, whether or not anyone is looking.

Clock domain. Regions land on the same timeline as the runtime’s own events, so “the handler took 40 ms” and “38 of those were spent runnable” are visible together. Log timestamps come from a different clock and correlating them with a trace is manual and error-prone.

Concurrency. A task carried in the context follows work across goroutines. Log lines from a request that fans out interleave with every other request’s output and must be re-associated by an identifier you remembered to print everywhere.

And the one that is not a matter of convenience: a log line is a synchronisation primitive. log.Printf takes a mutex so output does not interleave mid-line, which serialises goroutines that were not previously serialised. On a racy path that narrows the window you are trying to observe, and it is a common enough cause of a bug appearing to fix itself that §19.1.6 treats it as a named phenomenon. A region is designed not to do this.

The instrument that changes the measurement is the wrong instrument, however convenient it is.

You are asked to instrument a service for tracing. Where do the regions go?

At decisions, not at function boundaries.

The temptation is to wrap every function, which produces a trace as unreadable as the raw one plus overhead. The regions worth having are the places where a request could plausibly stall, because those are the only ones whose start and end will differ by something interesting.

In practice that means: one task per inbound request, created where the request context is; one region around each outbound dependency call; one region around each contended acquisition — a limiter, a semaphore, a lock held across real work; and a log carrying the identifiers you would need to join with anything else, typically request, tenant and job.

Those are the same boundaries the rest of the book has already told you to care about. Chapter 17's admission points are exactly where a request waits; Chapter 15's drain points are exactly where shutdown waits. If you have followed either chapter’s advice, the instrumentation points are already marked in the code.

Set goroutine labels from the same place. They are not redundant with tasks: labels attach to goroutines and appear in profiles and the goroutine census, tasks attach to contexts and appear in traces. Setting both together means an incident dump and a trace name the same tenant the same way — which is the difference between two investigations and one.

You already instrument every request with a structured logger that emits JSON to stdout, and your platform indexes it. Why add tasks and regions rather than more log fields?

Because they answer a question the log cannot, and they do it without the cost the log is already paying.

The question is where the time went inside the request, and specifically whether it went to your code, to a dependency, or to waiting for a processor. A structured log tells you the request took 40 ms. It cannot tell you that 38 of those were spent runnable-but-not-running, because that fact exists only in the runtime’s timeline and your logger has no access to it. Regions land on that same timeline, so the two facts appear together (§19.5.1).

The cost argument runs the other way from the intuition. Adding log fields is not free: every field is formatted and written on every request forever, and the logger takes a mutex to do it. Regions cost a branch when tracing is off, which is almost always.

They are also complementary rather than competing. The log is your permanent, always-on record and it is what you search when you do not yet know what happened. The trace is what you turn on — or capture from a flight recorder — when the log has told you which requests are bad and you need to know why. Detect in the log, search in the trace, which is §19.4's two-tier argument in a different costume.

The one thing worth doing to make them work together is carrying the same identifier in both: trace.Log(ctx, "request", id) with the same id the logger emits. Then a slow request found in the log can be located in a trace, which is otherwise a matter of matching timestamps across clock domains.

Key Takeaways

  • A raw trace knows about goroutines and nothing about requests, which makes it unreadable on a real service
  • A task follows a context across goroutines; a region is an interval on one; a log carries the identifiers
  • Measured: tasks, regions and logs all survive a round trip through go tool trace
  • They beat timestamped logs on cost-when-off, clock domain, concurrency, and not perturbing the timing
  • A log line is a mutex — adding one to a racy path serialises goroutines and can hide the bug
  • Instrument decisions, not functions: limiter acquisitions, dependency calls, locks held across work
  • Set labels and tasks from the same place so a dump and a trace share one vocabulary
Section 19.6 — in one line

The runtime’s timeline records what it did; tasks and regions are how you write your own vocabulary onto it without changing what you are measuring.

19.7 The Trace You Were Not Recording

Every technique so far has one of two problems. The cheap ones — dumps, profiles, counters — throw away ordering, so they cannot describe an interleaving. The one that keeps ordering costs too much to leave on, so it is never running when the interesting thing happens.

That is the bind the chapter opened with, and it is the reason “it happened once at 03:14” is the hardest category in §19.1.7's triage. You cannot start a tracer in response to an event that has already finished.

runtime/trace.FlightRecorder, added in Go 1.25, is the way out. It keeps a moving window of execution trace in memory — always the most recent few seconds — and writes it out only when you ask. You do not have to predict when the interesting thing will happen. You have to be able to recognise it afterwards, which is a much easier problem.

19.7.1 The Shape

fr_197.go
// Illustrative snippet — not a complete program
fr := trace.NewFlightRecorder(trace.FlightRecorderConfig{
    MinAge:   5 * time.Second,   // keep at least this much history
    MaxBytes: 8 << 20,           // a hint; see §19.7.3
})
if err := fr.Start(); err != nil {
    log.Fatal(err)
}
defer fr.Stop()

// ... later, in the code that notices something is wrong:
if latency > slo {
    f, _ := os.Create("incident.trace")
    n, err := fr.WriteTo(f)   // the last MinAge seconds
    f.Close()
    log.Printf("captured %d bytes of trace: %v", n, err)
}
THE MOVING WINDOW

A flight recorder keeps a sliding window. Everything older than MinAge -- here five seconds -- is dropped, and the last five seconds are kept. When the trigger fires, WriteTo() writes out the window that is already behind you. You do not predict the moment; you recognise it afterwards.

That is the whole mechanism, and the interesting part is the trigger rather than the API. The recorder runs continuously; something in your code has to decide that now is the moment worth keeping. Good triggers are the ones you already have: an SLO breach, a health check failing, a request exceeding its deadline, a circuit breaker opening (§17.6), a shutdown taking longer than its budget (§15.7).

Measured the resulting snapshot is a real, complete trace. go tool trace -d=parsed reads it back with full StateTransition scheduling events, stacks and metrics — 536 events in a 4,465-byte capture from a short run. It opens in the same tool, with the same views, as a trace you started deliberately.

19.7.2 It Is Not the Cheap Option

The universal assumption about the flight recorder is that it is a lightweight alternative to full tracing. It is not, and this is the section’s most useful finding.

Measured min of five, same channel ping-pong as §19.5:
Terminal
untraced 182.2 ns/op
flight recorder running 429.8 ns/op 2.36x
trace.Start to io.Discard 426.1 ns/op 2.34x

The two tracing modes are statistically indistinguishable. That makes sense once stated: the flight recorder does not trace less, it traces exactly as much and throws away the oldest data instead of writing it out. The runtime is generating the same events either way.

What the flight recorder saves is storage, and the decision of when to start — which are the two things that actually prevented you from having a trace of the incident. It does not save CPU.

And §19.5's second measurement is what makes that acceptable. The 2.36× is the scheduler-heavy ceiling; a workload that blocks on a socket paid 1.10×. Leaving a flight recorder running on a service that spends its time in network calls costs on the order of ten percent, permanently, in exchange for being able to explain any incident you can recognise. That is a real trade with a real price, and it is a very different conversation from 136 percent.

Budget it against the incident you cannot currently explain.

Ten percent of CPU is a genuine cost and nobody should pretend otherwise. The comparison is not against zero — it is against the alternative, which is an unexplained recurring incident and an engineer taking dumps after the fact. If you have one of those, the trade is easy. If you do not, do not pay it.

19.7.3 MaxBytes Is a Hint, Not a Bound

The configuration has a MaxBytes field, and it does not do what its name suggests. The documentation is honest — it says to treat the value as a hint — and the size of the discrepancy is worth knowing before you size a disk or a network payload from it.

Measured configured at 1 << 20 (1 MiB), against eight goroutines doing 200,000 channel sends each, WriteTo produced 27,191,162 bytes — about 26× the hint.

The overshoot is not a fixed factor. It scales with the event rate, because the window is defined primarily by MinAge — a duration — and a busy program simply generates more bytes in that duration. An independent run at a lower load produced roughly 2×. So the durable claim is the qualitative one:

MaxBytes will not stop a snapshot from being large.

Size your handling for the event rate, not for the configured number: write to a file rather than buffering in memory, apply your own cap if you are shipping the capture somewhere, and do not assume a snapshot fits in a log line, a request body, or whatever budget the field’s name implied.

The companion constraint: at most one flight recorder may be active in a process.

19.7.4 Two Errors, and Why the Difference Matters

Start fails in two distinguishable ways, and telling them apart is a diagnostic you cannot get any other way.

Measured the two Start errors, on one recorder started twice and on a second recorder started while the first is still active.
Terminal
same recorder, Start() twice
    -> "cannot enable a enabled flight recorder"
a second recorder, first still active
    -> "flight recorder already enabled"

The first says you double-started: a bug in your own initialisation, usually a retry or a duplicated setup path.

The second says something else. Only one recorder may be active process-wide, so this error means another component in this process already owns it — an APM agent, a profiling sidecar library, a framework’s debug mode, something a dependency enabled without telling you. That is a fact about your process you have no other way to discover, and it explains an otherwise baffling situation: your flight recorder never captures anything, because it never started, because a library beat you to it.

Check the error string, not just the error.

These two conditions call for opposite responses — fix your initialisation, or find out what else in the process is tracing — and they are distinguished only by the message. The standard library’s own grammatical slip in the first (a enabled) makes it easy to match on. Log the string.

19.7.5 Wiring It Up

Three practical points, in the order they bite.

Set MinAge from what you need to see, not from what feels tidy. The window must cover the interval before the trigger fires, because the trigger is a consequence and the cause is earlier. If your SLO breach is detected when a request completes at 800 ms, a 1-second window may already have discarded the beginning of that request. Five seconds is a reasonable default; think in terms of the longest causal chain you might need to look backwards through.

The trigger must be cheap and must not fire continuously. WriteTo is not free and a service that captures on every slow request will spend its time writing traces. Rate-limit the capture — one per minute is generous — which is Chapter 17's machinery pointed at your own diagnostics.

Capture on the way out, not on the way in. The natural place is wherever you already decide something went wrong: the error path, the deadline check, the breaker’s transition to open. Those places have the context to name the capture usefully, and §19.6's task identifiers are what make the resulting trace searchable.

19.7.6 A Worked Investigation, End to End

Everything in this chapter is a piece of one procedure. Here it is on a single symptom, with the perturbation ladder climbed one rung at a time and stopped as soon as it answers.

The report. “Checkout is slow. Started around 14:00. No errors.”

Rung 1 — the always-on numbers (§19.4), a few hundred nanoseconds. Before capturing anything, read what is already there:

Terminal
/sched/goroutines 4,102 (380 this morning)
/sched/goroutines/runnable 0
/sched/goroutines/waiting 4,088
/sync/mutex/wait/total 0.02 s (flat since start)
/sched/latencies p99 ~40 us (unchanged)
/sched/pauses/total/other n=0

That is a complete triage in one read, for the cost of a memory load. runnable is zero and scheduler latency is unchanged, so you are not CPU-saturated — which rules out the most common guess and saves a wasted GOMAXPROCS change. Mutex wait is flat, so it is not lock contention. pauses/total/other is zero, so nobody’s monitoring is stopping you (§19.4.4). And waiting has grown by an order of magnitude: goroutines are accumulating in a blocked state.

Rung 2 — the snapshot (§19.2), one HTTP call. debug=1, because it aggregates, it carries labels, and §19.1.4 measured it at an eighth of the cost of the full dump on a process this size:

Terminal
3891 @ 0x43e5c5 0x40c2b8 0x7c1a90 ...
# labels: {"route":"checkout", "tenant":"acme"}
# 0x7c1a8f main.(*PaymentClient).Do+0x8f payment.go:112

Nearly four thousand goroutines at one line, all blocked, all labelled. The labels are doing exactly what §19.2.3 promised — without them this is 3,891 identical stacks with no indication of whose work it is, and below go 1.27, with debug=2 you would not have them at all.

Rung 3 — is it growing? (§19.3.6), one more profile. Five minutes later, and subtract:

Terminal
$ go tool pprof -base t0.pprof t1.pprof
      +1,204 main.(*PaymentClient).Do

Still climbing. That is the difference between a busy period that will drain and a leak that will not — between waiting and paging someone.

Rung 4 — what are they waiting on, and for how long (§19.3). The stack says payment.go:112; the block profile would say how long each wait lasts. If nobody enabled it — which §19.3.1 says is the default — you do not have to deploy anything to find out: capture a five-second trace and run go tool trace -pprof=sync (§19.5.2). Same attribution, no restart, at 2 a.m.

What was never needed. No debugger, because nothing was worth stopping and stopping it would have cost the schedule (§19.1.6). No CPU profile, because runnable=0 had already established the time was not being spent on a processor. No stress harness, because the failure was happening continuously in front of us — Chapter 16's tooling is for the failures that are not.

And the one case where all of it fails. If this had been a transient stall — three seconds at 03:14, recovered on its own, gone before anyone looked — every rung above would have been useless, because all of them require the problem to be happening while you look. That is the case this section exists for.

THE LADDER, IN ORDER

Six rungs, each costing more and answering a narrower question. Metrics at about 300 ns ask whether anything is wrong and what kind. A dump, in milliseconds to a second, asks where everyone is. A diff of two dumps asks whether it is growing. A profile, once enabled, asks how long and blamed on whom. A trace, over seconds, asks in what order and against what. A debugger stops the program and asks what the actual values are. Stop as soon as you have the answer.

19.7.7 A Capture Without Tasks Is Nearly Unreadable

There is a failure mode specific to flight recording that does not arise with a deliberate trace, and it is worth anticipating because you discover it at the worst moment.

When you start a trace by hand you already know what you are investigating. You started it because a particular endpoint was slow, you ran the thing that makes it slow, and you stopped it. The trace is a few seconds long and every goroutine in it is plausibly relevant.

A flight recording is the opposite. It fires on a trigger, it contains whatever the whole process was doing for the last several seconds, and the request that breached the SLO is one of perhaps ten thousand things in it. You have the ordering — which is what you could not get any other way — and no way to find the part you care about.

§19.6's tasks are what make it navigable, which is why the two sections are adjacent. With one task per request, go tool trace's task view gives a duration distribution, you pick the outlier, and you get that request’s goroutines, regions and blocking against the scheduler’s own events. Without them you get a correct, complete, unsearchable timeline.

So the deployment order matters: instrument first, record second. A service that adds a flight recorder before it adds tasks has bought the ability to capture an incident it will not be able to read.

Two smaller things follow from the same observation.

Log the trigger alongside the capture. The file is useless six months later if nobody wrote down why it was taken. One log line naming the trigger, the request identifier and the file path costs nothing and is the difference between an artefact and a diagnosis.

Name the capture after the thing that fired it. incident-<trigger>-<request-id>-<timestamp>.trace lets you correlate with the logs that made you take it, which is where §19.6's shared identifier pays off a second time.

19.7.8 Common Mistakes

Assuming the flight recorder is the cheap tracer
Problem

Overhead you did not budget for

Fix

It traces identically; it saves storage

Rejecting it on the 2.36× figure
Problem

No diagnostics for a real service

Fix

That is the ceiling; I/O-bound paid 1.10×

Sizing storage from MaxBytes
Problem

Up to a 26× surprise at high event rates

Fix

It is a hint; size for the event rate

Buffering a snapshot in memory
Problem

Large allocations at the worst moment

Fix

WriteTo a file

Not checking which Start error you got
Problem

Silent no-op; nothing ever captured

Fix

The two strings mean opposite things

A MinAge shorter than the causal chain
Problem

A trace that starts after the cause

Fix

Cover the interval before the trigger

Recording before instrumenting
Problem

A complete, correct, unsearchable timeline

Fix

Tasks first (§19.6), recorder second

Starting an investigation at the profile
Problem

Enabling instruments to learn what metrics knew

Fix

Climb the ladder; rung 1 costs nanoseconds

Capturing without logging why
Problem

An artefact nobody can interpret later

Fix

Log the trigger, the ID and the path

Capturing on every slow request
Problem

The service spends its life writing traces

Fix

Rate-limit the capture

Summary: The Trace You Were Not Recording

The cheap views discard ordering and the view that keeps ordering costs too much to leave on — which is why the hardest category of incident is the one that is already over. The flight recorder resolves it by keeping a moving window in memory and writing it out on a trigger, so you no longer have to predict the moment, only recognise it afterwards.

Measured, it is not the cheap option: 429.8 ns/op with the flight recorder against 426.1 with full tracing and 182.2 untraced. It traces exactly as much and discards the oldest data. What it saves is storage and the decision of when to start — which are precisely the two things that stopped you having a trace. Its acceptability rests on §19.5's other number: 1.10× on a workload that blocks on a socket.

MaxBytes is a hint and overshot by 26× at high event rates, so size for the event rate rather than the field. Only one recorder may be active, and the two Start errors distinguish “you double-started” from “something else in this process already owns it” — a fact about your process available no other way.

Set MinAge to cover the causal chain, rate-limit the trigger, and capture where you already decide something went wrong. And instrument before you record: a flight recording without §19.6's tasks contains the ordering you could not otherwise get, in a form you cannot search.

Self-Check Questions: The Trace You Were Not Recording

Your team wants continuous tracing but is worried about cost, and proposes the flight recorder specifically because it is “lighter than full tracing”. Is that right?

No, and the misconception is worth correcting carefully because the conclusion — use the flight recorder — is still right for a different reason.

Measured, on the same workload: untraced 182.2 ns/op, flight recorder 429.8, full tracing to io.Discard 426.1. The two tracing modes are statistically identical. That follows from the mechanism: the flight recorder does not generate fewer events, it generates the same events and discards the oldest instead of writing them out. The runtime’s work is unchanged.

What it saves is the two things that actually stopped you having a trace of the last incident. Storage — you keep seconds rather than hours, and only write the seconds you asked for. And the decision of when to start, which is the impossible one, because it requires predicting an event that has not happened.

So the right argument for adopting it is not that it is cheap; it is that continuous tracing at any cost is useless if you cannot afford to keep the output, and this makes keeping it affordable.

The cost question then has to be answered honestly, and §19.5 gives the range: 2.36× on a scheduler-heavy microbenchmark, 1.10× on a workload that blocks on a socket. A real service is near the second. Ten percent permanently, in exchange for being able to explain incidents, is a real trade — and one the team should make on the 1.10× number measured on their own workload, not on either of mine.

You configure MaxBytes: 1 << 20 and your capture handler writes the snapshot into a bytes.Buffer before shipping it. What goes wrong, and when?

The buffer is far larger than a megabyte, and it happens at precisely the worst moment.

Measured: configured at 1 MiB, against eight goroutines doing 200,000 channel sends each, WriteTo produced 27,191,162 bytes — about 26× the hint. The documentation says to treat MaxBytes as a hint, and the overshoot scales with event rate rather than being a fixed factor, because the window is defined mostly by MinAge, a duration, and a busier program produces more bytes in that duration.

The timing is what makes it dangerous. Your trigger fires when something is already wrong — an SLO breach, a breaker opening, a deadline missed — which typically means the service is under load, which is exactly when the event rate is highest and the snapshot largest. So the allocation is biggest at the moment the process is least able to absorb it, and a diagnostic intended to explain an incident becomes a participant in it.

The fix is to write to a file with WriteTo rather than buffering, and to apply your own cap if you are shipping it somewhere with a size limit. Rate-limit the capture as well, or a period of sustained trouble produces a continuous stream of multi-megabyte snapshots.

The general lesson is one this book keeps returning to: read what the field does, not what its name implies — the same discipline §17.3 applied to Reserve and §19.3 to an empty profile.

Your flight recorder never captures anything. Start returned an error at boot which your code logged and ignored. Which error would you hope to find, and what does each mean?

The two possibilities mean opposite things and call for opposite responses, which is why logging the string rather than just the fact of an error matters.

cannot enable a enabled flight recorder means the same recorder was started twice. That is a bug in your own initialisation — a duplicated setup path, a retry, an init that also runs from main. It is entirely within your control and usually a five-line fix.

flight recorder already enabled means a different recorder is active, and since at most one may be active process-wide, something else in the process owns it. An APM agent, a profiling library, a framework’s debug mode, a dependency that quietly enabled tracing. Your code is fine; your process has another tenant.

That second one is the valuable diagnostic, because there is no other way to discover it. Nothing prints “a library enabled the flight recorder” at startup, and the symptom — captures that never happen — looks identical to a configuration mistake. The error string is the only signal.

The operational lesson is smaller and more general: an error at boot that is logged and ignored is a diagnostic that silently does not exist, which is the same failure this chapter opened with. The instrument was off, nothing reported a problem, and the absence of data read as an absence of trouble.

Key Takeaways

  • The flight recorder keeps a moving window in memory and writes it on a trigger, so you recognise the moment instead of predicting it
  • Measured: 429.8 ns/op flight recorder against 426.1 full tracing and 182.2 untraced — it is not the cheap option
  • It traces identically and discards the oldest data; what it saves is storage and the decision of when to start
  • Its acceptability rests on the I/O-bound figure: 1.10×, not the 2.36× ceiling
  • Measured: MaxBytes overshot its hint by 26× at high event rates — size for the event rate, and write to a file
  • The two Start errors distinguish “you double-started” from “another component owns the recorder”
  • MinAge must cover the causal chain before the trigger, and the trigger must be rate-limited
  • A capture without §19.6's tasks is a correct, complete, unsearchable timeline — instrument first, record second
  • The captured snapshot is a real trace and opens in the same tool
Section 19.7 — in one line

You cannot start a tracer in response to something that has already finished, which is why the only useful question about a rare incident is what was already recording.

Chapter Summary

A sequential bug lives in a state and a concurrent bug lives in an interleaving, which is why the tools divide into ones that report where everyone ended up and one that reports how they got there — and why the second kind has to have been running before the incident to be worth anything.

There are four views. A snapshot says where everyone is now. An aggregate says where time accumulates and has discarded ordering to be cheap enough to leave on. A timeline preserves ordering, and is therefore the only view that can describe an interleaving. A single execution answers in detail about one goroutine by stopping the program, which is why the debugger is the weakest tool here and not the strongest. Underneath all four sit continuous counters, which are free.

They form a perturbation ladder, and it is measured rather than asserted: a full debug=2 dump costs 169 µs at a hundred goroutines and 913 ms at a hundred thousand, while metrics.Read stays sub-microsecond throughout — six orders of magnitude at the bottom row. The rule is to use the least invasive tool that can answer the question, and it is a correctness rule rather than a frugal one, because the tools at the bottom change what they measure. A breakpoint cannot show you a race; preventing simultaneity is precisely what it does. “I added a log line and it went away” is a measurement of window width, not a fix.

The dump is not quite what Chapter 2 described. The runtime now names the primitive — sync.Mutex.Lock rather than semacquire — though the symbol survives as a stack frame, so the claim is about the bracket. debug=1 is a census and debug=2 is every stack, and measured, goroutine labels appear only in the first. Since SIGQUIT and panics print the second, the dump you are handed in an incident is the one without your labels — a trap Go 1.27 retires for modules that declare it, behind a permanent opt-out. measured, GOTRACEBACK spans zero to six goroutine stacks across its five levels and defaults to single — one goroutine, which for a concurrent bug is usually the victim rather than the cause.

Two of the seven profiles are switched off by default and report an empty profile rather than an error. Measured: 32,000 contended lock operations and 200 blocking sends produce zero block records and zero mutex records until something enables them, so an empty block profile is ambiguous between “nothing blocked” and “nothing was recording”. A record is a unique stack rather than an event, and the counts are not stable between runs. measured, the two profiles attribute the same contention to opposite places — block charges the waiter at Mutex.Lock, mutex charges the holder at Mutex.Unlock — which is what decides between them. And their knobs are asymmetric: SetMutexProfileFraction returns the previous rate and reads with -1; SetBlockProfileRate has neither.

The runtime also measures its own stop-the-world pauses, and measured, five debug=2 dumps produced exactly five events in /sched/pauses/total/other — which means a monitoring system scraping goroutine dumps on a schedule is inflicting the pauses it was installed to observe, at 913 ms apiece on a large process.

Against all of that, runtime/metrics costs a counter read and was already correct during the incident you missed. measured, /sync/mutex/wait/total reports real accumulated wait with nothing enabled, and the per-state goroutine split makes saturation legible: 128 spinners on 16 processors gave running pinned at exactly 16 and runnable at 113, with 13.66 ms of mean scheduling latency. Cheap always-on metrics tell you whether; expensive on-demand profiles tell you where.

The tracer is the only view that keeps ordering, and its least-known output is its most useful: go tool trace -pprof= converts a trace into pprof profiles for sched, sync, net and syscall. measured, -pprof=sched attributed 28.65 s of runnable-but-not-running time to a call stack — the quantity every standard profile is blind to — and -pprof=sync recovers blocking attribution from a trace even when the block profiler was never enabled, which is the escape hatch for an incident where nobody turned it on. Its cost is two numbers rather than one. Measured: 2.3× on a channel ping-pong built to maximise scheduler events, and 1.10× on a workload that blocks on a socket. Quoting the ceiling is how teams reject the only instrument that could have explained the incident. Tasks and regions put your own vocabulary on that timeline, at a fraction of the cost of the log lines they replace — and without the mutex a log line quietly is.

And the endgame is the flight recorder. measured, it is not the cheap tracer: 429.8 ns/op against 426.1 for full tracing. It traces identically and discards the oldest data, saving storage and the decision of when to start — which happen to be the two things that stopped you having a trace of the incident. MaxBytes overshot its hint by 26×, and its two Start errors distinguish your own double-start from another component in the process already owning the recorder.

Chapter Connections

How Chapter 19 connects
Chapter 2
§19.2.1 corrects §2.4's goroutine-state table; §2.5's ~2 KB is the stack half of §14.4.4's 2.7 KB, which is what turns a leak into a memory graph
Chapter 8
§8.3's race detector is a fourth instrument with its own blind spot; §19.1.4 adds that it distorts timing enough to hide narrow windows
Chapter 9
§9.4 introduced the mutex profile; §19.3.3 says what it attributes and why that differs from the block profile
Chapter 10
§10.4 owns taking a dump and the detector’s blind spots; this chapter cites it throughout and owns the reading
Chapter 12
§12.3 said rate-limiter state cannot live in a sync.Pool; the same reasoning is why profile state is process-global (§19.3.5)
Chapter 13
Tasks are carried in a context (§19.6.1), so instrumentation propagates exactly where cancellation does
Chapter 15
§15.7.5 reaches for kill -QUIT on a hung shutdown; §19.2.4 explains why the default GOTRACEBACK may show you only the wrong goroutine, and §19.1.4's near-second dump is §15.3.3's liveness argument with the roles reversed
Chapter 16
Owns reproduction and stress (§16.7) and hands this chapter diagnosis after capture; §19.7 is the answer to the failures §16.7 never made reproducible
Chapter 17
§17.7.5's four numbers are the always-on argument one level up from §19.4's; together they separate a saturated machine from an over-tight limiter
Chapter 18
The bug catalogue is the other end of §19.1.7's triage table: it names the bugs whose symptoms you are matching
Chapter 20
Takes the profiles this chapter teaches you to read and decides what to change

Final Checklist

Before moving to Chapter 20, ensure you can:

Exercise 19.1 — Fix the Diagnostic, Not the Program

Your move

Fix the Diagnostic, Not the Program

Every exercise in this book so far has given you a broken program. This one gives you a broken diagnostic, which is the right shape for a chapter about instruments: the program under test is fine, and the tool that examines it reports health.

Diagnose runs a workload and reports the contention it observed. It compiles, go vet is clean, and go test -race finds nothing — there is genuinely nothing racy here. It reports no blocking and no contention against a workload that does nothing but block and contend.

The first bug is §19.3.1. The block and mutex profiles are off by default, Diagnose never enables them, and an empty profile is returned rather than an error — so the diagnostic cannot distinguish “nothing blocked” from “nothing was recording”, and it reports the first.

The second is §19.3.2. runtime.BlockProfile returns the number of records, and a record is a unique stack. Every send from one call site collapses into one record however many times it blocks, so even once enabled the magnitude is wrong by orders of magnitude.

ch19/diagnose.go
// Package ch19 is the exercise for Chapter 19: Debugging Concurrent
// Programs.
//
// Diagnose runs a workload and reports the contention it observed. It
// is meant to hold three promises:
//
//   - a workload that blocks is reported as blocking
//   - the reported magnitude tracks how much blocking happened
//   - a workload that does not block is reported as quiet
//
// TODO(reader): this compiles, vets clean, and has no data race. It
// keeps the third promise and breaks the first two. Four tests prove
// it, and the fixes are not independent -- read the traps below.
package ch19

import "runtime"

// Report describes what a diagnostic run observed.
type Report struct {
	BlockEvents int64
	MutexEvents int64
}

func (r Report) Blocking() bool  { return r.BlockEvents > 0 }
func (r Report) Contended() bool { return r.MutexEvents > 0 }

const maxRecords = 1 << 14

// Diagnose runs workload and reports the contention it observed.
func Diagnose(workload func()) Report {
	workload()

	blk := make([]runtime.BlockProfileRecord, maxRecords)
	nb, _ := runtime.BlockProfile(blk)

	mtx := make([]runtime.BlockProfileRecord, maxRecords)
	nm, _ := runtime.MutexProfile(mtx)

	return Report{
		BlockEvents: int64(nb),
		MutexEvents: int64(nm),
	}
}

The four gates:

ch19/diagnose_test.go
package ch19

import (
	"runtime"
	"sync"
	"testing"
	"time"
)

func blockingWorkload(n int) func() {
	return func() {
		ch := make(chan int)
		var wg sync.WaitGroup
		wg.Add(1)
		go func() {
			defer wg.Done()
			for i := 0; i < n; i++ {
				<-ch
			}
		}()
		for i := 0; i < n; i++ {
			ch <- i
		}
		wg.Wait()
	}
}

func contendedWorkload(goroutines, iters int) func() {
	return func() {
		var mu sync.Mutex
		var wg sync.WaitGroup
		for i := 0; i < goroutines; i++ {
			wg.Add(1)
			go func() {
				defer wg.Done()
				for j := 0; j < iters; j++ {
					mu.Lock()
					time.Sleep(time.Microsecond)
					mu.Unlock()
				}
			}()
		}
		wg.Wait()
	}
}

// Gate 1: a program that blocks must be reported as blocking.
func TestDetectsBlocking(t *testing.T) {
	got := Diagnose(blockingWorkload(200))
	if !got.Blocking() {
		t.Fatalf("no blocking reported for 200 blocking sends\n"+
			"  BlockEvents=%d\n\n"+
			"  The block and mutex profiles are OFF by default and\n"+
			"  report an empty profile, not an error. An empty\n"+
			"  profile means either nothing blocked or nothing was\n"+
			"  recording, and Diagnose cannot tell the difference\n"+
			"  because it never enabled anything.", got.BlockEvents)
	}
}

// Gate 2: the magnitude must track the workload, not the stack count.
func TestMagnitudeTracksWorkload(t *testing.T) {
	small := Diagnose(blockingWorkload(200))
	large := Diagnose(blockingWorkload(800))
	ratio := float64(large.BlockEvents) / float64(small.BlockEvents+1)
	if ratio < 2.5 {
		t.Fatalf("magnitude does not scale with the workload\n"+
			"  200 sends -> %d, 800 sends -> %d, ratio %.2f "+
			"(want ~4)\n\n"+
			"  runtime.BlockProfile returns RECORDS, and a\n"+
			"  record is a unique stack. Both workloads block\n"+
			"  at the same line, so both collapse to one\n"+
			"  record however many times they block. Sum\n"+
			"  the Count field instead.",
			small.BlockEvents, large.BlockEvents, ratio)
	}
}

// Gate 3: guards the wrong fix -- do not leave profiling enabled.
func TestRestoresProfileState(t *testing.T) {
	before := runtime.SetMutexProfileFraction(-1)
	Diagnose(contendedWorkload(8, 500))
	after := runtime.SetMutexProfileFraction(-1)
	if after != before {
		t.Fatalf("Diagnose left mutex profiling enabled\n"+
			"  fraction before=%d after=%d\n\n"+
			"  SetMutexProfileFraction returns the previous rate and\n"+
			"  reads with -1, so it can be saved and restored.\n"+
			"  SetBlockProfileRate has neither: decide a\n"+
			"  policy for that half and write it down.",
			before, after)
	}
}

// Gate 4: the other overshoot -- a quiet program reports nothing.
func TestQuietProgramReportsNoBlocking(t *testing.T) {
	got := Diagnose(func() {
		x := 0
		for i := 0; i < 100000; i++ {
			x += i * i
		}
		_ = x
	})
	if got.Blocking() {
		t.Fatalf("Diagnose invented blocking in a program with none\n"+
			"  BlockEvents=%d\n\n"+
			"  Reporting unconditionally, or scaling a record\n"+
			"  count by a constant to make gate 2 pass, both\n"+
			"  fail here. The profile is cumulative and\n"+
			"  process-global: take a sample before and after\n"+
			"  and report the difference.",
			got.BlockEvents)
	}
}

Run it:

Terminal
$ cd labs/go-concurrency/code/ch19
$ go test -race ./...

Two of the four fail, the same way every time:

Terminal
--- FAIL: TestDetectsBlocking (0.00s)
    diagnose_test.go:51: no blocking reported for 200 blocking sends
          BlockEvents=0
          The block and mutex profiles are OFF by default and
          report an empty profile, not an error. An empty
          profile means either nothing blocked or nothing was
          recording, and Diagnose cannot tell the difference
          because it never enabled anything.
--- FAIL: TestMagnitudeTracksWorkload (0.01s)
    diagnose_test.go:67: magnitude does not scale with the workload
          200 sends -> 0, 800 sends -> 0, ratio 0.00 (want ~4)
          runtime.BlockProfile returns RECORDS, and a
          record is a unique stack. Both workloads block
          at the same line, so both collapse to one
          record however many times they block. Sum
          the Count field instead.
FAIL
FAIL corebackend.dev/go-concurrency/ch19 0.524s
Done when: go test -race ./... in code/ch19/ reports ok for all four, and keeps reporting it under -count=10.

Three fixes, and they are ordered rather than independent. This was verified by applying each alone, and the result is more interesting than the usual pair.

Enabling the profilers alone makes gate 1 pass — and breaks gate 4, which was passing. That is not a regression in your code; it is the profilers finally recording, and the fourth gate discovering that runtime.BlockProfile returns everything accumulated since profiling was enabled, process-wide. The quiet workload now inherits every record the earlier tests produced. Summing Count alone fixes nothing at all, because with the profilers off there is nothing to sum.

So the fix has three parts and they have to arrive in order: enable the profilers, sum the Count field rather than counting records, and difference a sample taken before the workload against one taken after. Miss the third and the diagnostic reports every previous run’s contention as though it belonged to this one — which is the single most common bug in real in-process diagnostics, and the reason gate 4 exists.

The third gate is the guard worth understanding. Having found that the profilers are off, the natural fix is to switch them on and leave them on, which gate 3 fails. It is also where the API asymmetry from §19.3.5 bites: SetMutexProfileFraction returns the previous value and reads with -1, so the mutex rate can be restored exactly. SetBlockProfileRate returns nothing and has no getter, so it cannot. The gate asks you to restore what can be restored and to have decided, deliberately, what to do about the half that cannot.

Where the files are: labs/go-concurrency/code/ch19/. A worked answer sits in solution/diagnose.go.txt, including why the before/after sample has to bracket the workload rather than the whole function, and why a diagnostic that enables profiling temporarily is a different design from a process that enables it once at startup — which is what most services should actually do.

Further Reading

Next

You can now pick an instrument instead of reaching for the nearest one: metrics to ask whether anything is wrong, a dump to ask where everyone is, a profile to ask how long and blamed on whom, and the tracer for the one question only it answers — in what order. You know which two profiles are off by default and report health while they are, why the labels you added were missing from the dump you get and what 1.27 did about it, and why the debugger is the weakest tool here rather than the strongest. What none of it tells you is what to change. Chapter 20 is that step: reading a profile as a decision rather than a picture, and what the changes actually buy.