Chapter 21: Scheduler and Runtime Internals

Twenty chapters have taught you to write concurrent Go by taking the runtime at its word. Goroutines are cheap. Blocking is free. GOMAXPROCS bounds parallelism. Channels synchronise. Every one of those is true, and every one of them is a budget that the runtime spends on your behalf — and this chapter is about the places where the budget runs out.

That framing is not decoration. It is the only honest answer to the question an internals chapter has to earn its way past: why should you care? Not because the machinery is elegant, though it is. Because the machinery is what decides where the abstraction stops being free, and you have already met five places where it did, printed on pages of this book, unexplained.

FIVE THINGS THIS BOOK HAS ALREADY PRINTED

Five artifacts from earlier chapters, unexplained. One: Chapter 19's schedtrace line showing gomaxprocs 16, idleprocs 0, runqueue 60 and per-P depths of 14, 3, 14, 15, 2, 4, 0, 35, 34 and then zeros, where two Ps hold sixty-nine goroutines between them and one holds none. Two: a schedtrace line with gomaxprocs 16 and threads 19, which Chapter 1 promised to explain here. Three: Chapter 2 says a goroutine costs about two kilobytes and section 14.4.4 measured 2.7 kilobytes, and both are right. Four: Chapter 19 found a goroutine the runtime could not stop, and Chapter 10's deadlock-detector table has no row for it. Five: Chapter 5 called an unbuffered channel a synchronisation point and Chapter 19 found channel operations in the mutex profile.

None of those is a bug. Each is a place where a rule this book gave you stops predicting what the machine does, and where the next question can only be answered one level down.

WHAT THE BUDGET BUYS, AND WHERE IT RUNS OUT

Six lines split by a rule. Above it, the things that are free or nearly so: blocking on a channel costs nothing and uses direct handoff and a sudog; blocking on a socket costs nothing and uses the netpoller; creating a goroutine costs about two kilobytes on a growable, pooled stack. Below the rule, where the budget runs out: blocking in a syscall costs one whole thread; a loop with no calls was, until Go 1.14, never preempted; and GOMAXPROCS is no longer a constant. The first three are why Go feels the way it does; the last three are this chapter.

What you’ll learn
  • The five artifacts above, explained — and two of them are corrections to pages this book has already shipped
  • G, M and P organised by the question that matters: which one do you run out of, and what happens when you do
  • Why work stealing does not balance load, and why the uneven run queues Chapter 19 showed you were fine
  • What one environment variable does to a tight loop, and what Go 1.14 actually bought
  • Why five hundred goroutines blocked on sockets cost one thread and sixty-four blocked in a syscall cost sixty-two
  • What a goroutine really costs, and why Chapter 2's number and Chapter 14's number are both correct
  • Why GOMAXPROCS is no longer a constant, and why the advice this book gave you about sizing pools is wrong in a container
What we’re not covering
  • The GMP model as an introduction — §1.4 owns that, names all three, and this chapter assumes it. What follows demonstrates, extends, and in two places corrects
  • Goroutine states and scheduler observability — §2.4, §19.2.1 and §19.4. This chapter borrows their artifacts rather than re-teaching their instruments
  • The netpoller’s own mechanism — §1.4 explains epoll, kqueue and IOCP. §21.4 is about the price of the other branch
  • Garbage collection beyond where it stops the world (§19.4.4). The collector is a book
  • Performance decisions — Chapter 20. This chapter owns mechanism and the numbers that fall out of it; Chapter 20 owns what to do about them
Building toward

Chapter 1 introduced the runtime, named this chapter once, and deferred two questions to it without naming it. Chapter 19 taught you to observe the scheduler and left several observations unexplained. This chapter closes both. Chapter 20 takes the mechanism here and turns it into decisions; Chapter 22 puts all of it to work.

Prerequisites

§1.4 and §1.5 throughout — the GMP names, why goroutines are lightweight, the netpoller, and GOMAXPROCS. §2.4's goroutine states and §2.5's cost model. §5's buffered-versus-unbuffered argument, which §21.6 finally supplies a mechanism for. §14.4.4's measured 2.7 KB. §19.4's runtime/metrics habit and its GODEBUG=schedtrace output, which §21.1 turns up to scheddetail. And §10.4's deadlock-detector table, to which §21.5 adds a missing row.

Which Go are we on?

Every figure was run on go1.26.1; every source citation was read from go1.27.1’s $GOROOT/src/runtime (Go 1.27 changed nothing in the scheduler, preemption or GOMAXPROCS, so the figures stand). Two things changed recently enough to invalidate most of what is written elsewhere. Go 1.14 introduced asynchronous preemption, which §21.5 demonstrates by turning it off. And Go 1.25 changed what GOMAXPROCS defaults to — it is now container-aware, it updates while your program runs, and the behaviour is gated on the go line in your own go.mod. §1.5 has already absorbed the first half of that and ends at “on a current toolchain, do nothing”; what it does not carry is the go.mod gating, or the fact that setting the value yourself now opts you out of the updating. §21.2 has both, and §21.7 has the two places where this book’s other advice did not survive the change.

Measured go1.26.1, darwin/amd64, Intel i7-10700K, GOMAXPROCS=16 unless a listing says otherwise. Source citations are file and line in $GOROOT/src/runtime at go1.27.1, read rather than recalled. Benchmark tables report the minimum of repeated runs — the least-perturbed sample — with the repeat count named at the table, because this machine had other work on it and a mean would report that work rather than the code.

Two cautions specific to this chapter. Thread and scheduler counts are machine- and OS-dependent, so the ratio is the finding and the absolute number is one machine’s expression of it — where a figure below says 67 threads, an independent run said 67 and another said 66, and none of that changes the argument. And several constants here are implementation details that have changed and will change again. The 256-entry queue, the 61-tick valve, four steal attempts, ten milliseconds: all true today, none of them promises. §21.7 says which parts are durable, and it is a short list.

21.1 Five Things You Have Already Seen

21.1.1 The Artifacts

This chapter does not open with a diagram of the scheduler, because you have already seen one in §1.4 and it did not let you answer any of the following.

One. The uneven run queues. §19.4.3 printed per-processor queue depths under GODEBUG=schedtrace and observed that processors 7 and 8 held 35 and 34 goroutines while processor 6 held none. It called that “work that is not being stolen effectively”. That explanation is wrong, and §21.3 replaces it — along with the belief about work stealing that made it seem right.

Two. More threads than processors. A schedtrace line reads gomaxprocs=16 … threads=19. Chapter 1 raised exactly this and deferred it — §1.4 notes that the runtime “may create additional OS threads when goroutines block on syscalls” and hands the detail to §1.5, and §1.3 says that when a goroutine blocks a thread “the runtime detects this and creates an additional thread so other goroutines keep running.” Neither says how many, which kinds of blocking count, or whether the threads come back. §21.4 answers all three, and the gap gets much wider than three.

Three. Two different costs for one goroutine. §2.5 says a goroutine starts with about 2 KB of stack. §14.4.4 measured 2.7 KB apiece for ten thousand parked goroutines. Both numbers are correct and they are measuring different things; §21.6 says which to use when.

Four. A goroutine that would not yield. §19.4.4 described a goroutine the runtime could not stop, and §10.4's table of what does and does not trigger the deadlock detector has rows for channels, mutexes, WaitGroup, select, sleeps and I/O — and no row for a goroutine that is simply running. §21.5 adds the row.

Five. A channel in the mutex profile. §5 taught that an unbuffered channel is a synchronisation point. §19.3.3 found channel operations attributed in the mutex profile. §21.6 explains why both are true, and corrects a plausible-sounding conclusion about channel performance along the way.

WHAT WAS PRINTED, AND WHAT EXPLAINS IT

Five boxes, each an artifact from an earlier chapter, each with an arrow to the section that explains it. Uneven per-P queues from section 19.4.3 go to section 21.3, where the explanation the book gave turns out to be wrong. Threads nineteen against gomaxprocs sixteen goes to 21.4, which Chapter 1 promised. Two kilobytes against 2.7 kilobytes goes to 21.6, where both are correct. A goroutine that would not yield goes to 21.5, which adds a row missing from section 10.4's table. And a channel in the mutex profile goes to 21.6, because a channel is a lock.

21.1.2 The Test This Chapter Applies to Itself

An internals chapter can go wrong in one specific way: it becomes a tour of runtime source that the reader admires and never uses. The defence is a rule, stated up front and applied throughout:

An internals fact earns its place only if it explains an observation or changes a decision.

Everything else is source-code tourism. Each of the five artifacts above is an observation; §21.7 is the decision. Anything in this chapter that is neither should not be here, and if you catch some, the rule is doing its job and the author was not.

That is also the honest answer to “when does internals knowledge matter?” — which your instinct probably files as the last section of a chapter like this. It matters at the boundaries where the abstraction stops being free, and every section below is one of those boundaries.

21.1.3 The Instrument

Chapter 19 used GODEBUG=schedtrace and mentioned its louder sibling in a single clause. That sibling is this chapter’s instrument, because it prints the model rather than describing it.

Measured GODEBUG=schedtrace=100,scheddetail=1, eight CPU-bound goroutines, GOMAXPROCS=16, second interval. Long lines are wrapped and [...] marks fields elided to fit the page; nothing else is edited, and the real output is one line per P and per M with no wrapping at all:
WHAT scheddetail PRINTS

One block of GODEBUG scheddetail output. The header line reports gomaxprocs sixteen, idleprocs seven, threads twelve, one spinning thread, one idle thread, an empty global run queue and sysmonwait false. Then one line per processor: P0 running with schedtick nine and no queued work, P1 idle with no M, the middle processors elided, and P15 running with one timer. Then three Ms: M11 spinning with P7, M10 running goroutine 17 on P8, and M8 with no P and blocked. Then goroutine 1 in state four, sleeping. Every field in it is explained by a section of this chapter.

Read that block and you have the chapter’s table of contents. Every number in it is explained by a section below — including the two that look odd: threads=12 against gomaxprocs=16 (§21.4), and one processor owning the only pending timer in the process while fifteen own none (§21.3.5).

What each field becomes
gomaxprocs, idleprocs
§21.2 — what a P is, and how many you have
runqsize, runqueue
§21.3 — local queues and the global one
threads, spinningthreads
§21.4 — where threads come from
syscalltick
§21.4 — the P being retaken from a syscall
schedtick
§21.3 and §21.5 — scheduling decisions and the 61st
gfreecnt
§21.6 — why creating a goroutine is fast
sysmonwait
§21.4 — the thread with no P
timerslen
§21.3.5 — where timers live, and why on the P

Two of those are also available as always-on counters, which is §19.4's habit applied here: /sched/threads/total:threads reports the live OS thread count and /sched/gomaxprocs:threads reports the current GOMAXPROCS — both from runtime/metrics, both for a counter read, and the second is a value that now moves (§21.2).

Run it against a built binary, not go run.

The toolchain is itself a Go program, so GODEBUG=schedtrace=... go run . prints the compiler’s and linker’s scheduler lines interleaved with your program’s — several processes' worth of SCHED 0ms in one stream, with # package markers between them. Nothing warns you. go build -o prog . && GODEBUG=... ./prog is the form that means what it looks like.

scheddetail=1 is a development instrument.

It writes a block per P and per M to standard error on a fixed interval, forever, and cannot be turned off without a restart. On a 64-P machine that is a wall of text every interval. Its value is that it needs nothing — no import, no endpoint, no code change — so it is the fastest way to see the model on a binary you already have. Look once, learn the shape, then go back to runtime/metrics.

21.1.4 How to Read the Source Without Drowning

Every claim in this chapter is cited to a file and a line, and you should check them — not out of suspicion, but because the checking is the skill, and it outlasts any particular fact.

Four habits make $GOROOT/src/runtime tractable.

Start from a symbol, not a file. proc.go is over seven thousand lines and reading it in order teaches nothing. Start from something you already know exists — findRunnable, stealWork, newproc, chansend — and follow only what it calls.

Find the callers before reading the function. A function’s meaning is mostly its call sites. grep -n "stealWork(" proc.go returns two lines: the declaration and one call. That one fact — stealing has a single caller, and it is inside the function a P enters when it has nothing to do — is the whole of §21.3.2, and it took a grep rather than a reading.

**The comments are unusually good, and they explain the why.** The runtime is commented for people who will maintain it, so the interesting comments are the ones justifying a choice rather than describing an action. The 61-tick check in §21.3.1 carries a comment naming the exact starvation it prevents; the n > s.size branch of a package in Chapter 17 carried one explaining why a doomed acquire is parked outside the queue. Those are the sentences worth reading twice.

Check the version, always. Go’s runtime changes, and it changed twice recently in ways that invalidate most of what is written about it elsewhere: preemption in 1.14, GOMAXPROCS in 1.25. A file-and-line citation is only true for the toolchain it was read on, which is why every one in this chapter names go1.27.1.

A negative grep is not evidence.

