The Go runtime from a systems programmer's seat: the scheduler, the collector, and the netpoller
go-runtime-for-systems is a single Go binary that observes its own runtime across four themes -- the GMP scheduler and signal-based async preemption, GC pacing under GOGC/GOMEMLIMIT with Go 1.26's Green Tea collector on by default, the epoll-backed netpoller that turns 2,000 blocked goroutines into roughly a dozen OS threads, and the effective knobs a process is actually running under -- each theme printing a self-computed ok/FAIL verdict. Verified on the Fedora 44 host on go1.26.5 with verify.lua PASS 16/FAIL 0, and the same preemption check flipped to FAIL on demand by GODEBUG=asyncpreemptoff=1.
Chapter 43 spent its whole budget on Rust’s compiler doing extra work at
build time – a proc-macro crate that runs once, produces tokens, and gets
out of the way. This chapter is the mirror image: a runtime that never gets
out of the way, because it is doing something every second the program
runs. Every earlier chapter that used Go leaned on its runtime without
looking at it – goroutines that seemed to run in parallel, garbage that
seemed to collect itself, sockets that seemed to block without blocking
anything. go-runtime-for-systems is the chapter where that runtime stops
being invisible: a single stdlib-only binary that asks its own scheduler,
collector, and netpoller direct questions and prints the answers as
computed verdicts – ok or FAIL, never just an exit code. It’s also the
last chapter of Part 12: the Rust-only dive into macros and miri, and
this Go-only look under the runtime’s hood, are the two language-specific
excursions the tri-language chapters made room for.
Tools used —
go build/go run(host, thego 1.26.xcheck inscripts/check-host.sh).GODEBUGis a runtime environment variable the Go binary itself reads at startup, not a separate tool this chapter installs or shells out to;/proc/self/statusis opened and parsed by the program’s ownreadThreadCounthelper (below), not scraped by a host command. No VM, no root, no LGTM —examples/44-go-runtime-for-systemsismode: localinexamples/manifest.yaml.
The GMP model: many goroutines, few threads
Go’s scheduler has three kinds of object, and the names are the whole
mental model. A G is a goroutine: a small growable stack, an
instruction pointer, and a status — cheap enough that sched spawns
thousands without a second thought. An M is a machine: an actual OS
thread, the thing clone(2) creates. A P is a processor: a
scheduling context, exactly GOMAXPROCS of them, and the resource token
that makes this an M:N scheduler — an M cannot run Go code without
holding a P. Each P owns a local run queue (a 256-slot ring buffer plus a
single-slot runnext fast path); a global run queue is the overflow and
injection target every P eventually checks. When a P runs dry, it
steals — findRunnable/stealWork picks a random other P and takes
half its local queue, not one goroutine at a time.
None of that explains how a tight loop with no yield point ever gets
interrupted. Before Go 1.14 it didn’t: a bare for {} could wedge the
runtime indefinitely (golang/go#10958). The fix is sigPreempt =
_SIGURG — chosen because no real program uses SIGURG for anything
else. preemptone() sets a cooperative flag first, then, if async
preemption is enabled, calls preemptM, sending SIGURG to the specific
thread running the goroutine; the signal handler redirects into
asyncPreempt, which saves registers, parks the goroutine at a safe
point, and lets the scheduler run something else. sched proves this
exists: with runtime.GOMAXPROCS(1) forced, one goroutine spins
2,000,000,000 iterations of pure integer arithmetic — comfortably past
the runtime’s 10ms forced-preemption window — while a second does
nothing but increment a counter in its own tight loop. On a purely
cooperative scheduler the second goroutine would never run at all under
one P; this run’s ticker_incr=388757095 says it ran the whole spin.
// --- preempt: async (signal-based) preemption under GOMAXPROCS=1 ---
const spinnerOps = 2_000_000_000
origProcs := runtime.GOMAXPROCS(1)
var spinDone int32
var spinSink uint64
var tick int64
// The ticker goroutine is launched first and the spinner second so that
// the spinner -- the most recently spawned goroutine -- claims the P's
// single-slot "runnext" fast path and is what the scheduler runs first
// when main below yields. This keeps the outcome deterministic instead
// of depending on which of two equally non-yielding goroutines happens
// to win an unspecified scheduling race.
go func() {
for atomic.LoadInt32(&spinDone) == 0 {
atomic.AddInt64(&tick, 1)
}
}()
go func() {
// Pure integer arithmetic: no function calls, no channel ops --
// nothing that is a cooperative yield point. Only Go's async
// (signal-based) preemption can interrupt this goroutine.
var x uint64 = 0x9E3779B97F4A7C15
for i := int64(0); i < spinnerOps; i++ {
x = x*6364136223846793005 + 1
}
atomic.StoreUint64(&spinSink, x)
atomic.StoreInt32(&spinDone, 1)
}()
for atomic.LoadInt32(&spinDone) == 0 {
runtime.Gosched()
}
tickVal := atomic.LoadInt64(&tick)
runtime.GOMAXPROCS(origProcs)
preemptOk := tickVal > 0
fmt.Printf("sched: preempt forced_gomaxprocs=1 spinner_ops=%d ticker_incr=%d ticker_incr>0 %s\n",
spinnerOps, tickVal, verdict(preemptOk))
ok = ok && preemptOk
The goroutine that spins is launched second, deliberately: the most
recently spawned goroutine claims runnext, so it — not the ticker — is
guaranteed to run first when main yields below. Behind this sits
sysmon, a dedicated M with no P of its own, looping with a
20µs-to-10ms doubling backoff. Each tick it can retake a P stuck in a
syscall or a goroutine past forcePreemptNS (10ms), force a GC if none
has run in two minutes, trigger the SIGURG preemption above, poll the
network as a backstop if nobody has in over 10ms, and — new since Go
1.25 — re-evaluate GOMAXPROCS at most once a second. That last item is
the other headline scheduler change here: Go 1.25 made the default
GOMAXPROCS container-aware, reading cgroup v2 cpu.max (or v1’s
quota/period pair) and taking the minimum of logical CPU count, affinity
mask, and cgroup throughput limit — floor of 2, fractional limits round
up. GODEBUG=containermaxprocs=0 reverts to the old, NumCPU()-only
behavior; Go 1.26 adds only observability metrics on top, no algorithm
change. On this 16-CPU host with nothing limiting this process, knobs:
GOMAXPROCS=16 NumCPU=16 — nothing narrows either.
The pacer’s bargain: GOGC, GOMEMLIMIT, and Green Tea
Go’s collector is concurrent, tri-color, mark-and-sweep, and
non-generational: marking runs alongside the mutator, bounded by two
short stop-the-world phases (sweep and mark termination, both normally
sub-millisecond), with a hybrid write barrier keeping the tri-color
invariant intact. The pacer decides when to start the next cycle, and
GOGC is the dial: target heap size is Live + (Live + roots) * GOGC /
100, so GOGC=100 (the default) roughly doubles the live heap before
the next collection. Loosen GOGC and the pacer waits longer — double
the memory overhead, roughly halve the GC CPU cost. GOGC=off
(debug.SetGCPercent(-1)) removes the percentage trigger entirely,
exactly what gc does for its whole run, so every NumGC delta below is
caused only by an explicit call this program makes.
func measureCycles(pct int) int {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
prev := debug.SetGCPercent(pct)
churn()
runtime.ReadMemStats(&after)
debug.SetGCPercent(prev)
return int(after.NumGC - before.NumGC)
}
func measureCyclesMemLimit(limit int64) int {
runtime.GC()
prevPct := debug.SetGCPercent(800) // held fixed and loose in both runs
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
prevLimit := debug.SetMemoryLimit(limit)
churn()
runtime.ReadMemStats(&after)
debug.SetMemoryLimit(prevLimit)
debug.SetGCPercent(prevPct)
return int(after.NumGC - before.NumGC)
}
measureCycles isolates the GOGC effect: normalize with one
runtime.GC(), set a percent, run a fixed 64 MiB churn workload, read the
NumGC delta. Run at 100 and at 800 against identical churn, and this
session shows the textbook shape: gogc100_cycles=21 against
gogc800_cycles=2 — the looser percent needs a tenth as many collections
for the same garbage. measureCyclesMemLimit isolates a second knob,
GOMEMLIMIT (debug.SetMemoryLimit, since Go 1.19): a soft ceiling
on runtime-managed memory, respected even with GOGC=off — why “GOGC
off, GOMEMLIMIT set” is coherent rather than contradictory: don’t collect
on a heap-growth ratio at all, only when about to breach an absolute
ceiling. The docs call it deliberately soft (“no guarantees… only some
reasonable amount of effort”), since a hard limit risks the GC thrashing
to stay under it. Holding GOGC=800 fixed and varying only the limit —
unset (math.MaxInt64) against a tight 8 MiB — this session measured
memlimit_unset_cycles=2 against memlimit_tight_cycles=122: the tight
limit forces sixty times as many collections as GOGC alone would ever
trigger.
Go 1.26 changes what’s doing the marking, without touching any of the
above. Green Tea, opt-in in Go 1.25 (GOEXPERIMENT=greenteagc), is
enabled by default in 1.26 — the release notes: “now enabled by default
after incorporating feedback.” Instead of chasing individual object
pointers, it organizes marking around 8 KiB pages, scanning in memory
order with per-page bitmaps — cache-friendly, and on Ice-Lake/Zen-4-class
CPUs it also uses vector instructions to scan whole pages in a handful of
cycles. The measured effect, per Google’s benchmarks, is 10-40% less GC
CPU overhead on GC-heavy programs (if GC was 10% of total CPU, a 1-4%
total-CPU win, not 10-40%). An opt-out, GOEXPERIMENT=nogreenteagc,
still exists in 1.26 but is slated for removal in 1.27. None of this
shows in gctrace=1’s line format, which is unchanged; Green Tea is a
build-time signal only, visible via go version -m <binary> or
debug.ReadBuildInfo(), never in a trace line.
The netpoller: why 2,000 goroutines are not 2,000 threads
A classic blocking syscall and a network read look identical from inside
a goroutine — conn.Read(buf) either way — but they take different
paths through the runtime, which is why Go can run thousands of
concurrently-blocked goroutines on a double-digit thread count. A real
blocking read calls entersyscall, transitioning the goroutine to
_Gsyscall and optimistically keeping its P, betting the syscall
returns fast; if it doesn’t, sysmon’s retake notices — within ~20µs if
other work is waiting, otherwise within forcePreemptNS (10ms)
regardless — steals the P away, and handoffp wakes a parked M or, if
none is idle, clone(2)s a new one. Block N goroutines this way and the
thread count tracks N.
A network or pipe read on a non-blocking fd exits differently.
internal/poll’s read loop calls the raw syscall once; on EAGAIN it
calls pollDesc.waitRead → netpollblock → gopark, dropping the
goroutine into _Gwaiting, detached with no P or M held at all — its
identity is stored as a bare pointer inside the fd’s pollDesc, and the M
that was running it is immediately free to find other work. The wakeup
side is one process-wide epoll instance (epoll_create1 plus an
eventfd for the runtime’s own wake-itself-up calls), each fd armed
edge-triggered. The wrapper is named EpollWait but the syscall it
issues is epoll_pwait — what an strace -f on any Go binary shows,
never epoll_wait. findRunnable polls this netpoller opportunistically
before resorting to work-stealing, and blockingly when there’s nothing
else to do — an M with no work drops its P and calls netpoll(delay),
temporarily becoming the netpoller thread.
The netpoll theme measures exactly this: 2,000 os.Pipe() pairs, one
goroutine per pipe blocked on a Read of the unwritten end.
const numPipes = 2000
readers := make([]*os.File, numPipes)
writers := make([]*os.File, numPipes)
for i := 0; i < numPipes; i++ {
r, w, err := os.Pipe()
if err != nil {
fmt.Fprintln(os.Stderr, "netpoll: os.Pipe error:", err)
os.Exit(1)
}
readers[i] = r
writers[i] = w
}
var wg sync.WaitGroup
wg.Add(numPipes)
for i := 0; i < numPipes; i++ {
r := readers[i]
go func() {
defer wg.Done()
buf := make([]byte, 1)
r.Read(buf)
}()
}
Thread count is read from /proc/self/status’s own Threads: field — an
OS-level cross-check independent of anything the Go runtime claims about
itself:
// readThreadCount reads this process's own OS thread count from
// /proc/self/status's "Threads:" field. Self-observation only -- no external
// tool is shelled out to.
func readThreadCount() int {
f, err := os.Open("/proc/self/status")
if err != nil {
return -1
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "Threads:") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
return -1
}
v, err := strconv.Atoi(fields[1])
if err != nil {
return -1
}
return v
}
return -1
}
This session: netpoll: baseline goroutines=1 threads=12 before the
fan-out, then netpoll: parked goroutines=2000 baseline=1 total=2001 and
netpoll: parked threads=12 bound=80 threads<=bound ok after it — 2,000
more live goroutines, zero additional threads, well inside a bound
(4*GOMAXPROCS+16 = 80) set orders of magnitude below 2,000 so it only
fails if something regresses to one-thread-per-blocked-goroutine. Go 1.26
also removes something from this picture: a dedicated _Psyscall P
status. A P whose M was in a syscall used to carry that status directly;
as of 1.26, pp.status only ever takes
_Pidle/_Prunning/_Pgcstop/_Pdead, and whether a P’s M is really in
a syscall is read off the goroutine’s status instead
(setBlockOnExitSyscall) — the release notes attribute a ~30% cut in
baseline cgo-call overhead to this, a narrower change than the still-open
proposal for a second dedicated syscall thread per M (golang/go#58492).
How the code works
Every theme follows the same shape: compute something, print a
fixed-text comparison label with the measured numbers substituted in, and
end the line with a verdict word the program itself decides —
fmt.Printf("... %s\n", verdict(cond)), where verdict renders "ok"
or "FAIL" from a boolean. verify.lua matches the label text plus the
literal ok; if the condition flips, FAIL prints instead and the
pattern simply stops matching. The check is the program’s own
arithmetic, not the test’s, and three techniques above make that
arithmetic trustworthy rather than merely plausible:
debug.SetGCPercent(-1) turns “allocate then measure” into an exact
integer comparison, readThreadCount cross-checks against the kernel
rather than the Go runtime’s own bookkeeping, and the ticker-before-spinner
launch order removes the one unspecified scheduling race that would
otherwise exist. None of the four themes uses time.Sleep; where a
bounded settle is needed, pollNumGoroutine loops on runtime.Gosched()
up to a fixed retry cap, polling cooperatively rather than the wall
clock.
Errors, three ways
With only one language, “three ways” means three moments an error
surfaces, not three languages producing the same one. A computed
assertion: every <label> FAIL line is an error — not a panic, not a
bare non-zero exit, but the program’s own boolean, printed where a human
or verify.lua can see exactly which comparison failed. A wrapped
runtime error: readThreadCount returns -1 on any failure along its
path — the /proc/self/status open failing, the Threads: line
missing, strconv.Atoi choking on a malformed field — rather than
panicking, because a self-observation helper failing to observe itself
shouldn’t crash the process it’s describing; runNetpoll’s os.Pipe()
call, by contrast, is a genuine unrecoverable setup failure and calls
os.Exit(1), since 2,000 goroutines with nothing to block on isn’t
worth continuing. An engineered failure: run the same preemption
probe with GODEBUG=asyncpreemptoff=1 and the check that reads
ticker_incr>0 ok every normal run instead prints ticker_incr>0 FAIL —
signal-based preemption is what let the ticker progress, and disabling
it reproduces the pre-1.14 world where a non-yielding loop starves
everything else on its P.
That contrast run almost didn’t terminate while this example was being
built. With async preemption disabled, the only way the ticker gets to
run is if the spinner voluntarily yields its P — which it never does by
design. Launch the spinner first and the ticker second under
asyncpreemptoff=1, and the ticker claims runnext, starts, and blocks
forever on a flag only the never-scheduled spinner can set: a genuine
livelock, not a slow test. The fix is the launch order in the excerpt
above — ticker first, spinner second — so the ticker observes tick
incrementing on a normal run, or never runs at all under
asyncpreemptoff=1, correctly reporting ticker_incr=0. The bug and
the fix teach the same lesson as SIGURG itself: a scheduler decision
that looks arbitrary usually isn’t.
Concurrency lens
This whole chapter is the concurrency lens, so the sharpest statement is
the one line every theme proves from a different angle: M:N — many
goroutines multiplexed onto few OS threads, through P. sched’s
rendezvous proves goroutines can be concurrently alive without true
hardware parallelism — eight workers blocked on a channel send are all
_Gwaiting, all real, all counted by runtime.NumGoroutine(). netpoll
proves the ratio directly: 2,000 goroutines parked with no M at all —
gopark detaches a blocked goroutine from its thread entirely, and the
epoll wakeup path hands it back a P only once runnable again. And the
preemption probe answers the question the M:N model raises on its own:
if a P is the resource that lets an M run Go code, how does anything
interrupt a goroutine refusing to yield on the only P
(GOMAXPROCS=1)? SIGURG doesn’t need a spare P — it’s a signal delivered
straight to the OS thread running the offending goroutine, dispatched by
sysmon, which holds no P of its own. Preemption and scheduling are two
different resources here: P’s are contended, the signal that forces a
yield point is not.
Build, run, observe
[host]$ cd examples/44-go-runtime-for-systems && ./demo.sh go build
./demo.sh go run all runs all four themes in order:
[host]$ ./demo.sh go run all
=== sched ===
sched: env gomaxprocs=16 numcpu=16
sched: baseline goroutines=1
sched: rendezvous workers=8 baseline=1 during=9 during>=baseline+workers ok
sched: rendezvous drained baseline=1 after=1 after<=baseline+2 ok
sched: workpool workers=8 jobs=64 done=64 done==jobs ok
sched: preempt forced_gomaxprocs=1 spinner_ops=2000000000 ticker_incr=388757095 ticker_incr>0 ok
sched: PASS
=== gc ===
gc: baseline gcpercent=100 numgc=0
gc: alloc bytes=64000000 objects=100000 auto_gc=disabled
gc: forced numgc_before=0 numgc_after=1 delta=1 delta==1 ok
gc: gogc100_cycles=21 gogc800_cycles=2 gogc800<gogc100 ok
gc: memlimit_unset_cycles=2 memlimit_tight_cycles=122 memlimit_tight>memlimit_unset ok
gc: PASS
=== netpoll ===
netpoll: baseline goroutines=1 threads=12
netpoll: parked goroutines=2000 baseline=1 total=2001 total==baseline+2000 ok
netpoll: parked threads=12 bound=80 threads<=bound ok
netpoll: drained baseline=1 after=1 after<=baseline+2 ok
netpoll: PASS
=== knobs ===
knobs: GOMAXPROCS=16 NumCPU=16
knobs: GOGC_env=unset
knobs: GOMEMLIMIT_env=unset
knobs: GODEBUG_env=unset
knobs: GOMEMLIMIT_effective_bytes=unset
knobs: PASS
ALL: PASS
GODEBUG=gctrace=1 puts the runtime’s own pacer on stderr, one line per
collection:
[host]$ GODEBUG=gctrace=1 ./go/bin/app gc
gc 1 @0.004s 3%: 0.081+0.95+0.006 ms clock, 1.3+0/2.1/0.012+0.099 ms cpu, 67->67->63 MB, 8532210231531 MB goal, 0 MB stacks, 0 MB globals, 16 P (forced)
gc 2 @0.006s 2%: 0.011+0.10+0.003 ms clock, 0.18+0/0.14/0.024+0.060 ms cpu, 63->63->0 MB, 8532210231530 MB goal, 0 MB stacks, 0 MB globals, 16 P (forced)
gc 3 @0.011s 2%: 0.013+0.12+0.013 ms clock, 0.22+0.020/0.11/0.014+0.21 ms cpu, 3->4->0 MB, 4 MB goal, 0 MB stacks, 0 MB globals, 16 P
The format, verbatim from the runtime’s doc comment: gc # @#s #%: #+#+#
ms clock, #+#/#/#+# ms cpu, #->#-># MB, # MB goal, # MB stacks, # MB
globals, # P — the three heap numbers are start/end/live size, the CPU
triplet breaks assist/background/idle time, and a trailing (forced)
marks a line from an explicit runtime.GC() call. Lines 1 and 2 above are
both (forced) and both print a goal field north of eight and a half
trillion megabytes. That’s not a bug: with SetGCPercent(-1), there’s no
heap-growth ratio to compute a goal from, so the field is arithmetically
meaningless while GC is off. The moment a real percent is restored (line
3), the same field reports a sane 4 MB.
GODEBUG=schedtrace=1000,scheddetail=1 dumps the scheduler’s own state
once a second, plus a full per-P/per-M/per-G snapshot at startup:
[host]$ GODEBUG=schedtrace=1000,scheddetail=1 ./go/bin/app sched
SCHED 0ms: gomaxprocs=16 idleprocs=13 threads=5 spinningthreads=1 needspinning=0 idlethreads=1 runqueue=0 gcwaiting=false nmidlelocked=0 stopwait=0 sysmonwait=false
P0: status=1 schedtick=3 syscalltick=0 m=0 runqsize=0 gfreecnt=0 timerslen=0
P14: status=1 schedtick=0 syscalltick=0 m=4 runqsize=0 gfreecnt=0 timerslen=0
P15: status=1 schedtick=2 syscalltick=0 m=2 runqsize=0 gfreecnt=0 timerslen=0
M4: p=14 curg=nil mallocing=0 throwing=0 preemptoff= locks=0 dying=0 spinning=true blocked=false lockedg=nil
M0: p=0 curg=1 mallocing=0 throwing=0 preemptoff= locks=2 dying=0 spinning=false blocked=false lockedg=nil
G1: status=2(chan receive) m=0 lockedg=nil
G2: status=4(force gc (idle)) m=nil lockedg=nil
G5: status=4(GOMAXPROCS updater (idle)) m=nil lockedg=nil
threads=5 at startup, before the theme’s own work spawns goroutines, is
mcount() — sysmon’s M plus the small handful the runtime keeps idle.
G5’s GOMAXPROCS updater (idle) line is the Go 1.25+ periodic re-check
goroutine, visible here as a real, named goroutine rather than an
abstraction.
The chapter’s payoff run flips the preemption check on purpose:
[host]$ GODEBUG=asyncpreemptoff=1 ./go/bin/app sched
=== sched ===
sched: env gomaxprocs=16 numcpu=16
sched: baseline goroutines=1
sched: rendezvous workers=8 baseline=1 during=9 during>=baseline+workers ok
sched: rendezvous drained baseline=1 after=1 after<=baseline+2 ok
sched: workpool workers=8 jobs=64 done=64 done==jobs ok
sched: preempt forced_gomaxprocs=1 spinner_ops=2000000000 ticker_incr=0 ticker_incr>0 FAIL
sched: FAIL
The first three checks are unaffected — they don’t depend on async
preemption. Only ticker_incr>0 flips, exactly as the mechanism predicts:
ticker_incr=0, since with SIGURG-based preemption disabled the spinner
never yields its single P and the ticker never runs.
The gate the runner checks:
[host]$ python3 scripts/test-all-examples.py --only 44-go-runtime-for-systems
building 1 example-lang combinations (jobs=1)...
build 44-go-runtime-for-systems [go]: ok
verifying...
verify 44-go-runtime-for-systems [go]: PASS
example go
44-go-runtime-for-systems PASS
1 passed, 0 failed, 0 skipped (logs in build-logs/)
[host]$ LSP_LANG=go lua verify.lua
...
PASS 16 / FAIL 0
Cross-check: 2,000 blocked goroutines against a bounded thread count proves the netpoller, not just the claim
The chapter’s central claim — goroutines are not threads — is checkable
two independent ways, and both agree. The first is
runtime.NumGoroutine(), the Go runtime’s own bookkeeping: netpoll:
parked goroutines=2000 baseline=1 total=2001 total==baseline+2000 ok
says all 2,000 spawned goroutines are alive and blocked, none errored out
early. The second is /proc/self/status’s Threads: field, read by
readThreadCount directly from the kernel — a number the Go runtime has
no way to fake, since it’s the kernel’s own thread-group accounting:
netpoll: parked threads=12 bound=80 threads<=bound ok. Twelve threads,
not two thousand, comfortably inside a bound (4*GOMAXPROCS+16=80) set
two orders of magnitude below the goroutine count specifically so it only
fails if something regresses to one-thread-per-blocked-goroutine.
verify.lua’s PASS 16 / FAIL 0 folds this in alongside the other three
themes — netpoll alone contributes three of those sixteen assertions,
none of them “the binary exited 0.”
What you learned
- G, M, and P are three resources, and only P is scarce by design:
GOMAXPROCSP’s are the token that lets an M run Go code at all; goroutines (G) are cheap and plentiful; OS threads (M) come and go — work-stealing moves half a queue at a time between P’s when one runs dry. - Signal-based async preemption is what makes
GOMAXPROCS=1still fair:SIGURG, sent bypreemptMto the specific thread running a non-yielding goroutine, lets a second goroutine progress (ticker_incr=388757095this run) without needing a spare P —GODEBUG=asyncpreemptoff=1reliably reproduces the pre-1.14 world where it can’t (ticker_incr=0 FAIL). GOGCandGOMEMLIMITare independent, composable dials: looseningGOGCfrom 100 to 800 cut this run’s cycle count from 21 to 2 for identical churn; layering an 8 MiBGOMEMLIMITon a looseGOGC=800raised it from 2 to 122 — the soft memory ceiling still bites when the percentage trigger is effectively off.- The netpoller is why goroutines outnumber threads by two orders of
magnitude: 2,000 goroutines blocked on pipe reads, parked with no M
held, left this session’s thread count at 12 against a bound of 80 — a
raw blocking
syscall.Readon the same 2,000 goroutines would instead push the thread count toward 2,000. - Go 1.26 shipped two real changes without touching any of the above
defaults: Green Tea’s page-granular marking is on by default
(invisible in
gctrace, a build-time signal only), and the dedicated_PsyscallP status is gone, syscall-ness now read off the goroutine — a ~30% cut in baseline cgo-call overhead. - A verdict line is a claim the program checks about itself: every
<label> ok/FAILline is the runtime’s own arithmetic, deterministic enough (viaSetGCPercent(-1),/proc/self/status, and a launch order that avoided a real livelock during development) thatverify.luaonly has to match the fixed label text plus the literal wordok.
Part 12 closes here. Two deep dives, two languages, one shared discipline:
Chapter 43 proved Rust’s unsafe promises with miri; this chapter proved
Go’s runtime promises with the runtime’s own instrumentation, turned back
on itself.
verified — on the Fedora 44
host this session, on go1.26.5:
python3 scripts/test-all-examples.py --only
44-go-runtime-for-systems reported 1 passed, 0 failed
(go column only; the book's second single-language example), and
LSP_LANG=go lua verify.lua reported
PASS 16 / FAIL 0. ./demo.sh go run all produced
the exact transcript quoted above: ALL: PASS with every theme
verdict ok, including NumGC advancing by exactly
delta=1 across one forced runtime.GC() call,
gogc800_cycles=2 under gogc100_cycles=21 for
identical churn, memlimit_tight_cycles=122 over
memlimit_unset_cycles=2 with GOGC held fixed at
800, and 2,000 goroutines parked on the netpoller with the OS thread count
at 12 against a bound of 80 — all on this 16-logical-CPU host
(gomaxprocs=16 numcpu=16). A fresh
GODEBUG=gctrace=1 capture reproduced the two
(forced) lines with the arithmetically meaningless
8532210231531 MB goal under SetGCPercent(-1),
followed by a sane 4 MB goal once a real percent was
restored; a fresh GODEBUG=schedtrace=1000,scheddetail=1
capture showed the startup threads=5 summary line and
per-P/M/G detail including the Go 1.25+
GOMAXPROCS updater (idle) goroutine. The deliberate contrast
run, GODEBUG=asyncpreemptoff=1 ./go/bin/app sched, reproduced
ticker_incr=0 ticker_incr>0 FAIL and sched: FAIL
exactly, with the other three sched checks unaffected. Green
Tea GC's default-on status in Go 1.26 and the removal of the dedicated
_Psyscall P state are cited from the official Go 1.26 release
notes and runtime source, not measured directly — Green Tea leaves no
signal in gctrace output by design. Not exercised:
examples/manifest.yaml marks this mode: local —
no VM or LGTM path applies; the asyncpreemptoff=1 run is a
deliberate FAIL demo, never asserted green in verify.lua.