While preparing this chapter a search for runq \[ returned nothing and appeared to disprove a correct citation — the field is declared runq [256]guintptr, with alignment padding the pattern did not allow for. If a grep fails to find something you have good reason to believe is there, suspect the pattern before the belief. This is the same discipline §19.3.1 applied to an empty profile: an instrument reporting nothing is ambiguous between “nothing is there” and “you asked wrongly”.

21.1.5 Common Mistakes

Learning GMP as a diagram
Problem

Recall without predictive power

Fix

Learn it as scarcity: which do you run out of?

Reading blog posts about the scheduler
Problem

Pre-1.14 preemption, pre-1.25 GOMAXPROCS

Fix

Read $GOROOT/src/runtime at your version

Memorising constants
Problem

61, 256, 4, 10 ms — all changeable

Fix

Learn the shape; §21.7 lists what is durable

Treating internals as prerequisites
Problem

Twenty chapters written without them

Fix

They are for the boundaries, not the basics

Assuming more threads means a bug
Problem

Panic at threads > GOMAXPROCS

Fix

§21.4; it is usually correct behaviour

scheddetail=1 in production
Problem

Unbounded stderr, no way to stop it

Fix

Development only; metrics in production

Summary: Five Things You Have Already Seen

This chapter opens with five artifacts printed in earlier chapters and left unexplained: §19.4.3's uneven per-P run queues and its wrong explanation of them, threads=19 against gomaxprocs=16, which Chapter 1 raised and deferred, §2.5's 2 KB against §14.4.4's 2.7 KB, §19.4.4's goroutine that would not yield against a row missing from §10.4's table, and channel operations appearing in §19.3.3's mutex profile.

The test the chapter applies to itself is that an internals fact earns its place only if it explains an observation or changes a decision. That is also the answer to when internals knowledge matters: at the boundaries where the abstraction stops being free.

The instrument is GODEBUG=scheddetail=1, which prints the model rather than describing it — every field in its output is a section of this chapter, and two of them are also always-on runtime/metrics counters.

Self-Check Questions: Five Things You Have Already Seen

You are asked to justify spending a week on runtime internals to a sceptical team lead. What is the argument, and what is not the argument?

The argument is that internals knowledge is what lets you interpret observations you can already collect and decisions you are already making — and that without it, those observations get interpreted wrongly.

Concretely: your team is already looking at goroutine counts, thread counts and scheduler metrics, because Chapter 19 put them on a dashboard. Someone will eventually see threads=200 on a 16-core box and file a bug, or see uneven per-P queues and try to “fix” the imbalance, or size a worker pool from NumCPU() in a container and over-provision it thirty-two-fold. Every one of those is an internals question wearing an operations costume, and getting it wrong costs more than a week.

What is not the argument is mastery, depth, or completeness. “Understanding the scheduler” is not a deliverable, and a week spent reading proc.go end to end produces recall rather than judgement. The book has spent twenty chapters demonstrating that you can write correct, fast concurrent Go without any of this.

The honest framing is that this is a boundary skill. It is worth exactly what it lets you decide, and the test in §21.1.2 — does this explain an observation or change a decision — is the one to apply to any individual fact, including every fact in this chapter.

Why does this chapter open with observations rather than with the GMP model?

Because the model was already introduced, in §1.4, and introducing it again would teach nothing.

More importantly, a model presented without the observations it explains is unfalsifiable and unmemorable. “There are Gs, Ms and Ps” is a fact you can recite and cannot use. “Two processors held 69 goroutines between them and one held none, and here is why that is correct behaviour rather than a bug” is a fact that changes what you do the next time you see a schedtrace line.

There is also a structural reason. The five artifacts are all things this book printed — two of them with explanations that turn out to be wrong. A chapter that opens by naming its own errors has established that it is doing something other than restating §1.4, and it gives every subsequent section a specific question to answer rather than a general topic to cover.

The risk this guards against is the one §21.1.2 names outright: an internals chapter drifting into source-code tourism, where the material is interesting, correct and useless. Anchoring each section to an observation is the defence.

scheddetail=1 shows gfreecnt=12 on one processor. What does that field tell you, and what would a large value across all processors suggest?

It is the count of free g structures cached on that processor — goroutines that have finished, whose descriptor and stack have been kept rather than returned, so the next go statement on that P can reuse them.

That is the missing half of “goroutine creation is cheap” (§21.6). The 2 KB stack allocation everyone quotes is the cost when there is nothing to reuse; when there is, creation is closer to taking an object off a free list, and the stack comes with it already the right size.

A large value across every processor tells you the program has recently finished a great many goroutines — a burst that has drained. That is not a leak and not a problem; it is the runtime holding capacity in anticipation of the next burst, which is the same bet sync.Pool makes (§12.3) with different lifetime rules.

What it does not tell you is anything about goroutines currently alive, which is /sched/goroutines (§19.4.2). Confusing the two is easy, because both are counts of goroutine-shaped things on a per-P structure — one is what is running, the other is what has finished and been kept.

Key Takeaways

  • Five artifacts from earlier chapters open this one, and two of them are corrections to pages already shipped
  • An internals fact earns its place only if it explains an observation or changes a decision
  • That test is also the answer to “when do internals matter” — at the boundaries where the abstraction stops being free
  • GODEBUG=scheddetail=1 prints the model rather than describing it, and every field is a section here
  • /sched/threads/total and /sched/gomaxprocs are the always-on halves of the same view
  • scheddetail is a development instrument: unbounded output, no way to stop it without a restart
  • Constants in this chapter are implementation details; the shape and the observables are what last
Section 21.1 — in one line

You have already collected the observations this chapter explains, and two of the explanations you were given for them were wrong.

21.2 G, M, P — and Which One You Run Out Of

§1.4 names all three. This section is about the question the names do not answer, which is the one that matters in production: there are three resources here, they are scarce in wildly different degrees, and running out of each one looks completely different.

21.2.1 Three Resources, Three Ceilings

G
P
M
THREE RESOURCES, THREE CEILINGS

The three scheduler resources stacked by what happens when you run out. G, goroutines, run to millions and the book has said to spend them freely; each needs a P to run. P, processors, is bounded by GOMAXPROCS, queueing there is normal, and running out is what a scheduler is for; each needs an M to execute. M, threads, stops at ten thousand, and running out there is a fatal error: thread exhaustion.

Read that last column as the whole point of the table. Running out of Gs is a memory problem you can see coming on a graph. Running out of Ps is what a scheduler is for — it is the normal operating condition of a busy service, and §21.3 is about how the runtime handles it. Running out of Ms is not a degradation at all.

Measured with the limit lowered from its default to make the demonstration cheap:
prev_212.go
// Illustrative snippet — not a complete program
prev := debug.SetMaxThreads(20) // default is 10,000
// ... 64 goroutines each entering a blocking syscall
Terminal
max threads was 10000, now 20
runtime: program exceeds 20-thread limit
fatal error: thread exhaustion

Not an error return, not backpressure, not a slow degradation: fatal error, and the process is gone. The documentation is blunt about it — “If it attempts to use more than this many, the program crashes” — and it names the three causes in the same paragraph: goroutines “blocked in system calls, cgo calls, or are locked to other goroutines.” That list is §21.4.

The asymmetry is the design.

Gs are meant to be spent freely; the entire book has told you to create them without ceremony, and that advice is correct. Ms are the opposite: the runtime works hard to need as few as possible, and the number it needs is decided almost entirely by what your goroutines block on. The middle resource, P, exists to keep the other two apart.

21.2.2 Why P Exists At All

The G and the M are the obvious two: the work, and the thing that can run work. If that were the whole design you would have a scheduler with a queue of goroutines and a pool of threads pulling from it — which is exactly what Go had before version 1.1, and it did not scale.

The problem is the queue. One queue shared by every thread needs one lock, and that lock is taken on every scheduling decision — every channel operation that blocks, every goroutine that finishes, every go statement. At four threads it is contended. At sixty-four it is the program.

P is the fix, and it is a simple one: make the queue local. A P owns a run queue that only it touches in the common case, so the scheduling decision that used to take a global lock now takes none at all. The M has to hold a P to run Go code, which is what bounds parallelism — and the count of Ps is GOMAXPROCS, which is why that variable controls parallelism rather than thread count.

That single idea explains the whole of §21.3. Every mechanism there — the local queue, the fast slot in front of it, stealing, the periodic check of the global queue — exists because the queue was made local and the consequences had to be managed.

GOMAXPROCS has never been a thread limit

, and §1.5 is careful about this. It is the number of Ps, so it bounds goroutines executing Go code simultaneously. Threads are a separate resource with a separate ceiling, and §21.4 is about the gap. If you have ever set GOMAXPROCS expecting your thread count to follow, this is why it did not.

21.2.3 How Many Ps You Have, and Who Decided

Here is the most consequential fact in this chapter, and the one most likely to be stale in whatever you read before this.

The rule everyone learned is that GOMAXPROCS defaults to runtime.NumCPU() — the number of logical CPU cores. §1.5 has already retired that: it prints GOMAXPROCS = min(logical CPUs, affinity mask, cgroup quota) for Go 1.25 and later, and ends at “on a current toolchain, do nothing.”

The old rule has not been true since Go 1.25, and the documentation is where the precise version lives. go doc runtime.GOMAXPROCS on go1.27.1 says the runtime “selects an appropriate default value from a combination of the number of logical CPUs on the machine, the process’s CPU affinity mask, and, on Linux, the process’s average CPU throughput limit based on cgroup CPU quota, if any.”

Three inputs, not one. And three consequences that follow from it, each verified in the same documentation.

It is container-aware now. A process in a cgroup with a 2-CPU quota on a 64-core host gets 2, not 64. That is the problem §1.5 spends a diagram on and recommends a third-party library for — and the runtime now solves it.

It is gated on your own go.mod. GODEBUG=containermaxprocs=0 restores the old NumCPU behaviour, and “is default for language version 1.24 and below”. Two engineers running identical source on identical toolchains get different defaults if their modules declare different language versions. This is the detail most likely to waste an afternoon, and it is the one thing here you can check in one command — the gating is baked into the binary and go version -m prints it:

THE GATING IS BAKED INTO THE BINARY

The same program built twice, inspected with go version -m. Built from a module declaring go 1.24, the binary carries a DefaultGODEBUG line setting containermaxprocs to zero and updatemaxprocs to zero — the pre-1.25 behaviour. The list is longer on a go1.27 toolchain — a go 1.25 module also gets cryptocustomrand=1, tlssecpmlkem=0, tracebacklabels=0, urlstrictcolons=0 and x509sslcertoverrideplatform=0 — and asynctimerchan, which 1.27 removed, is no longer in it. Built from a module declaring the toolchain’s own language version — go 1.27 on go1.27 — there is no DefaultGODEBUG line at all. Same source, same toolchain, one go.mod line apart.

Measured the same source, the same toolchain, one line of go.mod apart. A DefaultGODEBUG naming containermaxprocs=0 means that binary is running the pre-1.25 behaviour no matter what toolchain built it.

It moves while your program runs. “The Go runtime periodically updates the default value based on changes to the total logical CPU count, the CPU affinity mask, or cgroup quota.” If your container’s quota is raised or lowered — by an operator, by a vertical autoscaler, by a noisy-neighbour policy — GOMAXPROCS follows, without a restart.

And then the sharp edge, which turns §1.5's advice into a trap:

Setting GOMAXPROCS explicitly disables the automatic updates.

From the same documentation: “Setting a custom value with the GOMAXPROCS environment variable or by calling GOMAXPROCS disables automatic updates.” So the decade-old standard fix — set it at startup, or import go.uber.org/automaxprocs — now opts your process out of a runtime feature that does the job properly. §1.5 already says to stop doing it; what it does not say is what the opt-out costs, which is every subsequent quota change. runtime.SetDefaultGOMAXPROCS() is the way back, and it also forces an immediate re-read if you know the quota has changed.

§1.5 keeps two cases where you still set it yourself — CPU shares with no quota, where there is nothing for the runtime to read, and a deliberate value below the quota. Both are now decisions to take knowingly rather than by default, and §21.7 is about the advice that did not survive the change.

21.2.4 Reading the Value Without Changing It

There is one more asymmetry, and it is in the same shape as a finding from Chapter 19.

runtime.GOMAXPROCS both reads and writes, distinguished by the sign of its argument. Measured, from $GOROOT/src/runtime/debug.go:

gomaxprocs_212.go
// Illustrative snippet — not a complete program
func GOMAXPROCS(n int) int {
    lock(&sched.lock)                 // :75
    ret := int(gomaxprocs)
    if n <= 0 {
        unlock(&sched.lock)
        return ret                    // :77-79 — returns FIRST
    }
    sched.customGOMAXPROCS = true     // :83
    unlock(&sched.lock)
    // ... then the no-op check at :90, and the STW at :96
    return ret
}

GOMAXPROCS(0) is a pure read. The n <= 0 branch returns before the flag is set, so querying the value does not disable automatic updates. That is worth knowing precisely because the write path does.

If that pattern feels familiar it is because §19.3.5 found the same shape in a different package: SetMutexProfileFraction(-1) reads the current rate without setting it, and SetBlockProfileRate offers no reader at all. One function, two behaviours, selected by a sentinel argument — a convention the standard library uses more often than it documents.

But look at line 75 again. Reading GOMAXPROCS takes sched.lock — the one global lock the entire P design exists to avoid. It is cheap in absolute terms and it does not scale:

Measured minimum of five runs, with an empty benchmark loop included as the floor:
Call
empty loop (acc += i) — the floor
runtime.NumCPU()
runtime.GOMAXPROCS(0)
runtime.GOMAXPROCS(0), 16-way parallel

NumCPU does not register above the floor at all, and the floor row is why it is printed. NumCPU is return int(numCPUStartup) (debug.go:154-156) — one load of a variable the runtime sets at process startup, inlined into the caller. Dividing the middle row by it would produce a confident ratio out of the benchmark harness’s own loop overhead, so the honest statement is the qualitative one: reading NumCPU is free, and reading GOMAXPROCS(0) is a lock acquisition costing about 13 ns.

It also gets worse under parallelism — nearly threefold at 16-way — which is the signature of a global lock and the exact pathology P was introduced to remove.

Derived at ~13 ns serial, a hot path calling GOMAXPROCS(0) once per item costs about 13 ms per million items — negligible. At 16-way concurrency it is ~36 ns and rising with contention, which is enough to matter on a path that does little else. The rule that follows is §21.7's: read it where the width is decided, not where the work is done.

21.2.5 Changing It Stops the World

One more fact about the write path, and it is sharper than the lock.

Setting GOMAXPROCS is not a variable assignment. Measured, from $GOROOT/src/runtime/debug.go:

stw_212.go
// Illustrative snippet — not a complete program
if n == ret {                        // :90
    // sched.customGOMAXPROCS set, but no need to actually STW
    // since the gomaxprocs itself isn't changing.
    return ret                       // :93
}

stw := stopTheWorldGC(stwGOMAXPROCS) // :96
newprocs = int32(n)
startTheWorldGC(stw)                 // :104

Changing the number of processors stops the world. Every goroutine halts, procresize adds or removes Ps — moving any work stranded on a removed P to the global queue — and the world restarts. SetDefaultGOMAXPROCS does the same at :135.

The source says why it cannot be done live, at proc.go:6077: “gcworkbufs must not be being modified by either the GC or the write barrier code, so the GC must not be running if the number of Ps actually changes.”

Two consequences, and note how the first composes with §21.2.4's finding.

A same-value pin costs no pause but still sets the flag. Line 90 returns before the stop-the-world — so pinning to the value you already had is free in wall-clock terms and permanent in effect. That is the worst possible combination for detection: nothing to measure, and automatic updates gone.

And GOMAXPROCS(n) on a hot path is far worse than it looks. It is not a lock acquisition; it is a stop-the-world pause. The runtime’s own automatic updates pay this too, which is part of why they are rate-limited and skipped when the value has not moved — a container whose CPU limit is adjusted repeatedly buys a pause each time it actually changes.

21.2.6 Common Mistakes

Treating GOMAXPROCS as a thread limit
Problem

Thread count ignores it entirely

Fix

It counts Ps; threads are §21.4

Believing GOMAXPROCS defaults to NumCPU
Problem

Wrong width in a container, either way

Fix

Container-aware since 1.25; check your go.mod

Setting GOMAXPROCS to “pin” it
Problem

Automatic updates silently disabled

Fix

SetDefaultGOMAXPROCS() restores them

Reaching for automaxprocs today
Problem

A dependency the runtime made redundant

Fix

Check the language version in go.mod first

Assuming identical binaries behave identically
Problem

Different defaults from different go.mod

Fix

containermaxprocs is gated on language version

GOMAXPROCS(0) in a hot loop
Problem

A global lock on your fastest path

Fix

Read it where width is decided

GOMAXPROCS(n) in a hot loop
Problem

A stop-the-world pause per call

Fix

It resizes the world; read with (0)

Expecting thread exhaustion to degrade
Problem

fatal error, no recovery

Fix

It is a crash; §21.4 is about avoiding it

Summary: G, M, P — and Which One You Run Out Of

Three resources with three wildly different ceilings. Gs are millions and running out is a memory problem. Ps are GOMAXPROCS and running out is the normal condition of a busy service. Ms top out at 10,000 and running out is fatal error: thread exhaustion — demonstrated by lowering the limit to 20 and watching the process die.

P exists to make the run queue local. Before Go 1.1 one global queue meant one global lock on every scheduling decision, and that is what did not scale. Every mechanism in §21.3 follows from making the queue local and managing the consequences.

GOMAXPROCS is container-aware since Go 1.25 — derived from logical CPUs, the affinity mask and the cgroup quota — it updates while the program runs, and its behaviour is gated on the language version in your own go.mod. That retires the NumCPU rule everyone learned, and makes setting the value yourself a trap rather than a fix, because doing so disables the automatic updates that now do the job.

And GOMAXPROCS(0) is a pure read that nonetheless takes the global scheduler lock: 12.8 ns serial and 36.3 ns at 16-way, against a NumCPU that does not measure above an empty benchmark loop.

Self-Check Questions: G, M, P — and Which One You Run Out Of

Your service runs in a 2-CPU container on a 64-core host. A colleague reports that runtime.NumCPU() returns 64 and runtime.GOMAXPROCS(0) returns 2, and asks which is the bug.

Neither is a bug. They answer different questions, and the difference is exactly what changed in Go 1.25.

NumCPU reports the number of logical CPUs usable by the process as queried from the operating system at startup. On a host with 64 cores that is 64, because the cgroup quota is a throughput limit rather than a restriction on which CPUs exist. It is a hardware question with a hardware answer.

GOMAXPROCS(0) reports how many Ps the runtime has decided to run, which since 1.25 accounts for the cgroup quota. In a 2-CPU container it is 2, because that is how much CPU the process can actually get.

The one that is almost always wrong for your purposes is NumCPU, and that is the trap: it is the older, more familiar call, it reads like the obvious thing to ask, and in a container it is thirty-two times too large. Anything you size from it — a worker pool, a semaphore, a batch width — is over-provisioned by that factor from the first second the process runs. §21.7 is about the fact that this book recommended exactly that in two chapters.

Worth checking as a follow-up: what language version the module declares. GODEBUG=containermaxprocs=0 is the default at 1.24 and below, and under it GOMAXPROCS also returns 64 — so the colleague’s observation depends on the go.mod, not only on the toolchain.

Why does a local run queue per P require more machinery than one global queue, and why is it still the better design?

Because a global queue is trivially correct and does not scale, while local queues scale and create three problems that then need solving.

The global design needs one lock. Every go statement, every unblocked goroutine, every scheduling decision takes it. That is simple and it is the bottleneck: the more processors you add, the more contention on the one structure they all need.

Making the queue per-P removes the lock from the common path — a P pushing and popping its own queue touches nothing shared. But it creates the problems §21.3 is about. A P can run dry while another has work, so you need stealing. A local queue has to be bounded or it defeats the purpose, so you need an overflow path to a global queue. And once there is a global queue that only gets drained when locals are empty, work there can starve, so you need a periodic check.

That is three mechanisms to replace one lock, and it is still the better trade because the three mechanisms run rarely and the lock ran constantly. Stealing happens only when a P has nothing to do; overflow only when a queue exceeds its bound; the global check on a fixed fraction of scheduling decisions.

The general shape recurs throughout this book: §17's per-key limiters and §19.3.5's per-P profile state make the same bargain. Sharding removes contention and adds bookkeeping, and it wins whenever the bookkeeping is off the hot path.

A service sets GOMAXPROCS from an environment variable at deploy time to make its behaviour predictable. On Go 1.27, what has it actually done?

It has pinned the value and disabled the runtime’s automatic updates, which is more than it intended and probably the opposite of what “predictable” meant.

Setting the environment variable is one of the two documented ways to opt out: “Setting a custom value with the GOMAXPROCS environment variable or by calling GOMAXPROCS disables automatic updates.” From then on the runtime will not re-read the CPU count, the affinity mask, or the cgroup quota, whatever happens to them.

In a static environment that is harmless and the value would not have moved anyway. In a container platform it is a real loss. If the quota is raised the process cannot use the extra capacity; if it is lowered the process keeps scheduling as though it had the old allowance, which produces exactly the throttling and latency the operator was trying to relieve.

The deeper problem is that the value the deploy pins is chosen by a human reading a manifest, and the value the runtime would compute is read from the kernel. Only one of those is right after the manifest changes.

If pinning is genuinely wanted — a benchmark, a reproduction, a workload with a known-better width — the honest form is to set it deliberately and call runtime.SetDefaultGOMAXPROCS() when you want the runtime’s judgement back. What should not happen is a team acquiring the opt-out as a side effect of an environment variable they set for a different reason years ago.

Key Takeaways

  • Three resources, three ceilings: Gs are millions, Ps are GOMAXPROCS, Ms stop at 10,000 with a fatal error
  • Measured: exceeding the thread limit prints fatal error: thread exhaustion and the process dies
  • P exists to make the run queue local; one global queue meant one global lock, which is what did not scale
  • GOMAXPROCS is container-aware since Go 1.25, updates at runtime, and is gated on your go.mod language version
  • That retires the NumCPU rule and makes setting the value yourself a trap: doing so disables the updates
  • GOMAXPROCS(0) is a pure read — the n <= 0 branch returns before the flag is set (debug.go:77-79)
  • Measured: it still takes the global scheduler lock — 12.8 ns serial, 36.3 ns at 16-way; NumCPU does not measure above an empty loop
Section 21.2 — in one line

Gs are meant to be spent, Ms are rationed on pain of death, and P exists so that the two never have to meet.

21.3 Where a Goroutine Waits

A goroutine becomes runnable — you called go, or a channel receive completed, or a timer fired. Between that moment and the moment it executes, it sits somewhere. This section is about the three places, and about a claim Chapter 19 made concerning them that turns out to be wrong.

21.3.1 Three Places, in Priority Order

NEW OR READIED GOROUTINE

Where a new or readied goroutine goes, as three boxes in a chain. First runnext, one slot per P, checked first. A goroutine displaced from it falls to the local run queue, 256 entries per P and lock-free for its owner. When that is full, half of it moves to the global run queue, which is unbounded and has one lock.

Read in $GOROOT/src/runtime at go1.27.1:

Structure
runqhead / runqtail
runq [256]guintptr
runnext guintptr
Overflow batch is len(runq)/2 + 1
Global-queue check every 61st tick
const stealTries = 4

runnext is a single-goroutine fast path, and it exists for one pattern in particular. When goroutine A sends on a channel and unblocks goroutine B, B goes into A’s runnext — so the very next scheduling decision on that P runs B, without touching a queue. That is what makes a request/response handoff between two goroutines fast: the value is copied straight into B’s frame (§21.6.4) and B is then the next thing scheduled, with no queue and no lock between the two events.

The local queue is 256 entries and is accessed without a lock by its owning P. That bound is the point: an unbounded local queue would let one P hoard work that other Ps could be running.

Overflow moves half, not one. When the local queue is full, runqputslow moves a batch of len(runq)/2 + 1128 goroutines taken from the 256-entry queue, plus the one being pushed — to the global queue in a single lock acquisition. Moving one at a time would mean taking the global lock on every subsequent go statement; moving half amortises it and leaves the P with 128 to work on.

The global queue is checked on every 61st scheduling decision, whether or not the local queue has work:

snippet_213.go
// Illustrative snippet — not a complete program
// proc.go:3458
if pp.schedtick%61 == 0 && !sched.runq.empty() {

That is a starvation valve, and overflow is only half of what it protects. The other half is preemption: when sysmon stops a goroutine that has held its processor too long, that goroutine does not go back on the local queue. goschedImpl puts it on the global one (proc.go:4352), keeping it local only when the stop was for a garbage collection. So on a busy machine the global queue fills continuously with preempted goroutines, and without the 61st-tick check a processor with a permanently non-empty local queue would never look at them again. That is the loop between §21.3 and §21.5 closing: the mechanism in one section is what makes the valve in the other necessary.

The number itself is arbitrary — a prime, chosen to avoid resonating with other periodic behaviour — and it is exactly the kind of constant §21.7 tells you not to memorise.

21.3.2 Stealing Is Conservation, Not Balancing

Now the correction, and it changes what a figure printed in Chapter 19 means.

§19.4.3 showed per-P queue depths under schedtrace[14 3 14 15 2 4 0 35 34 …] — and wrote: “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.”

That is wrong, and the source says why in one line. stealWork has exactly one caller in the entire runtime:

Terminal
proc.go:3537 gp, inheritTime, tnow, w, newWork := stealWork(now)

and that call site is inside findRunnable (proc.go:3404) — the function a P enters when it has nothing to run. There is no path by which a P that is currently executing a goroutine steals anything from anyone.

So look again at the line §19.4.3 printed. It reads idleprocs=0. Every P was busy. Not one of them was in findRunnable, so not one steal was ever attempted — and the imbalance is not evidence of stealing failing, because stealing was never invoked.

Work stealing is work conservation, not load balancing.

It exists so that an idle P finds something to do rather than sitting still while work exists somewhere. It does not exist to keep queues even, it is not invoked while Ps are busy, and an uneven distribution across busy Ps is the expected steady state — costing nothing, because every P has work.

That distinction is worth holding onto because it inverts the diagnosis. Uneven queues with idleprocs=0 are fine: everyone is working. Uneven queues with idleprocs greater than zero would be the anomaly, because it would mean a P went looking and came back empty-handed while work existed — and that is a much narrower and more interesting situation.

21.3.3 Measured: The Difference Stealing Makes, and When It Cannot

The mechanism is directly observable. Two hundred short CPU-bound tasks, GOMAXPROCS=8, differing only in whether the other seven Ps are free to steal or are held by goroutines that are genuinely running:

200 tasks x 500 µs, GOMAXPROCS=8
serial lower bound
all Ps free to steal
7 Ps held busy by spinners

With every P free, the burst finishes in about an eighth of the serial time — the tasks were created by one goroutine, landed on one P’s queue, and the other seven Ps pulled them across. That is stealing working exactly as intended.

With seven Ps held by running goroutines, the same burst takes the full serial time. Nobody stole, because nobody was in findRunnable, so all two hundred tasks ran on the single P that created them.

Derived 101 ms against a 100 ms serial bound is the giveaway. It is not “stealing performed badly” — it is stealing not happening at all, which is the correct behaviour and the point of the section. A busy P does not stop working to rebalance somebody else’s queue.

An important qualification, because the first arm is easy to over-read: this is not a benchmark showing stealing is fast. It is a demonstration that stealing is pull-based by idle Ps, using the presence or absence of idle Ps as the only variable.

21.3.4 How a P Looks for Work

findRunnable is where a P goes when its own queue is empty, and its order tells you the priorities:

HOW A P LOOKS FOR WORK

The six places a P looks for work, in order. First its own timers, before anything else, at proc.go line 3430. Second its local run queue, lock-free. Third the global run queue, shared behind one lock. Fourth netpoll, for ready network goroutines, non-blocking. Fifth stealing from other Ps, four randomised passes. Sixth, park the M. Cheapest source first, and stealing is fifth.

Step 1 is easy to miss and it is the reason §21.3.5 works. pp.timers.check(0, nil) (proc.go:3430) is the first statement after the safe-point checks at the top of findRunnable — before the trace reader, before the GC worker, before the 61st-tick global check. A P looking for work looks at its own clock first.

Two details in step 5 repay knowing. The order in which other Ps are visited is randomised, so that many Ps entering findRunnable at once do not all attack the same victim. And the last of the four passes will also steal a P’s runnext and its timers — stealTimersOrRunNextG := i == stealTries-1 at proc.go:3850, a concession that only applies once the earlier, gentler passes have failed.

Step 6 is where the M parks, and it connects to §21.4: a P with no work releases its M rather than spinning forever, which is why an idle Go program does not burn CPU.

spinningthreads in a scheddetail line is this state made visible.

An M that is looking for work — steps 3 through 5 — counts as spinning, and the runtime caps spinning Ms at roughly half the busy Ps: 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() at proc.go:3532, which is the condition an M must pass before it is allowed to start hunting. Without that cap a mostly-idle program would have every thread searching at once. A persistently high spinning count means Ms are repeatedly finding nothing, which is a different problem from a high runqueue.

21.3.5 Where Timers Live

timerslen appears in every line of §21.1's scheddetail output, and no chapter of this book has said what it counts. Every chapter since Chapter 4 uses time.After, time.NewTimer or context.WithTimeout, and none of them said where the timer actually runs.

Timers live on the P. Each processor owns a heap of pending timers, and the scheduler checks it as the first thing it does when looking for work — step 1 of §21.3.4's list. There is no timer thread and no timer goroutine; a fired timer simply makes a goroutine runnable on the P that owned the timer.

That placement has three consequences worth carrying.

A timer is nearly free until it fires. Creating one is a heap insertion on the local P, with no lock in the common case and no goroutine. This is why the book has been able to recommend context.WithTimeout on every request without ever discussing cost — Chapter 13 gave it away for a reason.

Timers are stolen along with goroutines. The last of §21.3.4's four steal passes takes timers as well as goroutines. An idle P will take over another P’s pending timers rather than let them fire late, which is what stops a busy P from delaying every timeout it happens to own.

And a timer is a reason for the scheduler to expect progress, which is §21.5.4's point about the deadlock detector arriving from the other direction. checkdead looks for pending timers precisely because a timer will make something runnable without any other goroutine’s help — so one time.Ticker anywhere in the process suppresses deadlock detection for the whole program.

This is why time.Sleep appears in §10.4's table as not triggering the detector

, and it is a more satisfying reason than “it is timer-based”. The runtime is not making an exception for sleeps; it is checking whether any pending timer exists, and a sleeping goroutine has one. The rule and the implementation are the same fact.

Derived for a service with a timeout on every request, timerslen across all Ps approximates in-flight requests, which is occasionally a useful cross-check against /sched/goroutines when you suspect a leak (§19.3.6). A large and growing timerslen with a flat request rate means timers are being created and not cancelled — the classic missing defer cancel() from §13.3.7, visible from the scheduler’s side.

21.3.6 Common Mistakes

Expecting stealing to even out busy Ps
Problem

“Rebalancing” work that needs none

Fix

It is pull-based by idle Ps only

Reading uneven queues as a bug
Problem

Chasing a non-problem, as §19.4.3 did

Fix

Check idleprocs; zero means all is well

Memorising 61, 256, 4
Problem

Confident recall of changeable details

Fix

Learn the shape; §21.7 lists what lasts

Assuming work spreads at creation
Problem

Surprise that one P holds the burst

Fix

Creation is local; spreading is on demand

Expecting runnext to be a queue
Problem

Confusion about handoff ordering

Fix

One slot, checked before the queue

Ignoring spinningthreads
Problem

Ms hunting and finding nothing

Fix

It is a distinct signal from runqueue

Summary: Where a Goroutine Waits

A runnable goroutine sits in one of three places: runnext, a single per-P slot checked first and used for the channel handoff that makes request/response fast; the local run queue, 256 entries and lock-free for its owner; or the global queue, which the local one spills half of itself into when full, which a preempted goroutine is put on (proc.go:4352), and which is checked every 61st scheduling decision so neither source can starve.

The correction: work stealing is conservation, not balancing. stealWork has one caller, inside findRunnable, so a busy P never steals. §19.4.3's uneven queues had idleprocs=0, which means no steal was ever attempted — the imbalance was the expected steady state, not evidence of a scheduler failing.

Measured, 200 tasks that finish in 13 ms with all Ps free take 101 ms against a 100 ms serial bound when seven of eight Ps are held by running goroutines. That is not stealing performing badly; it is stealing not happening, correctly.

findRunnable checks this P’s timers first, then the local queue, the global queue, the netpoller, and four randomised steal passes — the last of which will take another P’s runnext and timers — before parking the M.

Self-Check Questions: Where a Goroutine Waits

A schedtrace line shows per-P run queues of [40 0 38 1 0 0 0 2] and idleprocs=5. Is this the same situation §19.4.3 described, and what would you conclude?

No — and the difference is the one thing §19.4.3 did not look at.

In §19.4.3's figure idleprocs=0: every P was executing a goroutine, nobody was in findRunnable, and therefore no steal was attempted. The uneven queues were the expected steady state and meant nothing was wrong.

Here idleprocs=5. Five Ps have no work, and two Ps are holding 78 goroutines between them. That is the anomaly, because an idle P should be in findRunnable, and step 4 of that function is four randomised passes stealing from other Ps. Work exists, Ps are idle, and they have not taken it.

The plausible explanations are worth ranking. Most likely the sample is simply mid-flight — schedtrace prints an instant, the steal is in progress, and the next line looks different; take a second sample before believing the first. Next most likely is that the queued goroutines are not stealable at that moment. And least likely, but the one worth ruling out, is that the “goroutines” in those queues are being created faster than they can be drained, so the queues refill as quickly as they are robbed.

The general lesson is that the interesting quantity was never the imbalance. It is the imbalance conditioned on idleprocs, and reading the first without the second is what produced §19.4.3's wrong conclusion.

Why does the local run queue overflow by moving 129 goroutines rather than one?

Because the expensive part is the global lock, not the moving, and a batch pays for it once.

runqputslow builds a batch of len(runq)/2 + 1 — 128 goroutines taken from the full 256-entry queue, plus the one that would not fit — and pushes all 129 to the global queue under a single acquisition. The alternative, moving one goroutine each time the queue is full, would take the global lock on every subsequent go statement while the queue stayed at its bound. That reintroduces exactly the contention P was designed to remove (§21.2.2).

Moving half rather than all of it matters too, in the other direction. The P keeps 128 goroutines to work on, so it does not immediately go back to the global queue for more; and the ones it handed over are available to any P that runs dry. Emptying the queue entirely would make the P dependent on the global queue for its next work, which is the shared structure you were trying to avoid.

So the choice of half is a balance between two costs that pull in opposite directions: lock acquisitions on the overflow side, and locality on the execution side. That is the same shape as every batching decision in this book — §17's token bucket, §19's trace buffering — where the batch size trades a fixed per-batch cost against a per-item one.

Your service creates a burst of goroutines from a single request handler. A colleague proposes distributing the go statements across several goroutines so the work “lands on more Ps”. Will it help?

Almost certainly not, and the reason is §21.3.2.

Creating a goroutine puts it on the creating P’s runnext or local queue, so a burst created from one goroutine does land on one P. That much of the colleague’s model is right. What it misses is that the imbalance resolves itself the moment any other P runs dry: findRunnable will steal from the loaded queue, in four randomised passes, and the work spreads without anyone arranging it.

Measured, exactly that: 200 tasks created from one goroutine finished in 13 ms on 8 Ps — near-perfect parallelism — with no distribution logic at all. The scheduler did it.

The case where distributing creation would help is the one from the same measurement’s second arm: when the other Ps are already busy running long goroutines, nobody enters findRunnable, nothing is stolen, and the burst runs serially on its creating P. But note that in that situation the machine has no spare capacity anyway — the other Ps are doing real work — so spreading creation moves the queueing around rather than removing it.

The change also has a cost the proposal does not account for: spawning goroutines to spawn goroutines adds scheduling work and destroys the runnext handoff that makes creation-then-execution fast.

The honest answer is to measure before restructuring, and the thing to measure is idleprocs. If it is zero, the machine is saturated and no arrangement of go statements will help.

Key Takeaways

  • Three places, in priority order: runnext (one slot), the local queue (256, lock-free), the global queue
  • runnext is what makes a channel handoff between two goroutines fast
  • Overflow moves 128 goroutines plus the arriving one in a single lock acquisition, and the P keeps 128
  • Every 61st scheduling decision checks the global queue, so overflowed work cannot starve
  • stealWork has exactly one caller, inside findRunnable (proc.go:3537) — a busy P never steals
  • Work stealing is conservation, not balancing, and §19.4.3's uneven queues at idleprocs=0 were fine
  • Measured: 200 tasks take 13 ms with Ps free to steal and 101 ms — the serial bound — when they are not
  • The interesting quantity is imbalance conditioned on idleprocs, never imbalance alone
Section 21.3 — in one line

An idle processor goes looking for work; a busy one never does, which is why the uneven queues you were shown were the system working.

21.4 Threads You Did Not Ask For

Chapter 1 raised this and deferred it. §1.4 says the runtime “may create additional OS threads when goroutines block on syscalls” and hands the detail to §1.5; §1.3 says that when a goroutine blocks an OS thread “the runtime detects this and creates an additional thread so other goroutines keep running.” Neither says how many, or which kinds of blocking count.

Here is the detail, and the gap gets much wider than the threads=19 §21.1.1 opened with, or the threads=12 on sixteen processors that §21.1.3's block prints.

21.4.1 Measured: Two Kinds of Blocking

The same program, the same machine, GOMAXPROCS=16, two ways of having a thousand goroutines wait:

What is blocked
nothing — at rest
500 goroutines on sockets
64 goroutines in a blocking syscall

Five hundred blocked goroutines cost about one extra thread. Sixty-four cost about sixty-two. The duration of the individual calls is not the variable — §21.4.3 sweeps it and finds it barely matters.

That is roughly a five-hundred-fold difference in thread cost per blocked goroutine, and the only thing that varies is whether the wait is something the netpoller can express. §1.4 explains the netpoller itself — epoll, kqueue, IOCP, and the four-step flow — and this chapter does not repeat it. What §1.4 does not say is the price of the other branch, which is the entire content of this section.

And the threads do not come back. After the syscalls complete the count stays near 67. Ms are cached rather than destroyed, on the reasonable assumption that a program which needed 67 threads once may need them again. That is why thread count is a high-water mark rather than a current reading, and why a brief burst of file I/O leaves a permanent trace in your process.

21.4.2 The Mechanism

A goroutine that enters a syscall takes its M with it — the thread is now inside the kernel and cannot run Go code. If nothing else happened, GOMAXPROCS goroutines doing file I/O would stall the whole program.

So something else happens: the P is taken away from the blocked M and given to another one.

WHAT HAPPENS WHEN A GOROUTINE ENTERS A SYSCALL

Before and after. Before: thread M1 holds processor P3, which is running a goroutine that has entered a syscall, and other threads are idle. After: M1 stays with the goroutine, which is still in the kernel, while P3 is handed to a new thread M7 and runs a different goroutine. The syscall keeps its thread and the P moves on. Threads go from one to two; parallelism is preserved.

Two consequences follow, and both are visible in scheddetail. The syscalltick field counts how many times a P has been handed over this way. And threads grows, because the M that was handed a P had to come from somewhere — either the idle pool or a fresh clone.

The netpoller is the contrast. A goroutine reading from a socket does not enter a blocking syscall. It registers with the poller, parks, and its M is free immediately to run something else. One poller thread watches every registered socket. That is why five hundred socket-blocked goroutines cost one thread and why a Go server can hold a hundred thousand idle connections on a handful of threads.

21.4.3 What Decides It Is Concurrency, Not Duration

Before the list, a correction to the impression §21.4.2 leaves. It describes what happens to a syscall that blocks. It does not say which syscalls pay, and the intuitive answer — the slow ones — is wrong.

When a goroutine enters a syscall the runtime marks its P as being in one and leaves it attached. Nothing is handed off and no thread is created. sysmon decides later whether to take the P, and its decision is mostly not about elapsed time. Measured, proc.go:6743-6761 in retake:

snippet_214.go
// Illustrative snippet — not a complete program
// Retake the P if it's there for more than 1 sysmon tick
// (at least 20us).
if syst := int64(pp.syscalltick); !sysretake &&
    int64(pd.syscalltick) != syst {
    pd.syscalltick = uint32(syst)
    pd.syscallwhen = now
    thread.resume()
    goto done
}

if runqempty(pp) && sched.nmspinning.Load()+
    sched.npidle.Load() > 0 &&
    pd.syscallwhen+10*1000*1000 > now {
    thread.resume()
    goto done
}

thread.takeP()

The first branch is the clock everyone quotes, and it is one sysmon tick — at least twenty microseconds, not ten milliseconds. The ten milliseconds is in the second branch, and that branch is an exemption rather than a threshold: the runtime leaves the P alone only when all three of its conditions hold — the P’s own run queue is empty, some other processor is idle or already hunting, and the call is younger than ten milliseconds. Nothing waiting, somewhere else to run it, and recent. Fail any one and the P is taken, which is where the thread comes from.

Note also that the ten milliseconds is a bare literal in that expression. It is not forcePreemptNS, and the two constants are unrelated despite being equal today.

Measured GOMAXPROCS=4, a fresh process per cell, goroutines looping on syscall.Select with a fixed timeout; ranges are the spread over repeated runs. First hold the duration at fifty microseconds — two hundred times under the number the folklore names — and vary how many calls are in flight:
Calls in flight, against extra threads — duration fixed at 50 µs, three runs
1
+0
2
+0
8
+6–8
32
+32
64
+64
128
+128

Then hold the concurrency and sweep the duration straight across ten milliseconds:

Concurrency fixed, four runs:
Call duration
50 µs
500 µs
5 ms
20 ms

Read the two tables against each other. A 128-fold increase in concurrency moved thread count 128-fold. A 400-fold increase in duration, straight across the number the folklore names, moved it by at most one thread. A fifty-microsecond call costs a thread — a hundred and twenty-eight of them, if a hundred and twenty-eight are outstanding. A twenty-millisecond call costs none when only two are. Thread count tracks how many blocking calls are in flight and is very nearly blind to how long each one takes.

The second table’s last row also shows the exemption doing its job, and it was the most stable measurement here — +0 in every run at every duration. At two calls in flight on four processors there is always an idle P and nothing queued, so the runtime declines to take anything and the thread count never moves. That is the case the ten-millisecond backstop exists for, and the reason a low-concurrency program can make slow syscalls all day for free.

Derived which makes this Chapter 17's arithmetic rather than a new one. §17.1.3's Little’s Law says in-flight equals rate times duration, and thread count follows in-flight. Duration matters, but only through that product: a service making a thousand file reads a second at 100 µs each holds about 0.1 concurrent reads and five threads; the same service against a filesystem that answers in 50 ms holds fifty concurrent reads and about fifty threads. The request rate did not change. Nobody deployed anything.
Which reframes the guidance entirely.

The rule is not “syscalls are expensive”, and it is not “slow syscalls are expensive”. It is that a blocking syscall costs a thread for as long as it is in flight, and the quantity you control is how many can be in flight at once. A service reading small files from page cache one at a time makes millions of syscalls and holds five threads. The same code with five hundred concurrent readers holds five hundred threads whether each read takes fifty microseconds or fifty milliseconds. That is why §21.4.7's fix is a bound on concurrency rather than a faster disk, and why this failure mode arrives with a traffic increase or an infrastructure slowdown rather than with a deploy.

21.4.4 What Actually Costs a Thread

Three categories, and it is worth being precise because “I/O” is not the dividing line.

Blocking syscalls the poller cannot express. Regular-file reads and writes are the common case: os.File operations on a local disk block the thread, because the OS offers no usable async interface for them on most platforms. So a service reading many files concurrently grows threads in proportion to its concurrency, while the same service reading many sockets does not.

cgo calls. Any call into C blocks its M for the duration, because the runtime has no way to interrupt or reschedule foreign code. A cgo-heavy service is a thread-heavy service, and the relationship is roughly one thread per concurrent cgo call in flight.

runtime.LockOSThread. This one is deliberate: it pins a goroutine to its M for as long as the lock is held, which some C libraries and OS interfaces require. The cost is that the M cannot be reused for anything else, and if the goroutine is long-lived the thread is permanently spoken for.

The documentation names all three in one sentence.

debug.SetMaxThreads's doc says the limit counts threads “blocked in system calls, cgo calls, or are locked to other goroutines” — which is this section’s list, from the standard library, in the function that will kill your process for exceeding it.

21.4.5 sysmon: the Thread With No P

Something has to notice that an M is sitting in a syscall and decide whether its P should be reassigned. That something is sysmon, a thread the runtime starts at boot and never schedules — it has no P and runs outside the Go scheduler entirely.

It appears in scheddetail as sysmonwait, and it does four jobs relevant to this book:

sysmon appears zero times in the preceding twenty chapters, which is defensible — you cannot control it and never call it — but it is the answer to “who preempts the preempter” and to “who decided my syscall had cost me a thread”, and both questions arrive eventually.

sysmon backs off when the program is idle

, and that is the fine print on §21.4.3's twenty microseconds. It polls at intervals that stretch from 20 µs up to 10 ms depending on how much is happening, so “one sysmon tick” is twenty microseconds on a busy program and as much as ten milliseconds on a quiet one. It is normally invisible, and it is occasionally the explanation for why a preemption or a P retake took longer than you expected on an otherwise idle process.

21.4.6 Watching It, and the Ceiling

Two runtime/metrics counters make this observable without pprof, in the always-on style §19.4 argued for:

s_214.go
// Illustrative snippet — not a complete program
s := []metrics.Sample{
    {Name: "/sched/threads/total:threads"},
    {Name: "/sched/gomaxprocs:threads"},
}
metrics.Read(s)

/sched/threads/total is the live OS thread count — the number that reproduced §21.4.1's table without any profiling enabled. /sched/gomaxprocs is the current GOMAXPROCS, read without going anywhere near runtime.GOMAXPROCS and therefore without touching sched.lock (§21.2.4). Post-1.25 that second value moves, which makes it a genuinely useful thing to alert on: a service whose GOMAXPROCS silently halved has had its quota cut, and nothing else in your telemetry will say so.

The reason to watch the first is §21.2.1's ceiling. Thread growth is gradual, silent, and terminal:

Terminal
runtime: program exceeds 10000-thread limit
fatal error: thread exhaustion
Derived at one thread per concurrent blocking call, a service reaches the default limit at ten thousand simultaneous file reads or cgo calls. That is an unusual number for deliberate concurrency and an ordinary one for a leak — an unbounded worker pool doing file I/O, or a cgo call that occasionally hangs while callers keep arriving. Chapter 17's admission control is the fix, and this is the sharpest possible argument for it: without a bound, the failure mode is not slowness but a fatal error with no recovery path.

21.4.7 Diagnosing Thread Growth

Thread growth is worth a procedure, because it is silent, monotonic, and terminal — and because the diagnosis is quick once you know the four questions.

First, confirm it is growth rather than a level. Threads are a high-water mark (§21.4.1), so a single reading tells you almost nothing. Sample /sched/threads/total twice, minutes apart, under comparable load. A flat number at 400 is a program that once did 400 concurrent blocking calls and is fine. A number rising steadily is the problem.

Second, find the blocked goroutines. Threads grow because goroutines block in ways the poller cannot express, so the goroutines are still there, parked. A debug=1 goroutine profile (§19.2.2) collapses them: if thread growth is real, one stack will have a count that tracks it almost exactly.

Third, classify the wait. The stack tells you which of §21.4.4's three categories you are in — a syscall.Read on a regular file, a cgo entry point, or a LockOSThread region. Those have different fixes and the stack distinguishes them immediately.

Fourth, bound it. The fix is almost never to make the blocking call faster; it is to limit how many can be in flight, which is Chapter 17's material. A semaphore sized from the thread budget you are willing to spend converts an unbounded climb into a queue you can see.

Derived the arithmetic is worth doing explicitly, because it sets the urgency. At the default ceiling of 10,000 and a growth rate of r threads per hour, you have (10000 − current) / r hours before fatal error: thread exhaustion — and the crash lands on whichever instance has been running longest, which is why this failure so often presents as random instance deaths uncorrelated with load.
Alert on the rate, not the value.

A thread count of 300 is unremarkable and a thread count of 9,000 is far too late to be finding out. The signal is the derivative, and it is available for a counter read from /sched/threads/total — the same argument §19.4 makes for every other always-on metric, applied to the one resource whose exhaustion is fatal.

21.4.8 Common Mistakes

Expecting GOMAXPROCS to bound threads
Problem

Alarm at threads far above it

Fix

It bounds Ps; threads are a separate resource

Treating all I/O the same
Problem

Sockets scale, files do not, same code shape

Fix

Only the poller-expressible kinds are free

“Syscalls are expensive” as a rule
Problem

Avoiding cheap calls, tolerating concurrent ones

Fix

Cost tracks calls in flight, not their duration

Assuming threads shrink after a burst
Problem

High-water mark misread as a leak

Fix

Ms are cached; the count is a maximum

Unbounded concurrency over file I/O
Problem

Thread growth, then thread exhaustion

Fix

Bound it (Chapter 17); the ceiling is fatal

LockOSThread without Unlock
Problem

A thread permanently spoken for

Fix

Pair them; scope the lock as tightly as possible

Not exporting thread count
Problem

The one metric that precedes a fatal crash

Fix

/sched/threads/total, always on

Ignoring a moving GOMAXPROCS
Problem

Quota cut in half, nothing says so

Fix

/sched/gomaxprocs is now a live value

Summary: Threads You Did Not Ask For

Chapter 1 promised this section. Measured, on one machine at GOMAXPROCS=16: five threads at rest, six with five hundred goroutines blocked on sockets, and sixty-seven with sixty-four blocked in a syscall. Roughly a five-hundred-fold difference in thread cost per blocked goroutine, decided entirely by whether the netpoller can express the wait — and the threads do not come back afterwards, because Ms are cached.

The mechanism is that a syscall keeps its M and loses its P: the P is handed to another thread so parallelism is preserved, syscalltick counts the handovers, and threads grows.

What decides whether a given call pays is concurrency, not duration. sysmon takes a syscalling P after one tick — twenty microseconds — unless the P’s queue is empty and another processor is idle and the call is younger than ten milliseconds, which is an exemption rather than a threshold. Measured at GOMAXPROCS=4: fifty-microsecond calls cost one thread each at 128 in flight, while twenty-millisecond calls cost nothing at two in flight — a 128-fold concurrency change moved threads 128-fold and a 400-fold duration change moved them by at most one. Thread count is in-flight count, which by §17.1.3's Little’s Law is rate times duration — so a filesystem that slows down raises threads at an unchanged request rate.

Three things cost a thread: blocking syscalls the poller cannot express, cgo calls, and LockOSThread — the same three the SetMaxThreads documentation names.

sysmon is the thread with no P that retakes those Ps, forces preemption, polls the network as a backstop, and runs the Go 1.25 GOMAXPROCS update. It appears in zero earlier chapters and answers several questions that do.

And the ceiling is fatal: ten thousand threads, then fatal error: thread exhaustion, reachable by unbounded concurrency over file I/O or cgo.

Self-Check Questions: Threads You Did Not Ask For

Your service holds 50,000 idle websocket connections on 12 OS threads. A new feature reads a small config file per request, and thread count climbs to 400 under the same load. Explain both numbers.

Both are the netpoller distinction, seen from either side.

The 50,000 connections cost almost nothing because a goroutine blocked on a socket read does not hold a thread. It registers the file descriptor with the poller, parks, and its M is released immediately. One poller watches every registered descriptor, so the marginal thread cost of an idle connection is approximately zero — measured here as five hundred socket-blocked goroutines costing one extra thread.

The config file is a regular file, and regular-file I/O has no usable asynchronous interface on most platforms. So each read is a genuine blocking syscall: the goroutine takes its M into the kernel, the runtime hands that M’s P to another thread to preserve parallelism, and thread count rises with the number of concurrent reads in flight. Four hundred threads means roughly four hundred concurrent reads.

Two follow-ups matter more than the diagnosis. First, the count will not come back down when the load drops — Ms are cached, so 400 is now the high-water mark for the life of the process. Second, this scales with concurrency rather than with request rate, so a slow disk makes it worse in a way that a busy disk does not.

The fixes are ordinary: cache the config rather than reading it per request, or bound the concurrency of the reads with Chapter 17's machinery. The reason to care is §21.4.6 — thread growth of this shape ends in a fatal error rather than a slowdown.

Why does GOMAXPROCS=1 not mean “one thread”?

Because GOMAXPROCS counts Ps, and a P is permission to execute Go code — not a thread.

At GOMAXPROCS=1 exactly one goroutine executes Go code at a time. But a goroutine that enters a blocking syscall stops executing Go code while remaining on its thread, so the runtime takes the P away and gives it to a different M. The syscall keeps its thread, the P moves on, and you now have two threads with one P between them.

Repeat that with ten concurrent file reads and you have eleven threads and one P: ten sitting in the kernel and one running Go. All perfectly consistent with GOMAXPROCS=1, because at no point are two goroutines executing Go simultaneously.

The runtime also keeps a few threads for its own purposes — sysmon, which has no P at all and never counts against GOMAXPROCS, plus threads for the garbage collector and the network poller. That is why §21.4.1's table shows five threads at rest on a process doing nothing.

The general form is worth stating because it recurs: GOMAXPROCS bounds parallelism in Go code, not concurrency, not threads, and not resource use. It is the single most common misreading of the variable, and §1.5 is careful about it in a way that most secondary sources are not.

Your monitoring shows OS thread count growing 20 per hour on a service under steady load. Nothing else looks wrong. How urgent is this?

Urgent, because the endpoint is a crash rather than a degradation, and the arithmetic gives you a deadline.

Threads are cached and effectively never released, so a steady climb is a high-water mark that only moves one way. At 20 per hour from a base of, say, 50, the default ceiling of 10,000 arrives in roughly three weeks. What happens then is fatal error: thread exhaustion — no error return, no backpressure, no degraded mode. The process dies, and it dies on whichever instance has been up longest, which is why this class of bug tends to look like random instance failures long before anyone connects it to uptime.

Steady load with growing threads is also the diagnostic. If threads tracked load they would rise and fall; a monotonic climb under constant load means concurrent blocking operations are accumulating — a worker pool over file I/O with no bound, a cgo call that occasionally never returns, or goroutines calling LockOSThread without unlocking.

What to do: export /sched/threads/total if you have not (§21.4.6), and alert on the rate rather than the value, because the value looks fine until the day it does not. Then find the blocking call — a goroutine dump (§19.2) will show the accumulating goroutines parked in whatever syscall or cgo call is responsible, and the count of them should match the thread growth almost exactly.

The durable fix is Chapter 17's: bound the concurrency of anything that can block a thread. This is the sharpest argument in the book for admission control, because the failure mode is not slow — it is fatal.

Key Takeaways

  • Measured: 500 goroutines on sockets cost ~1 extra thread; 64 in a blocking syscall cost ~62
  • Threads are cached and do not come back — the count is a high-water mark
  • A syscall keeps its M and loses its P; the P is reassigned so parallelism survives, and syscalltick counts it
  • Three things cost a thread: poller-inexpressible syscalls, cgo, and LockOSThread — the trio SetMaxThreads names
  • sysmon is the thread with no P: retakes Ps, forces preemption, backstops the poller, updates GOMAXPROCS
  • /sched/threads/total and /sched/gomaxprocs observe both without profiling
  • Derived: ten thousand concurrent blocking calls reach the ceiling, and the ceiling is a fatal error
  • GOMAXPROCS=1 does not mean one thread; it means one goroutine executing Go at a time
Section 21.4 — in one line

Blocking on a socket is free and blocking in a syscall costs a thread, and the difference between them is a five-hundred-fold change in a resource that runs out fatally.

21.5 Preemption, and the Ten Years It Took

§1.4 states that Go 1.14 introduced asynchronous preemption and that a tight loop no longer starves other goroutines. That is correct and it is an assertion. This section makes it observable, which is the difference between knowing the fact and having seen what it protects you from.

21.5.1 Measured: One Environment Variable, One Hang

GOMAXPROCS(1) — exactly one P, so anything that wants to run must take it from whoever has it. One goroutine runs a loop with no function calls, no allocation, and no channel operations. main sleeps briefly, then needs the P back in order to print.

sink_215.go
// Illustrative snippet — not a complete program
runtime.GOMAXPROCS(1)
go func() {
    var x uint64
    for { // no calls, no allocation, no channel ops
        x++
        sink = x
    }
}()
start := time.Now()
time.Sleep(50 * time.Millisecond)  // main yields; the loop
                                   // takes the P
late := time.Since(start) - 50*time.Millisecond
fmt.Println("main resumed", late, "late")

Note that the loop never ends. That is deliberate and it is the whole experiment: main can only print if something takes the processor away from a goroutine that will never give it up.

Measured the same binary, twice, five runs each:
Setting
default
GODEBUG=asyncpreemptoff=1

One environment variable turns a working program into a permanent hang. That is precisely what Go 1.14 bought, reproducible on demand — and it is the world every Go program lived in before then.

The seven-to-ten milliseconds in the first row is not noise, and it is worth holding onto until §21.5.5 names it: it is the slice a goroutine gets before sysmon intervenes. Preemption is not instant; it is bounded.

21.5.2 The Cost Is Not Fairness, It Is Stop-the-World

The obvious reading of that measurement is about fairness on a single processor, and it is the less important one. Give the machine room — GOMAXPROCS=4, so three of four processors are idle — and ask the main goroutine to collect garbage instead of print.

Measured GOMAXPROCS=4, one goroutine in §21.5.1's call-free loop, runtime.GC() twenty times, five runs each:
Setting
default
asyncpreemptoff=1

Three idle processors did not help at all, and that is the finding. A garbage collection has to stop the world, and stopping the world means every goroutine reaching a safe point — not most of them, not the ones on busy processors. One goroutine that will never reach one makes the pause unbounded no matter how much of the machine is free.

Derived async preemption is not primarily a fairness feature. It is what makes stop-the-world pauses bounded, which is what makes the garbage collector’s latency guarantees possible at all. Fairness on a loaded processor is a side effect of the same mechanism.

That also gives §19.4.4 its mechanism. That section told you to compare /sched/pauses/stopping/other against /sched/pauses/total/other, and said that when stopping approaches total, “the collector is not slow — it is waiting on a goroutine that will not yield.” The measurement above is that sentence’s limiting case: stopping is the entire pause, and the pause never ends.

The two are one dial apart, and both are free to read.

stopping near zero means every goroutine reached a safe point immediately. stopping near total means the collector spent its pause waiting for one that would not. You can see which before capturing anything, which is the §19.4 argument arriving with the reason underneath it.

21.5.3 Why the Old Model Failed

The loop is deliberately austere. A function call is a cooperative preemption point, so a loop containing almost any call could always be preempted; that is why the pre-1.14 problem was rarer than it sounds and maddening when it appeared. The loops that hung were numeric kernels, tight for loops over slices, and busy-waits — code with no reason to touch the runtime.

HOW A GOROUTINE GETS INTERRUPTED

Two mechanisms side by side. Cooperative preemption, which has always existed: at a function entry the goroutine checks a preempt flag and yields if it is set, which works for any code that calls functions. Asynchronous preemption, since Go 1.14: sysmon notices ten milliseconds, sends SIGURG to the thread, and the signal handler parks the goroutine at whatever instruction it was on, which works for code with no call sites at all. The second required the compiler to guarantee that every instruction is a safe point, and that is the decade it took.

21.5.4 A Row Missing From Chapter 10

This is the chapter’s second correction to a shipped page.

§10.4 has a table of what does and does not trigger the runtime’s deadlock detector. It lists channel send and receive, sync.Mutex.Lock, sync.WaitGroup.Wait, select, empty select, time.Sleep, <-time.After(), and network or file I/O.

There is no row for a goroutine that is simply running.

That is the case §19.4.4 met and could not name — the goroutine that “will not yield” — and it is the most common reason a production deadlock goes unreported. The detector fires only when every goroutine is blocked on something that requires another goroutine to release it. One goroutine executing a loop is not blocked at all, so the condition never holds, and the deadlock among the other thousand goes unannounced.

The missing row:
Goroutine state
Running (a loop, a computation)

The distinction that makes it fit §10.4's framing: the other “✗” rows are cases where progress is possible without another goroutine — a timer will fire, an I/O completion will arrive. A running goroutine is the degenerate version, where progress is not merely possible but happening.

This is why §10.4 says production almost never reports a deadlock

, and the mechanism is worth stating one notch more precisely than that section’s summary line does. §10.4's headline attributes the suppression to a goroutine being “still runnable” — while its own table already gives the real reason for the commonest case, marking time.Sleep as “No (timer-based)” under “requires another goroutine”. Both are pointing at the same thing, and the runtime’s rule is the table’s: a goroutine in time.Sleep is not runnable — it is parked on a timer — and it suppresses the detector anyway, because checkdead treats a pending timer as future progress that needs nobody’s help. 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 last claim is not an inference. It is the final thing checkdead does before giving up, at proc.go:6512-6514:

snippet_215.go
// Illustrative snippet — not a complete program
// There are no goroutines running, so we can look at the P's.
for _, pp := range allp {
    if len(pp.timers.heap) > 0 {
        return
    }
}

Six lines below it is fatal("all goroutines are asleep - deadlock!"). One pending timer anywhere in the process is the whole distance between the two, which is why a service with a time.Ticker in it will hang silently where a toy program would have told you.

21.5.5 How the Interrupt Actually Happens

Cooperative preemption is a check. The compiler inserts a test at function entry — is this goroutine flagged for preemption? — and if so it yields. That covers any code that calls functions, which is nearly all code, and it costs a comparison.

Asynchronous preemption is a signal. sysmon (§21.4.5) notices a goroutine has held its P too long and sends the thread a SIGURG; the signal handler parks the goroutine at whatever instruction it had reached. That is what makes it work on a loop with no call sites.

The threshold is a constant:

force_preempt_215.go
// Illustrative snippet — not a complete program
// proc.go:6679
const forcePreemptNS = 10 * 1000 * 1000 // 10ms

That is the 10 ms §1.4 mentions without naming, and it is the maximum time a goroutine can monopolise a P before sysmon intervenes.

The mechanism is more delicate than “send a signal”: the runtime has to be able to stop at that instruction and later resume, which means the compiler must guarantee that register and stack state are recoverable at any preemptible point. That guarantee — asynchronous safe points — is why the feature took as long as it did, and why some regions remain non-preemptible.

21.5.6 What Preemption Still Cannot Interrupt

Two limits survive, and only one of them matters in practice.

A goroutine inside a syscall cannot be signal-preempted. The thread is in the kernel; the runtime cannot park it at an instruction it does not control. What happens instead is §21.4.2's mechanism: sysmon takes the P away and hands it to another M, so the goroutine keeps its thread and the rest of the program keeps running.

Measured GOMAXPROCS=1, one goroutine inside a one-second blocking syscall:
Terminal
another goroutine ran after 0s
-> the syscall was NOT interrupted; sysmon retook its P

With a single P, the second goroutine could only run if the P had been taken back — so this measures the retake directly. The distinction worth holding: the goroutine is not preempted, but the processor is recovered, which is what the rest of the program actually needs.

Runtime-internal non-preemptible regions. Some code marks itself unpreemptible — parts of the scheduler, the allocator, and stack growth itself, because moving a stack while something is preempting it is not a thing that can work. These windows are microseconds and you will not observe them from user code, which is the right amount of attention to give them.

runtime.Gosched() is almost never the answer any more.

It was the pre-1.14 workaround: sprinkle a yield into your tight loop so the scheduler gets a chance. Asynchronous preemption removed the need, and the call now mostly signals that its author learned Go before 2020. The exceptions are narrow — a spin-wait you deliberately want to yield from sooner than 10 ms, or a benchmark controlling scheduling explicitly. If you find one in a codebase, the useful question is what version it was written against.

21.5.7 Common Mistakes

Assuming a tight loop starves others
Problem

Defensive Gosched() calls everywhere

Fix

Async preemption since 1.14; measure first

Assuming preemption is instant
Problem

Surprise at up to 10 ms of latency

Fix

forcePreemptNS is the bound, not the target

Expecting a syscall to be preempted
Problem

Confusion about a goroutine that “ignores” it

Fix

The P is retaken; the goroutine is not

Reading “no deadlock reported” as no deadlock
Problem

A hung service the detector never mentions

Fix

One running goroutine suppresses it entirely

Gosched() in modern code
Problem

Noise, and a hint the author predates 1.14

Fix

Remove it unless you can name the reason

Blaming the scheduler for a 10 ms tail
Problem

Chasing a bound that is working as designed

Fix

It is the preemption slice; look elsewhere

Summary: Preemption, and the Ten Years It Took

§1.4 asserts asynchronous preemption; this section demonstrates it. The same binary at GOMAXPROCS=1 with a call-free tight loop resumes 7–10 ms late by default — inside forcePreemptNS — and never resumes at all under GODEBUG=asyncpreemptoff=1. That is the pre-1.14 world, on demand.

And the important half is not fairness. At GOMAXPROCS=4 with three processors idle, runtime.GC() still hangs under the same flag — because stopping the world requires every goroutine to reach a safe point, and one that never will makes the pause unbounded however much of the machine is free. Async preemption is what bounds stop-the-world pauses, which is what makes the collector’s latency guarantees possible; fairness is a side effect. That is also the mechanism behind §19.4.4's stopping-versus-total ratio.

Chapter 10's deadlock-detector table is missing a row: a goroutine that is simply running suppresses the detector, because it is not blocked at all. That is §19.4.4's goroutine that would not yield, and it is why production almost never reports a deadlock — though the more precise rule is that the runtime suppresses whenever it can see a reason to expect progress, which a single pending timer also provides.

Cooperative preemption is a compiler-inserted check at function entry. Asynchronous preemption is a SIGURG from sysmon after forcePreemptNS, 10 ms. It required asynchronous safe points, which is why it took a decade.

Two limits remain. A goroutine in a syscall is not preempted — its P is retaken instead, measured directly at GOMAXPROCS=1. And a few runtime-internal regions are unpreemptible for microseconds. runtime.Gosched() is a pre-1.14 workaround that modern code rarely needs.

Self-Check Questions: Preemption, and the Ten Years It Took

A service hangs. kill -QUIT shows 800 goroutines blocked on a mutex and one in a tight computation. The runtime reported no deadlock. Is this a deadlock?

Almost certainly yes, and the absence of a report is exactly what §21.5.4 predicts.

The runtime’s detector fires only when every goroutine is blocked on something requiring another goroutine to release it. Here one goroutine is running — not blocked, not waiting, executing instructions — so that condition never holds no matter how thoroughly the other 800 are stuck. The detector is silent by design, and Chapter 10's table has no row that covers this case.

The interesting question is which goroutine matters, and it is the one that looks healthiest. Eight hundred stacks converging on a mutex tell you the resource; the single [running] goroutine is the one to read, because it is very likely holding the lock while doing something long, or spinning in a loop waiting for a condition the blocked goroutines were supposed to establish.

Two follow-ups settle it. Take a second dump thirty seconds later (§19.2.5): if the same goroutine IDs are in the same states, nothing is progressing and it is a deadlock rather than a slow section. And check whether the running goroutine is making progress — a loop with a terminating condition that will never be met is a deadlock with one participant awake, which is the shape this whole section is about.

Note also that this program is not frozen. Async preemption means the 800 blocked goroutines are not starved of CPU; they are starved of the lock. Those are different problems and the distinction matters for the fix.

Why did asynchronous preemption take until Go 1.14, when “send the thread a signal” sounds straightforward?

Because the hard part is not sending the signal, it is being able to resume.

To park a goroutine at an arbitrary instruction and later continue it, the runtime must know exactly what the machine state means at that point: which registers hold pointers the garbage collector must trace, what the stack looks like, whether the goroutine is midway through an operation that cannot be interrupted. That guarantee has to be produced by the compiler for essentially every instruction — asynchronous safe points — and it is a change to code generation rather than to the scheduler.

Before that existed, the only safe places to stop were the ones the compiler had explicitly prepared: function entry, where the cooperative check lives. That is why the pre-1.14 failure mode was so specific. Loops with function calls were preemptible; loops without them were not, and the difference was invisible in the source.

The cost of the change is worth noting because it is a real trade the runtime made on your behalf: extra metadata to describe safe points, and a signal-handling path that must be correct under every possible interruption. Go took it because the alternative — a program that can be frozen by one arithmetic loop — is worse.

The useful generalisation is that “interrupt anywhere” is expensive wherever it appears, and systems that offer it have usually paid for it somewhere you have not looked.

Under GOMAXPROCS=1, one goroutine makes a one-second blocking syscall. Another goroutine runs immediately. What was preempted?

Not the goroutine — the processor.

A goroutine inside a syscall cannot be signal-preempted, because the thread is executing kernel code the runtime does not control and cannot park at an instruction. So the syscall runs to completion, uninterrupted, for its full second.

What sysmon does instead is take the P away from that thread and hand it to a different M (§21.4.2). The syscalling goroutine keeps its thread and continues to block; the P — permission to execute Go code — moves to a thread that can use it. With only one P, that reassignment is the only way the second goroutine could have run, which is what makes the measurement conclusive.

The consequence is the one §21.4 is built on: thread count grows. The P had to be given to some M, and if no idle M was available the runtime started one. So this preservation of parallelism is bought with a thread, every time, and it is why sixty-four concurrent blocking syscalls produced sixty-seven threads.

The distinction to keep is between the two resources. The goroutine’s progress is unaffected — it is in the kernel and will return when it returns. The program’s progress is preserved, because the P was recovered. Preemption in the ordinary sense never happened.

Key Takeaways

  • Measured: at GOMAXPROCS=1 a call-free tight loop lets main resume 7–10 ms late; under GODEBUG=asyncpreemptoff=1 it never resumes
  • Measured: at GOMAXPROCS=4 with three processors idle, runtime.GC() still hangs — stop-the-world needs every goroutine at a safe point
  • Derived: async preemption is what bounds stop-the-world pauses; fairness is the side effect
  • Cooperative preemption is a compiler check at function entry; async preemption is sysmon sending SIGURG
  • forcePreemptNS = 10ms (proc.go:6679) — the maximum a goroutine holds a P, and the 7–10 ms in §21.5.1
  • Chapter 10's detector table is missing a row: a running goroutine suppresses detection entirely
  • The precise rule is not “something is runnable” but “the runtime can see a reason to expect progress” — a pending timer counts
  • Measured: a syscall is not preempted; its P is retaken, which is what the rest of the program needs
  • Asynchronous safe points are why the feature took a decade — resuming is the hard part, not interrupting
  • runtime.Gosched() is a pre-1.14 workaround that modern code rarely needs
Section 21.5 — in one line

One environment variable still turns a working program into a permanent hang, which is the clearest possible statement of what Go 1.14 bought.

21.6 Why the Cheap Things Are Cheap

Three mechanisms, one theme. Creating a goroutine is cheap, blocking one is cheap, and handing a value between two of them is cheap — and each is cheap for a specific, findable reason. This section is the positive half of the spine, before §21.7 spends what is left of it.

21.6.1 A Stack That Moves

§1.4 covers the shape: a goroutine starts with about 2 KB, the runtime grows it when a call would overflow, growth is a copy to a larger allocation, and the cap is around 1 GB. What that section does not say is what the copy costs or what it implies.

The way to isolate the copying is to do the same descent twice in the same goroutine. The first pays for growth; the second finds the stack already grown.

Measured a recursive descent to depth d with 1 KB frames, inside one fresh goroutine, timed twice in a row:
Depth
16
128
1,024
8,192
32,768

Same goroutine, same work, same code path. At depth 1,024 — a recursive parser on a nested document, say — the first call through costs 1.11 ms and every later one costs 42 µs.

Now read the ratio column downward: 48×, 33×, 26×, 17×, 11×. The overhead is proportionally largest at shallow depths and shrinks as the chain lengthens, which is the opposite of what “deep recursion is expensive” would predict.

Derived stack growth is geometric. Each growth doubles the stack, so reaching depth d takes on the order of log₂(d) copies while the work done at depth d is proportional to d. Copy cost grows logarithmically, useful work grows linearly, and the ratio between them falls. That is the signature of doubling, visible in the numbers without reading a line of source.

It also says which case to worry about, and it is not the one people worry about. A goroutine recursing to 32,768 pays 11× on its first descent and is fast forever after. A goroutine recursing to 16 and then exiting pays 48× and never amortises anything. The expensive pattern is shallow growth repeated across many short-lived goroutines, not deep growth in one long-lived one.

The copy is why a goroutine’s stack address is not stable. When the runtime moves a stack it must find every pointer into that stack and rewrite it, which it can do because Go is type-safe and the compiler emits maps describing where pointers live. Two consequences follow that occasionally matter: you cannot hand a pointer to a Go stack variable to C and expect it to remain valid, and there is no way to observe the address change from Go, because everything that could observe it has been fixed up.

Deep recursion is not free even when it fits.

A function that recurses to 10,000 frames pays for a dozen stack copies, each one larger than the last, and the total work is proportional to the final size rather than to the depth. That is fine once and expensive in a loop — and it is invisible in a CPU profile, which will attribute the time to runtime.morestack rather than to your function.

21.6.2 The Goroutine Is Pooled; the Stack Is Not

Which raises the obvious optimisation: if growth is the cost, reuse the grown stacks. Keep a pool of goroutines that have already paid, and hand work to them.

The runtime looks like it is doing exactly that. Each processor keeps a free list of dead goroutines — gfreecnt in §21.1.3's output. When a goroutine returns its g structure goes on that list rather than being released, and the next go statement on that P takes one back with no allocation and no scheduling setup. That is the other half of “creation is cheap”: most creations are a pop from a per-P free list rather than an allocation. The list caps at 64 (proc.go:5536) and drains half to a central one beyond that.

Derived which makes goroutine creation cost bimodal. A burst that creates and destroys goroutines repeatedly runs almost entirely on the reuse path; a program creating goroutines for the first time pays for stacks. A benchmark that creates a million goroutines in a loop measures the cheap path almost exclusively, which is worth knowing before quoting one. It is the same bet sync.Pool makes (§12.3) with different lifetime rules — here the runtime owns the pool and the collector does not empty it every cycle.

But look at what actually goes on the list. Measured, proc.go:5521-5527, in gfput:

stksize_216.go
// Illustrative snippet — not a complete program
stksize := gp.stack.hi - gp.stack.lo

if stksize != uintptr(startingStackSize) {
    // non-standard stack size - free it.
    stackfree(gp.stack)
    gp.stack.lo = 0
    gp.stack.hi = 0

The g is pooled. A stack that is not exactly the starting size is freed. The next goroutine to reuse that g starts at the starting size and grows all over again.

Derived the runtime deliberately refuses to cache grown stacks, and it is the right call — pooling large stacks would let one deep-recursion burst permanently inflate the memory floor of a program that never recurses again. But it means a “warm pool of goroutines” does not work the way people expect: you can pool the goroutine, and you cannot pool the stack it grew.

Which turns §21.6.1's measurement into a decision. If the work needs a deep stack, do it on a long-lived goroutine rather than a fresh one per item. A worker pool of ten goroutines each handling a thousand deep-recursion jobs pays for growth ten times. Ten thousand short-lived goroutines each handling one pays ten thousand times, and no amount of pooling at the goroutine level changes that — which is a concrete reason to prefer Chapter 7's worker pool over spawning per item, independent of every reason that chapter gave.

“The starting size” is not a constant.

startingStackSize is recomputed at every garbage collection from the average size of the stacks scanned during it — the declaration says so at stack.go:1417 and gcComputeStartingStackSize does it at :1421 — gated, like most things in this chapter, on a GODEBUG, though adaptivestackstart is on by default (:1393), so which stacks qualify for pooling moves with the shape of your program. Measured, in one process reading /gc/stack/starting-size:bytes: 2,048 bytes at startup, 262,144 after three hundred goroutines each held roughly a quarter-megabyte of stack across a collection, and 2,048 again two collections after they exited — identical across three runs. The peak is set by the average live stack, rounded up to a power of two, so it is the depth those goroutines reached and not how many of them there were: shallower stacks land the same experiment on 16 KB or 64 KB instead. That is a heuristic doing its job — new goroutines start near the size they are likely to need — and it is why the “about 2 KB” below is a starting observation rather than a guarantee.

21.6.3 Reconciling 2 KB With 2.7 KB

§1.4 and §2.5 say a goroutine costs about 2 KB. §14.4.4 measured 2.7 KB apiece for ten thousand parked goroutines. Both are correct, and §21.1 promised to say which to use.

Measured runtime.MemStats.StackInuse before and after parking ten thousand goroutines, identical across three runs:
Heap
at rest
with 10,000 parked
difference

Note which number the division is on. Dividing the total by ten thousand gives 2,120 and quietly charges every goroutine a share of the runtime’s own stacks; dividing the difference gives 2,081, which is what one more goroutine actually costs — and which is §14.4.4's stack half to the byte. The remaining ~600 bytes in Chapter 14's 2.7 KB is the g structure and its scheduling bookkeeping, which lives on the heap rather than in StackInuse.

So:

Question
How deep can I recurse before growing?
What will a million goroutines cost?

Chapter 14 states the trap outright — “the commonly quoted 2 KB per goroutine is the stack half only” — and it is worth repeating here because the smaller number is the famous one and under-predicts memory by about a third.

21.6.4 The Channel: A Lock and a Queue

Now §21.1's fifth artifact — Chapter 5 called an unbuffered channel a synchronisation point, and §19.3.3 found channel operations in the mutex profile.

Both are true because a channel is a mutex around a ring buffer and two wait queues. Every operation — send, receive, close — takes that lock. That is why channel contention shows up where §9's material and §19.3's block and mutex profiles can see it, and why a heavily contended channel and a heavily contended mutex look similar in a profile: at that level they are the same thing.

But the two profiles name it differently, and one of them does not name it at all. Measured: sixteen goroutines contending on one buffered channel, with both profiles enabled at rate 1:

Profile
BLOCK
MUTEX

The block profile names the channel. The mutex profile does not name it anywhere in the flat column, because the leaf is where the contention was measured and that is literally runtime.unlock. The chansend and chanrecv frames are in the stacks, so they appear in the cumulative column and in a graph view — but a team that greps a flat mutex profile for chan, which is the first thing anyone does, finds nothing and concludes there is no channel contention. GODEBUG=runtimecontentionstacks=1 does not help; that knob is for the runtime’s own internal locks, and re-running under it left the leaf unchanged.

Derived so §19.3.3's finding has an operational half this chapter can add. When the mutex profile is dominated by runtime.unlock and you cannot find a sync.Mutex to blame, look one frame up rather than concluding the profile is broken.
THREE PATHS THROUGH A CHANNEL SEND

A send takes one of three paths. If a receiver is waiting, the value is copied straight into the receiver’s stack and the receiver is marked runnable through runnext. Otherwise, if the buffer has room, the value is copied into the ring buffer and the send returns. Otherwise the sender is parked in a sudog on the send queue. All three take the channel’s lock; only the third one blocks.

The interesting mechanism is what happens when the lock is held and a partner is already waiting.

A send to a waiting receiver copies directly into the receiver’s stack. No buffer slot is used, even on a buffered channel; the value goes from the sender’s frame to the receiver’s, the receiver is marked runnable, and — via runnext (§21.3.1) — it is very likely the next thing that P runs. The waiting goroutines are held in sudog structures, which are themselves pooled per-P, so blocking on a channel does not allocate in the common case.

That is a genuinely elegant path, and it is tempting to conclude something about performance from it that is not true.

21.6.5 Measured: What Direct Handoff Does Not Buy

The tempting conclusion is that direct handoff makes unbuffered channels as fast as buffered ones. It does not, and the mistake is worth showing because the mechanism is real while the consequence is false.

Measured one goroutine streaming values to another that only receives; minimum of ten runs:
Capacity
0
1
8
64
1024

Unbuffered is roughly four times slower than the best buffered case. Direct handoff happens for buffered channels too whenever a receiver is already waiting, so it is not something unbuffered channels have and buffered ones lack. What unbuffered channels lack is the ability for the sender to proceed without a partner, and on a streaming workload that is the whole cost.

The curve also stops paying, and the last row is why the table goes past 64. Capacity 1024 is no better than capacity 64 — slightly worse here, and it was the noisiest row across the ten runs. Buffering buys the sender room to run ahead of the receiver; once the buffer is larger than the lead the sender can actually build, more capacity is a larger allocation and a colder ring. If you are choosing a number, the useful question is how far ahead the producer gets, not how big the queue could be.

The true statement about handoff is narrower, and it is the useful one for §5's “when to buffer” question:

Measured strict request/response ping-pong — send, then wait for the reply. Minimum of ten runs at -benchtime 1s:
Capacity
0
1
64

The method matters more than usual here, so it is stated: run-to-run spread within a single capacity reached 30 % on this machine, several times the ~5 % spread between capacities. Capacity is not what this benchmark is measuring — which is the finding, and the contrast with the streaming table above, where capacity moved the number four-fold, is the whole argument.

When the pattern forces a rendezvous, buffering buys nothing.

In ping-pong the sender cannot proceed until the reply arrives, so a buffer slot it could have used is irrelevant — the second party has to arrive regardless. Buffering pays exactly when the sender has more work to do before it needs the receiver, which is §5's argument with a mechanism underneath it. If you have ever added a buffer to a request/response channel and measured no change, this is why.

21.6.6 Common Mistakes

Quoting 2 KB for memory planning
Problem

Under-predicting by about a third

Fix

2 KB is the stack; ~2.7 KB is the goroutine

Deep recursion in a hot path
Problem

Time attributed to runtime.morestack

Fix

Growth is geometric; the first descent pays

Spawning a goroutine per deep-recursion item
Problem

Growth paid once per item, never amortised

Fix

Long-lived workers; grown stacks are not pooled

Expecting a goroutine pool to keep warm stacks
Problem

The 48× first-descent cost, every time

Fix

Only the g is pooled; the stack is freed

Passing a Go stack pointer to C
Problem

Corruption after a stack move

Fix

Stacks move; the runtime fixes only Go pointers

Benchmarking goroutine creation in a loop
Problem

Measures the reuse path only

Fix

Creation cost is bimodal; say which you mean

Assuming unbuffered is as fast as buffered
Problem

A streaming path four times slower

Fix

Measured: it is not; buffer streams

Buffering a request/response channel
Problem

No change, and a slot that never fills

Fix

Rendezvous ignores capacity

Surprise at channels in the mutex profile
Problem

Looking for a mutex that is not there

Fix

A channel is a lock around a queue

Summary: Why the Cheap Things Are Cheap

Stacks grow by copying, which is why a goroutine’s stack address is not stable and why the runtime must rewrite every pointer into it. Measured, the same descent run twice in one goroutine costs 48× more the first time at depth 16 and 11× at depth 32,768 — a falling ratio, which is the signature of geometric growth and which says the expensive pattern is shallow growth across many short-lived goroutines rather than deep growth in one.

Creation is cheap partly because most creations pop a g from a per-P free list — gfreecnt in scheddetail — which makes creation cost bimodal and makes creation benchmarks measure the cheap path. But only the g is pooled: a stack that is not exactly the starting size is freed at proc.go:5523, and the starting size itself is recomputed at every collection from the average of the stacks scanned.

Measured, ten thousand parked goroutines add 2,081 bytes of stack each — §14.4.4's stack half to the byte, provided the arithmetic is on the difference rather than on the total. Use ~2 KB for “how deep can I go” and ~2.7 KB for “what will a million cost”, because the extra is the g structure on the heap.

A channel is a mutex around a ring buffer and two wait queues, which is why channel operations appear in the mutex profile — though the flat leaf there is runtime.unlock and the word chan appears nowhere in it, so the profile that names the channel is the block profile. A send to a waiting receiver copies straight into the receiver’s stack, and the waiters are pooled sudogs so blocking does not allocate.

But direct handoff is not why unbuffered channels are fast, because they are not: measured, streaming costs 184.2 ns at capacity 0 against 42.7 ns at capacity 64 — four times slower — and the curve then flattens, capacity 1024 buying nothing over 64. The true statement is that when the pattern forces a rendezvous, buffering buys nothing — ping-pong measured 355, 357 and 374 ns at capacities 0, 1 and 64, a spread smaller than the noise within any one of them.

Self-Check Questions: Why the Cheap Things Are Cheap

You are planning capacity for a service that will hold one million idle goroutines. Which number do you use, and what does it not include?

About 2.7 KB each — roughly 2.7 GB — and not the 2 KB that everyone quotes.

The 2 KB figure is the initial stack allocation, and it is what §1.4 and §2.5 mean. Measured here, ten thousand parked goroutines added 2,081 bytes of stack apiece, which confirms it to the byte. But a goroutine is not only its stack: the g structure carries scheduling state, the stack bounds, defer and panic links, and profiling labels, and §14.4.4 measured that at about 606 bytes on the heap. Using 2 KB under-predicts by roughly a third, which on a million goroutines is 600 MB you did not plan for.

What neither number includes is anything the goroutines hold. A goroutine parked on a socket read has a read buffer; one in a request handler has whatever the request allocated. In practice that dominates — a million idle connections cost far more in buffers than in goroutine overhead — so the 2.7 KB is a floor rather than an estimate.

And it is a floor that can rise. Any goroutine that has ever recursed deeply or held large locals has grown its stack, and the runtime shrinks stacks only at garbage collection and only sometimes. A million goroutines that each once used 16 KB are not back at 2 KB just because they are idle now.

The practical move is to measure StackInuse and /memory/classes/heap/objects on a representative load rather than multiply a constant — which is exactly what the measurement above did.

Why do channel operations show up in the mutex profile?

Because a channel is implemented as a mutex around a ring buffer and two wait queues, so every channel operation is a lock acquisition.

Send, receive and close all take that lock. When a channel is contended — many senders, many receivers, or both — goroutines wait on it exactly as they would wait on a sync.Mutex, and the mutex profile samples contended unlocks (§19.3.3) without caring whether the lock is one you declared or one inside a channel.

That is useful rather than confusing once you know it. It means §19.3's contention tooling covers channels, which is not obvious from the API — nothing about ch <- v suggests a lock.

What the profile will not do is spell it out. Measured, the flat leaf of a contended channel’s mutex profile is runtime.unlock and the word chan appears nowhere in that column; the chansend and chanrecv frames are one level up, in the stacks. So the answer to “which channel” comes from the cumulative view or a graph, and the profile that names the channel outright is the block profile, where runtime.chansend1 was 87.7 % of the delay in the same run.

It also explains a limit worth knowing: a single channel is a single lock, so it does not scale with the number of goroutines using it. At high fan-in the channel itself becomes the serialization point, which is the same shape as §19.3.6's shared rate limiter and has the same fix — shard it, or reduce how often you touch it by batching.

The elegant part sits underneath: when a partner is already waiting, the value is copied directly between goroutine stacks and no buffer slot is used at all. The lock is still taken, but the queueing is skipped.

A colleague adds a buffer to a request/response channel to “reduce blocking” and measures no improvement. Explain.

Because the pattern forces a rendezvous, and buffering only helps when the sender has something to do that does not require the receiver.

In request/response the sender sends and then immediately waits for the reply. Whether the request sat in a buffer slot or was handed directly to the receiver changes nothing about when the reply arrives — the receiver still has to be scheduled, do the work, and send back. The buffer removes a wait that was not on the critical path.

Measured, strict ping-pong at capacities 0, 1 and 64 gave 355, 357 and 374 ns/op — a spread smaller than the run-to-run noise within any single capacity.

The contrast is the streaming case, where the same change matters a great deal: one goroutine sending to another that only receives measured 184.2 ns at capacity 0 and 42.7 ns at 64, four times faster. There the sender genuinely does have more work to do, and a buffer lets it get on with it instead of waiting for a partner on every value.

So the rule §5 gave — buffer when the sender should not have to wait for the receiver — is right, and this is the mechanism. The diagnostic question is whether the sender’s next action depends on the receiver. If it does, capacity is irrelevant; if it does not, capacity is most of the performance.

Worth adding: the colleague’s change is not free. A buffered channel of size 64 in a request/response path adds memory, hides backpressure (§17.5), and makes a queue where there was a rendezvous — costs with no offsetting benefit.

Key Takeaways

  • Stacks grow by copying and the runtime rewrites every pointer into them — which is why stack addresses are not stable
  • Measured: first descent versus second in one goroutine — 48× at depth 16, falling to 11× at depth 32,768
  • Derived: growth is geometric, so copies scale with log₂(depth) while work scales with depth — the ratio falls
  • The expensive pattern is shallow growth across many short-lived goroutines, not deep growth in one
  • A dead goroutine’s stack is freed unless it is exactly the starting size (proc.go:5523) — you cannot pool a grown stack
  • And “the starting size” moves: it is recomputed at every GC from the average stack scanned (stack.go:1417)
  • Measured: ten thousand parked goroutines add 2,081 bytes of stack each — §14.4.4's stack half exactly
  • Use ~2 KB for recursion depth, ~2.7 KB for memory planning — the difference is the g on the heap
  • Most goroutine creations reuse a pooled g and stack (gfreecnt), which makes creation cost bimodal
  • A channel is a mutex around a ring buffer and two wait queues, which is why it appears in the mutex profile
  • Measured: but its flat mutex-profile leaf is runtime.unlock; the profile that names the channel is the block profile
  • A send to a waiting receiver copies directly into the receiver’s stack, and sudogs are pooled
  • Measured: unbuffered streaming is 4× slower than buffered — direct handoff does not make it competitive
  • Measured: in forced rendezvous, capacity 0, 1 and 64 are identical: buffering buys nothing there
  • Measured: and on a streaming path the gain stops at about 64; capacity 1024 buys nothing over it
Section 21.6 — in one line

Blocking is cheap because the runtime pools the waiting, and creation is cheap because it mostly recycles — but neither makes an unbuffered channel fast when the sender had somewhere else to be.

21.7 What This Changes About Code You Already Wrote

Every section so far has explained something. This one changes something, which is the test §21.1.2 set for the chapter and the debt it has to settle before ending.

21.7.1 Two Chapters of This Book Gave You a Number That Is Wrong in a Container

§14.4.4 says: Choosing the limit. Start at runtime.NumCPU() for CPU-bound work and roughly double that for I/O-bound, then measure.” And §17.4.7 repeats it: “Chapter 14 gave the starting heuristic and it stands: runtime.NumCPU() for CPU-bound work, roughly double for I/O-bound, then measure.”

Not GOMAXPROCS. NumCPU. And runtime.NumCPU is documented as reporting the logical CPUs “usable by the current process”, queried from the operating system at process startup — the doc comment says so at debug.go:151-153 and the body is a single load of the variable it recorded — which on Linux does not account for a cgroup CPU quota.

Derived in a container limited to 2 CPUs on a 64-core host, NumCPU() returns 64 while GOMAXPROCS(0) returns 2. A worker pool built on the book’s own advice is thirty-two times too wide from the first second it runs — not from staleness, not from a race, but from reading a number that describes the host rather than the allowance.

That over-provisioning is not harmless. Thirty-two times the intended concurrency means thirty-two times the in-flight memory, thirty-two times the pressure on whatever downstream Chapter 17 told you to protect, and a queue at the CPU that the Go scheduler will handle correctly and slowly.

This is the harder of the two bugs in this section, and it is the one this book owes you.

It has nothing to do with Go 1.25. It has been wrong for as long as containers have had quotas, and the reason it survived is that NumCPU is the older, more familiar call and reads like the obvious question to ask.

21.7.2 And a Second Bug, Which Only Appeared in Go 1.25

Fix the first bug naively — swap NumCPU() for GOMAXPROCS(0) at construction — and you land in the second.

Since Go 1.25 the runtime re-derives GOMAXPROCS from the quota while your program runs (§21.2.3). So a width read once, at startup is a value that can go stale: a service whose quota is cut mid-incident keeps scheduling at its old width, which is precisely the moment you would want it to narrow.

These are different bugs and they want distinguishing:

What is wrong
Introduced by
Symptom
Whose advice

21.7.3 And a Third Trap, If You Try to Make It Stop Moving

The natural response to a value that moves is to pin it. That is the trap §21.2.3 named:

snippet_217_x_1.go
// Illustrative snippet — not a complete program
// ✗ BROKEN: pins the value AND disables automatic updates
runtime.GOMAXPROCS(n)

Calling GOMAXPROCS with n >= 1 sets sched.customGOMAXPROCS, and from then on the runtime stops re-reading the quota entirely. You have traded a value that tracks reality for one that does not, in order to avoid having to read it twice.

Worse, at debug.go:83 the flag is set before the n == ret early return at :90 — so pinning to the value it already had disables updates while changing nothing observable. There is no exported way to ask whether automatic updates are still on; sched.customGOMAXPROCS is unexported, and the /godebug/non-default-behavior/updatemaxprocs counter tracks only the GODEBUG path, not the call path.

That is a genuine gap and worth stating plainly

the runtime gives you no way to discover that a dependency, a framework, or a five-year-old line in your own main has quietly opted your process out of automatic GOMAXPROCS updates. If you need certainty, call runtime.SetDefaultGOMAXPROCS() at startup — it restores the default behaviour whether or not anything had disabled it, and it costs one call.

THREE WAYS TO GET THE WIDTH WRONG

Three sizing bugs in sequence. Calling NumCPU at construction returns 64 in a 2-CPU container and is wrong from the first second — that is the book’s own advice. Fixing that naively by calling GOMAXPROCS with zero at construction is right at startup and stale after a quota change; nobody recommends it, you arrive there by fixing the first bug. Then trying to make it stop moving by calling GOMAXPROCS with a number disables the runtime’s updates forever, even when you pin the same value it already had, and nothing in Go can observe that it happened.

21.7.4 The Posture That Works

Three rules, in order of how much they matter.

Read GOMAXPROCS(0), not NumCPU(). It is the number the runtime will actually schedule, and it is container-aware. NumCPU remains correct for the question it answers — how much hardware exists — which is rarely the question you have.

Read it where the width is decided, not where the work is done. §21.2.4 measured GOMAXPROCS(0) at 12.8 ns serial and 36.3 ns at 16-way, because it takes the global scheduler lock. Once per batch, once per pool resize, once per period — fine. Once per item on a hot path — a global lock on your fastest code, and the one the P design exists to avoid.

If you pin it, pin it deliberately and say so. A benchmark, a reproduction, a workload with a measured better width: all legitimate. Acquiring the opt-out as a side effect of an environment variable somebody set in 2019 is not.

Derived a reasonable default for a long-lived pool is to re-read the width when it next matters — at the top of a batch, or on a timer of a second or so, which matches the runtime’s own update cadence: sysmon re-derives the default at most once per second (lastgomaxprocs+1e9 <= now, proc.go:6638), and the documentation states the same bound at debug.go:66-67. That costs one lock acquisition per period and removes both bugs.

21.7.5 When to Stop

The last thing this chapter owes you is a boundary, because it has spent seven sections reading runtime source and the honest position is that most of it will not stay true.

Not durable. The 256-entry run queue, the 61-tick global check, four steal attempts, the 10 ms preemption slice, runnext as a single slot, sudog pooling, per-P gFree lists. Every one is an implementation detail of go1.27.1. Several changed within the last few releases and several will change again. A reader who memorises 61 has acquired trivia with a half-life.

Durable. The shape:

And the observables, which outlast both: GODEBUG=scheddetail, /sched/goroutines/*, /sched/threads/total, /sched/gomaxprocs, /sched/latencies, and the four trace-derived profiles from §19.5.2. Those tell you what the current runtime is doing without requiring you to know how this year’s version implements it.

The test, one last time.

An internals fact earns its place if it explains an observation or changes a decision. The shape above does both, repeatedly. The constants do neither — they explain observations you will not make and change decisions you should not be taking. When the next release renumbers them, the shape will still be right, and that is the difference between reading the runtime and memorising it.

21.7.6 Common Mistakes

Sizing anything from NumCPU()
Problem

Over-wide by the quota ratio in a container

Fix

GOMAXPROCS(0); it is container-aware

Reading the width once at construction
Problem

Correct at startup, wrong after a quota change

Fix

Re-read where the width is decided

Pinning GOMAXPROCS for stability
Problem

Automatic updates silently disabled

Fix

SetDefaultGOMAXPROCS(), deliberately

Assuming a same-value pin is a no-op
Problem

Updates off, nothing observable changed

Fix

The flag is set before the no-op check

GOMAXPROCS(0) per task
Problem

A global lock on the hot path

Fix

Per batch or per period

Memorising 61, 256, 4, 10 ms
Problem

Confident recall that expires

Fix

Learn the shape and the observables

Treating this chapter as prerequisite
Problem

Twenty chapters say otherwise

Fix

It is for boundaries, not basics

Summary: What This Changes About Code You Already Wrote

Two chapters of this book told you to size pools from runtime.NumCPU(). That call is host-scoped and cgroup-blind, so in a 2-CPU container on a 64-core host it returns 64 — a pool thirty-two times too wide from the first second, with no staleness involved. That is the harder bug and it is the book’s.

Fixing it naively lands you in the second: since Go 1.25 GOMAXPROCS is re-derived while the program runs, so a width read once at construction can go stale exactly when a quota is cut.

And pinning the value to stop it moving is a third trap, because that disables the automatic updates — including when you pin it to the value it already had, since the flag is set before the no-op check. There is no exported way to ask whether updates are still on.

The posture: read GOMAXPROCS(0) rather than NumCPU(), read it where the width is decided rather than per item, and pin only deliberately.

And the boundary. The constants in this chapter are implementation details with a half-life. The shape — per-P queues, pull-based stealing, blocking that costs a thread when the poller cannot express it, threads as the fatal resource, preemption anywhere, GOMAXPROCS as a moving bound on Go execution — and the observables are what last.

Self-Check Questions: What This Changes About Code You Already Wrote

Your service runs NewPool(runtime.NumCPU()) and has done for two years without incident. It is now being moved into a 4-CPU container on a 96-core host. What happens on the first deploy, and what will the symptom look like?

The pool is created with 96 workers where 4 was intended — twenty-four times too wide — and the symptom will not look like a concurrency bug.

NumCPU reports the machine’s logical CPUs as queried at process start. The cgroup quota restricts how much CPU time the process gets, not which CPUs exist, so NumCPU returns 96 regardless. GOMAXPROCS(0) returns 4, because since Go 1.25 the runtime accounts for the quota.

What you will observe is latency, not errors. Ninety-six goroutines doing CPU-bound work on a 4-CPU allowance means every one of them is runnable and waiting most of the time — /sched/goroutines/runnable climbs and /sched/latencies degrades (§19.4.2), which is the scheduler correctly rationing a resource that was over-subscribed by the application.

The secondary effects are often worse than the primary. Twenty-four times the intended concurrency means twenty-four times the in-flight memory, and twenty-four times the load on whatever downstream Chapter 17's limiter was sizing itself against — so the first thing to break may be a dependency rather than this service.

The fix is one call, runtime.GOMAXPROCS(0), and the reason to be annoyed about it is that two chapters of this book recommended the wrong one. What makes it hard to catch is that it is invisible outside a container: on a bare host with no quota the two calls return the same number, and every test passes.

Why is pinning GOMAXPROCS to the value it already has not a no-op?

Because the flag that disables automatic updates is set before the code checks whether the value is changing.

Reading $GOROOT/src/runtime/debug.go: sched.customGOMAXPROCS = true is at line 83, and the if n == ret early return — the check for “this is the value we already have” — is at line 90. So by the time the runtime notices there is nothing to do, it has already recorded that you supplied a custom value, and it will not re-derive the default again.

The consequence is a silent, permanent behaviour change with no observable trace. Nothing about the process differs immediately: the value is the same, the scheduler behaves the same, the metric reads the same. What has gone is the runtime’s willingness to notice a quota change later.

Worse, there is no way to detect it from Go. sched.customGOMAXPROCS is unexported with no accessor, and the /godebug/non-default-behavior/updatemaxprocs counter tracks only the GODEBUG route, not the function-call route. A library can disable this for your whole process and you cannot ask whether it did.

Which makes the practical advice unusually blunt: if you care about the automatic updates, call runtime.SetDefaultGOMAXPROCS() at startup. It restores the behaviour whether or not anything had disabled it, costs one call, and is the only way to be certain.

Which facts from this chapter would you expect to still be true in five years, and which would you not?

Not durable: every number. The 256-entry local queue, the 61-tick global check, four steal passes, the 10 ms preemption slice, runnext as a single slot, the specific pooling of sudogs and gs. These are tuning decisions in one implementation, several have changed recently, and there is no compatibility promise on any of them. The GOMAXPROCS default itself changed in 1.25, which is the strongest available evidence that this layer moves.

Durable: the shape. Work queues are per-processor because a global one does not scale. Idle processors pull work and busy ones do not push it. Blocking is free when the runtime can express the wait and costs a thread when it cannot. Threads are the scarce resource with a fatal ceiling. A goroutine can be interrupted anywhere. GOMAXPROCS bounds Go execution rather than threads.

Those survive because they are consequences of the design’s constraints rather than choices within it. A future scheduler might use a different queue size or a different steal policy; it will still need per-processor structures, and it will still have to do something specific when a goroutine enters a syscall.

Most durable of all are the observables — scheddetail, the /sched/* metrics, the trace-derived profiles — because they are an interface rather than an implementation. They will tell you what next year’s runtime is doing without your having to know how it does it, which is exactly the property you want from knowledge that is expensive to acquire and decays.

Key Takeaways

  • §14.4.4 and §17.4.7 both say runtime.NumCPU(), which is host-scoped and cgroup-blind
  • Derived: in a 2-CPU container on a 64-core host that is a pool 32× too wide from the first second
  • The second, separate bug is staleness: since Go 1.25 GOMAXPROCS moves while the program runs
  • The third trap is pinning it, which disables the automatic updates — even when you pin the current value
  • The flag is set at debug.go:83, before the no-op check at :90, and there is no exported reader
  • Read GOMAXPROCS(0), read it where the width is decided, and pin only deliberately
  • Not durable: 256, 61, 4, 10 ms, runnext, the pools. Durable: the shape and the observables
Section 21.7 — in one line

This book told you to size pools from a number that describes the host rather than your allowance, and fixing it correctly means reading a value that no longer holds still.

Chapter Summary

The runtime spends its entire design budget making a blocked goroutine cost nothing, and every performance surprise in this book is a place where that budget ran out. That framing carries the chapter: each section is a mechanism, the boundary where it stops being free, and what crossing that boundary costs.

The chapter opened with five artifacts this book had already printed and left unexplained — and two of the explanations it had given were wrong.

Three resources, three ceilings. Gs are millions and run out as memory. Ps are GOMAXPROCS and running out is what a scheduler is for. Ms stop at 10,000, and measured by lowering the limit to 20, exceeding it prints fatal error: thread exhaustion and the process dies. P exists so the run queue can be local; one global queue meant one global lock, which is what did not scale before Go 1.1.

GOMAXPROCS is no longer the constant everyone learned. Since Go 1.25 it is derived from logical CPUs, the CPU affinity mask and the cgroup quota; it updates while the program runs; and its behaviour is gated on the language version in your own go.mod. §1.5 already carries the first of those three and ends at “do nothing”; the other two are new here, and together they make setting the value yourself a trap rather than a fix, because doing so disables the updates that now do the job. GOMAXPROCS(0) is a pure read — the n <= 0 branch returns before the flag is set — but it still takes the global scheduler lock: measured at 12.8 ns serial and 36.3 ns at 16-way, while NumCPU does not measure above an empty benchmark loop. And the write path is heavier than a lock: GOMAXPROCS(n) stops the world to resize (debug.go:96), though a same-value pin returns before the pause while still setting the flag — free to call, permanent in effect, and invisible either way.

Work stealing is conservation, not balancing. stealWork has exactly one caller, inside findRunnable, so a busy P never steals. §19.4.3's uneven per-P queues had idleprocs=0 — no steal was ever attempted, and the imbalance was the expected steady state rather than a scheduler failing. measured, 200 tasks finish in 13 ms when Ps are free to steal and in 101 ms, the serial bound, when seven of eight are held by running goroutines.

Blocking is free until it isn’t — and “blocking” is narrower than it sounds. A syscall keeps its P attached, and sysmon takes it after one tick — twenty microseconds, proc.go:6744 — unless the P’s queue is empty and another processor is idle and the call is younger than ten milliseconds. That last clause is an exemption, not a threshold, which inverts the folklore: a blocking syscall costs a thread for as long as it is in flight, and thread count is very nearly blind to how long each call takes. Measured at GOMAXPROCS=4, fifty-microsecond calls cost 128 threads at 128 in flight while twenty-millisecond calls cost none at two. Derived, thread count is in-flight count, which is §17.1.3's Little’s Law — rate times duration — so a slower filesystem raises threads at an unchanged request rate and nobody deployed anything. Measured: 500 goroutines blocked on sockets cost about one extra thread; 64 in a genuinely blocking syscall cost about sixty-two, and the threads do not come back because Ms are cached. A syscall keeps its M and loses its P, which preserves parallelism and grows the thread count. Three things cost a thread — poller-inexpressible syscalls, cgo, and LockOSThread — and they are the three the SetMaxThreads documentation names in the function that will kill your process.

Preemption took a decade and one variable still undoes it. Measured: at GOMAXPROCS=1 a call-free tight loop lets main resume 7–10 ms late — inside the 10 ms forcePreemptNS slice — and never resume at all under GODEBUG=asyncpreemptoff=1. And the important half is not fairness: at GOMAXPROCS=4 with three processors idle, twenty collections that complete in under half a millisecond by default never complete at all under the flag, because stop-the-world needs every goroutine at a safe point. Derived, async preemption is what bounds stop-the-world pauses — which is what makes the collector’s latency guarantees possible — and it is the mechanism behind §19.4.4's stopping-versus-total ratio. Chapter 10's deadlock-detector table is missing a row for a goroutine that is simply running, which is why production almost never reports a deadlock — though the precise rule is that the runtime suppresses whenever it can see a reason to expect progress, and a pending timer is one. A goroutine in a syscall is not preempted; measured at GOMAXPROCS=1, its P is retaken instead.

And the cheap things are cheap for findable reasons. Stacks grow by copying, which is why their addresses are not stable. measured, the same descent run twice in one goroutine costs 48× more the first time at depth 16 and 11× at depth 32,768 — and derived, that falling ratio is the signature of geometric growth, log₂(depth) copies against depth-proportional work. So the expensive pattern is shallow growth across many short-lived goroutines rather than deep growth in one. A dead goroutine’s stack is freed unless it is exactly the starting size (proc.go:5523) — and measured, that starting size is itself recomputed at every collection, moving between 2 KB and 256 KB inside one process — so you can pool the goroutine and not the stack it grew. measured, ten thousand parked goroutines add 2,081 bytes of stack each, which is §14.4.4's stack half to the byte. A channel is a mutex around a ring buffer and two wait queues, which is why it appears in the mutex profile — though its flat leaf there is runtime.unlock, so the block profile is the one that names it — and a send to a waiting receiver copies directly into the receiver’s stack.

That last mechanism is real and it does not make unbuffered channels fast. measured, streaming costs 184.2 ns at capacity 0 against 42.7 ns at capacity 64 — four times slower — after which the curve flattens and capacity 1024 buys nothing. The true statement is narrower: when the pattern forces a rendezvous, buffering buys nothing, measured at 355, 357 and 374 ns for capacities 0, 1 and 64.

Finally, the debt. Two chapters told you to size pools from runtime.NumCPU(), which is host-scoped and cgroup-blind — derived, thirty-two times too wide in a 2-CPU container on a 64-core host, from the first second. Fixing it naively finds the staleness bug; pinning the value to avoid that disables the runtime’s updates, even when you pin the value it already had.

Chapter Connections

How Chapter 21 connects
Chapter 1
§1.4 and §1.5 introduce everything here, name this chapter once, and defer the thread question to §1.5; §21.2.3 adds the go.mod gating and the opt-out that §1.5 does not carry
Chapter 2
§2.5's ~2 KB is the stack half of §21.6.3's measurement; §2.4's states are the vocabulary §19.2.1 corrected
Chapter 5
§5's buffered-versus-unbuffered argument finally gets a mechanism (§21.6.4) and a measurement that narrows it (§21.6.5)
Chapter 7
§21.6.2 gives an independent reason for its worker pool: grown stacks are not pooled, so long-lived workers amortise growth and per-item goroutines never do
Chapter 9
A channel is a lock, which is why §9's contention material covers channels without saying so (§21.6.4)
Chapter 10
§10.4's detector table is missing a row, added in §21.5.4 — the chapter’s second correction
Chapter 12
sync.Pool's bet is the same one the runtime makes with per-P gFree lists (§21.6.2)
Chapter 13
§21.3.5 makes timerslen a second view of §13.3.7's missing defer cancel(), from the scheduler’s side
Chapter 14
§14.4.4's 2.7 KB is reconciled in §21.6.3; its NumCPU advice is corrected in §21.7.1
Chapter 17
§17.4.7 repeats the NumCPU advice; §17.1.3's Little’s Law is the arithmetic behind §21.4.3; and §21.4.6's fatal thread ceiling is the sharpest argument for admission control in the book
Chapter 19
§19.4.3's explanation of uneven queues is corrected in §21.3.2; §19.4's metrics habit extends to /sched/threads/total; scheddetail is §19.4.3's instrument turned up
Chapter 20
Takes the mechanisms here and turns them into decisions
Chapter 22
Builds on the sizing posture of §21.7.4 rather than on any constant in this chapter

Final Checklist

Before moving to Chapter 22, ensure you can:

Exercise 21.1 — The Pool That Asked the Wrong Question

Your move

The Pool That Asked the Wrong Question

This pool is correct in every way the earlier chapters taught you to check. It compiles, go vet is clean, and go test -race finds nothing. Its sizing line is copied from the advice this book gave you in two separate chapters.

It is thirty-two times too wide in a container, and it cannot notice.

The first bug is §21.7.1, and it is the book’s fault. runtime.NumCPU() reports the logical CPUs of the machine, queried at process start; it does not know about a cgroup quota. In a 2-CPU container on a 64-core host it returns 64.

The second is §21.7.2, and it only became a bug in Go 1.25. GOMAXPROCS is now re-derived from the quota while the program runs, so a width read once at construction is a value that can go stale precisely when a quota is cut.

ch21/pool.go
// Package ch21 is the exercise for Chapter 21: Scheduler and Runtime
// Internals.
//
// Pool runs tasks at a width derived from the machine's parallelism.
// It is meant to hold three promises:
//
//   - the width matches what the runtime will actually schedule
//   - the width follows that value when it changes
//   - reading the width is not on the per-task path
//
// TODO(reader): this compiles, vets clean, and has no data race. Its
// sizing line is the one Chapters 14 and 17 recommended. Two tests
// prove it wrong and two more guard the attractive fixes.
package ch21

import (
	"runtime"
	"sync"
	"sync/atomic"
)

// Pool runs tasks at a width derived from the machine's parallelism.
type Pool struct {
	mu      sync.Mutex
	workers int

	// reads counts how many times Width has produced a width, and so
	// -- once Width consults the runtime -- how many times it did.
	// Gate 4 asserts this does not grow per task. Keep the increment
	// where it is when you change what Width returns.
	reads atomic.Int64
}

// New builds a Pool sized for this machine.
func New() *Pool {
	return &Pool{workers: runtime.NumCPU()}
}

// Width reports how many tasks the pool will run concurrently.
func (p *Pool) Width() int {
	p.reads.Add(1)
	p.mu.Lock()
	defer p.mu.Unlock()
	return p.workers
}

// Reads reports how many times the pool produced a width.
func (p *Pool) Reads() int64 { return p.reads.Load() }

// Run executes tasks with at most Width() running at once.
func (p *Pool) Run(tasks []func()) {
	sem := make(chan struct{}, p.Width())
	var wg sync.WaitGroup
	for _, t := range tasks {
		wg.Add(1)
		sem <- struct{}{}
		go func(t func()) {
			defer wg.Done()
			defer func() { <-sem }()
			t()
		}(t)
	}
	wg.Wait()
}

The four gates:

ch21/pool_test.go
package ch21

import (
	"runtime"
	"sync/atomic"
	"testing"
)

// withGOMAXPROCS sets GOMAXPROCS for one test and undoes it after.
// Restoring the old value would not be enough: GOMAXPROCS(n) also
// sets the sticky flag that disables the runtime's automatic updates,
// and nothing exported can clear it. SetDefaultGOMAXPROCS restores
// the value and the automatic behaviour together, which is gate 3's
// lesson applied to the test that teaches it.
func withGOMAXPROCS(t *testing.T, n int) {
	t.Helper()
	runtime.GOMAXPROCS(n)
	t.Cleanup(runtime.SetDefaultGOMAXPROCS)
}

// Gate 1: width must come from GOMAXPROCS, not NumCPU.
func TestWidthUsesGOMAXPROCSNotNumCPU(t *testing.T) {
	withGOMAXPROCS(t, 2)
	p := New()
	if got := p.Width(); got != 2 {
		t.Fatalf("width %d with GOMAXPROCS=2 (NumCPU=%d)\n\n"+
			"  runtime.NumCPU reports the machine's CPUs, queried at\n"+
			"  process start and blind to cgroup quota. In a 2-CPU\n"+
			"  container on a 64-core host it returns 64 -- a pool\n"+
			"  over-provisioned 32-fold from the first second.",
			got, runtime.NumCPU())
	}
}

// Gate 2: width must follow GOMAXPROCS while the program runs.
func TestWidthFollowsChanges(t *testing.T) {
	withGOMAXPROCS(t, 2)
	p := New()
	runtime.GOMAXPROCS(4)
	if got := p.Width(); got != 4 {
		t.Fatalf("width stayed %d after GOMAXPROCS went to 4\n\n"+
			"  Since Go 1.25 the runtime re-derives GOMAXPROCS from\n"+
			"  the cgroup quota while the program runs. A width read\n"+
			"  once at construction caches a value that moves.", got)
	}
}

// Gate 3: guards the wrong fix -- do not pin GOMAXPROCS.
func TestPoolDoesNotPinGOMAXPROCS(t *testing.T) {
	withGOMAXPROCS(t, 3)
	p := New()
	p.Run([]func(){func() {}, func() {}})
	_ = p.Width()
	if got := runtime.GOMAXPROCS(0); got != 3 {
		t.Fatalf("GOMAXPROCS is %d; the test set it to 3\n\n"+
			"  runtime.GOMAXPROCS(n) with n >= 1 both sets the\n"+
			"  value AND disables the runtime's automatic updates.\n"+
			"  Reading requires GOMAXPROCS(0), which is a pure read.",
			got)
	}
}

// Gate 4: guards the other overshoot -- do not consult per task.
func TestWidthIsNotReadPerTask(t *testing.T) {
	withGOMAXPROCS(t, 4)
	p := New()
	before := p.Reads()
	var ran atomic.Int64
	tasks := make([]func(), 500)
	for i := range tasks {
		tasks[i] = func() { ran.Add(1) }
	}
	p.Run(tasks)
	if grew := p.Reads() - before; grew > 10 {
		t.Fatalf("consulted the runtime %d times for 500 tasks\n\n"+
			"  runtime.GOMAXPROCS(0) takes the global scheduler lock\n"+
			"  and gets slower under parallelism: 12.8ns serial,\n"+
			"  36.3ns at 16-way. Read it where the width is decided,\n"+
			"  not once per item.", grew)
	}
}

Run it:

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

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

Terminal
--- FAIL: TestWidthUsesGOMAXPROCSNotNumCPU (0.00s)
    pool_test.go:26: width 16 with GOMAXPROCS=2 (NumCPU=16)
          runtime.NumCPU reports the machine's CPUs, queried at
          process start and blind to cgroup quota. In a 2-CPU
          container on a 64-core host it returns 64 -- a pool
          over-provisioned 32-fold from the first second.
--- FAIL: TestWidthFollowsChanges (0.00s)
    pool_test.go:41: width stayed 16 after GOMAXPROCS went to 4
          Since Go 1.25 the runtime re-derives GOMAXPROCS from
          the cgroup quota while the program runs. A width read
          once at construction caches a value that moves.
FAIL
FAIL corebackend.dev/go-concurrency/ch21 0.662s
Done when: go test -race ./... in code/ch21/ reports ok for all four, and keeps reporting it under -count=10.

One fix, two failures, and two traps on the way out. Both failing gates have the same root — the pool asks the wrong question at the wrong time — and a single change fixes both: read runtime.GOMAXPROCS(0) inside Width() rather than runtime.NumCPU() in New(). That is a smaller edit than the two gates suggest, and the interest is entirely in the two ways it can go wrong.

Gate 3 catches the fix that looks stable. Having discovered the value moves, the natural instinct is to stop it moving by calling runtime.GOMAXPROCS(n). Measured by applying it: that fails gate 3 and both original gates, because it disables the runtime’s automatic updates — and it is heavier than it looks, since GOMAXPROCS(n) stops the world to resize (debug.go:96) rather than merely taking a lock.

And gate 3 has a hole, which is the most useful thing in this exercise. §21.7.3's finding was that runtime.GOMAXPROCS(n) disables updates even when n is the value the process already had, because the flag is set at debug.go:83 and the no-op check is at :90. A solution that reads the width correctly and then pins it to itself —

n_217.go
// Illustrative snippet — not a complete program
n := runtime.GOMAXPROCS(0)
p.workers = runtime.GOMAXPROCS(n) // "pin" to the same value

passes all four gates, verified by running it, with automatic updates permanently off. Gate 3 asserts on the value, and a same-value pin leaves the value alone. That is not a flaw in the gate; it is the gap being demonstrated. No test can close it, because sched.customGOMAXPROCS has no exported reader, which is exactly why runtime.SetDefaultGOMAXPROCS() at startup is the only defence that does not depend on nobody having made this call.

Gate 4 catches the fix that looks thorough. Having discovered the value should be re-read, the natural instinct is to re-read it everywhere, including per task. Measured: routing that through Width() fails gate 4. GOMAXPROCS(0) takes the global scheduler lock — the one thing P was introduced to avoid (§21.2.2) — at 12.8 ns serial and 36.3 ns under 16-way parallelism. Putting it on a per-item path is a global lock on your hottest code.

Note where the starter puts the counter, and leave it there. reads is incremented in Width() rather than in New() precisely so that the gate measures the accessor you are about to rewrite: move the increment and you disable the gate that was watching you. What it still cannot see is a solution that calls runtime.GOMAXPROCS(0) directly from inside a task without going through Width() at all — a limit worth knowing rather than a hole to plug, since the fix this section is teaching is the one the gate catches.

Where the files are: labs/go-concurrency/code/ch21/. A worked answer sits in solution/pool.go.txt, including why Width() clamps to a minimum of one, why the test helper cleans up with runtime.SetDefaultGOMAXPROCS() rather than by restoring the old value — the setting is process-global and its flag is sticky for the life of the process, so putting the number back does not put the behaviour back — and why the same test would be much harder to write against NumCPU, which nothing can change.

Further Reading

Next

You can now name which of the three resources a symptom belongs to, read a thread count as in-flight work rather than as load, tell the two kinds of blocking apart before you measure them, and say why a value the runtime keeps re-deriving cannot be read once at startup. Every mechanism in this chapter arrived as an artifact from an earlier page. Chapter 22 is where they all run at once: three systems built end to end, with the whole book in them.