Go 程序在机器上怎么跑:执行链、垃圾回收器、调度器¶
这一章把前几章里「运行时替你做的事」全部拆开看。 一个 Go 程序从源码到跑起来经过哪几站?垃圾回收器怎么判断一块内存该收? 几万条 goroutine 怎么挤在少量 CPU 上跑?以及三个真能拧的旋钮: GOGC(内存换 CPU)、GOMAXPROCS(用几个核)、PGO(用运行数据指挥编译器)。 读完你会把「Go 运行时」从一个黑盒变成一个有仪表板的可控环境。
1. 这一章在原书里的位置¶
原书第 4 章(Advanced Go)是语言部分的最后一章,定位是「从学语言到懂语言」: 新手问语言能做什么,老手问它为什么是这个行为1。整章分两半, 本拆解覆盖前半(执行、GC、调度器、PGO、内存分配原语), 06 号拆解覆盖后半(接口、反射、泛型、模块、测试、benchmark)。
2. 顶层全景:一条执行链,两大运行时组件,三个旋钮¶
源码 ──go build──▶ 静态链接二进制 ──OS 加载──▶ 运行时初始化 ──▶ main()
│
┌─────────────────────────────────┼──────────────────────┐
▼ ▼ ▼
垃圾回收器(GC) 调度器(G-M-P) 系统调用层
三色标记+写屏障 G 复用到少量 M 文件/网络 I/O
│ │
▼ ▼
旋钮① GOGC 旋钮② GOMAXPROCS
(内存↔CPU 换挡) (同时跑 Go 代码的线程数)
旋钮③ PGO:先跑一遍采剖面,再拿着剖面重新编译
图说:执行链是「谁先把谁启动」;两大组件是运行时的两只手;三个旋钮各有换挡方向。
3. 执行过程:main() 不是第一个跑起来的¶
一条完整链路,每一步都有明确的负责方2:
go build把源码编译并链接成一个静态链接二进制(所有依赖打进一个文件, 回想 01 号拆解的静态链接);- 操作系统加载器(OS loader)把这个二进制装进内存、启动它;
- Go 运行时初始化——GC、调度器这些基础设施在这一步就位, 然后才轮到你的 main();
- main() 跑起来之后:GC 管内存,goroutine 和调度器管并发执行, goroutine 通过系统调用层做文件、网络 I/O;
- 程序退出:main() 返回,或任何 goroutine 里调
os.Exit()3。
值得建立的画面:你的代码从来不是「直接」在机器上跑的——它跑在一个已经初始化好的 运行时小系统里。后面每一节都是这个运行时系统的一个部件。
4. 对 Go 的七个误解:作者的纠偏清单¶
这一节(原书「Common misconceptions」)单独成节是因为它的密度: 作者把从 C++/Java/Python 阵营来的开发者最常见的七个投射性误解逐条拆4:
| 误解 | 书里的纠偏 |
|---|---|
| 「Go 有 GC,所以慢」 | GC 停顿已到亚毫秒;真实服务里 GC 开销常在 1–2% CPU;真到性能关头,减少分配 + 对象池通常就够了;极限场景才该选 Rust/Zig/C |
| 「并发就该全用 channel」 | channel 是协调用的;该用 sync.Mutex/WaitGroup/atomic 的地方硬套 channel 反而损可读性和性能。两种范式都在手里,按问题选 |
| 「Go 够快,不用 profile」 | 危险。瓶颈常在分配过多、阻塞 I/O、烂算法,不在运行时。pprof、trace 是内置的 |
| 「Go 编译器不优化」 | 早期属实,如今有逃逸分析、内联改进、寄存器分配增强、PGO;Go 刻意不做最深的优化以保住编译快而可预测 |
| 「goroutine 就是线程」 | 轻得多、由调度器复用到少量线程;几万条无压力。但便宜不是免费:栈空间、调度开销、协调成本都在,泄漏照样拖垮 |
| 「Go 没有内存控制的细粒度」 | 抽象是真的,但 GOGC 可调、-memprofile 可追、锁竞争可分析——看不见每一字节 ≠ 瞎飞 |
| 「Go 只是又一门通用语言」 | 它的真实卖点是速度、简单、安全、可观测性的组合,单项冠军不是目标 |
这一节的共同骨架值得记住:每个误解都源于拿别的生态的尺子量 Go, 而纠偏的每一步都指向「先测量,再下结论」。
5. 垃圾回收器:三色标记 + 写屏障¶
垃圾回收(GC)就是自动找出「再也用不到」的内存并释放它。 Go 的 GC 三个定语, 每个都对应一个设计选择5:
- 并发(concurrent):回收动作和程序代码同时跑,而不是把程序整个停下再收 (传统 stop-the-world 的做法);
- 非分代(non-generational):不按对象年龄分区——Java 那套「新对象大多早死」的 代际假设,Go 的设计者不采纳;
- 标记-清扫(mark-and-sweep):分两个阶段,标记出所有活着的对象, 清扫掉剩下的。
三色标记,主线例子走一遍¶
算法用三种颜色记账:白 = 待回收候选;灰 = 已知活着、还没扫描完它的指针; 黑 = 活着且扫描完毕6。起点是根(root)集合——goroutine 栈上的活跃变量、 全局变量、运行时持有的引用,根直接染灰。然后循环:取一个灰对象,扫描它指向的所有对象 (白→灰),把扫描完的灰染黑。没有灰对象时,剩下的白色全体不可达,清扫阶段收走7。
拿书上 User/Profile/Settings/Photo 的例子走:
栈 ──▶ User 初始:全白
│ ① 根可达,User 染灰
┌────┴────┐ ② 扫描 User:Profile、Settings 染灰;User 染黑
▼ ▼ ③ 扫描 Profile:Photo 染灰,Profile 染黑;Settings 无引用,直接染黑
Profile Settings ④ 扫描 Photo:无引用,染黑
│ ⑤ 无灰对象 → 标记结束:User/Profile/Settings/Photo 全黑(活)
▼ ⑥ Cache 对象没人指向,仍白 → 清扫阶段回收
Photo
(另有一个旧 Cache 对象,不在这条引用链上 → 保持白色 → 被回收)
图说:颜色就是「可达性证明」的进度条;黑色的多少决定这次 GC 保住多少内存。
写屏障:并发环境下颜色不会说谎¶
并发有个致命漏洞:程序一边跑一边改指针——假设 Photo 已染黑,程序又让一个黑对象 (已扫描完、不会再回头)指向一个新的白对象,这个白对象就会被误收。 Go 的解法是写屏障(write barrier):标记期间,每当一个黑色对象获得一个白色对象的引用, 那个白对象立刻被染灰8。一条拦截规则,堵住了并发标记的唯一漏洞, 保证「没有活对象被误收」,同时重活都在程序运行间隙并行干完,停顿压到亚毫秒级。 (暂停主要发生在标记开始和结束时短暂的「停全世界」同步点。)
旋钮① GOGC:内存和 CPU 的换挡杆¶
GOGC 的含义:堆内存比上次 GC 结束后增长百分之几,就触发下一次 GC。 默认 100——堆翻倍就回收。调小(5、20)= 收得勤:内存峰值低,CPU 花在 GC 上多; 调大(100、200)= 收得懒:CPU 省,内存涨9。
书上 GOGC.go 的实验把这一点跑成了数字:每轮迭代分配 50 MB 大块、每 3 轮丢弃旧引用,
runtime.ReadMemStats 同时报堆占用(HeapAlloc)和已发生的 GC 次数(NumGC)。
对比两组输出10:
GOGC=5: 15 轮下来 NumGC 一路爬到 16;堆锯齿状,400 MB 处会骤降到 250–300 MB
GOGC=50: 15 轮只有 7 次 GC;堆一路涨到 550 MB,第 13 轮才一次大降回 300 MB
图说:GC 次数差一倍多,峰值内存差约一倍——换挡杆的两头清晰可见。 (GOGC=off 可以彻底关掉自动 GC,书里点到为止,那是拿内存换确定性的极端玩法。)
6. Go 也会内存泄漏:两种Leak,机理完全不同¶
有了 GC 不等于没有泄漏。泄漏的定义变成:对象仍「可达」却已无用——GC 职责上不能收, 程序逻辑上不该留。 书上给了两种11:
第一种:引用没断。 leaksRef.go 里 leakyFunction 造一个无缓冲 channel,
开一条 goroutine 往里面发——但没人收,goroutine 永远阻塞在发送上;
闭包捕获了传进来的 1 MB 的 Data 对象,引用链因此永远不断。
输出铁证:强跑 3 次 runtime.GC() 后 Alloc 归零(临时对象都收干净了),
TotalAlloc 停在 10 MB、goroutine 数停在 11(main + 10 条卡死的)——
GC 工作正常,是程序自己把内存「藏」在了可达但够不着的地方12。
第二种:goroutine 本身泄漏。 leaksGoroutines.go 连开 50 条 goroutine,
每条占 5 MB 后进入无限循环(_ = data 保持引用)。50 × 5 MB ≈ 250 MB 永不可收,
NumGoroutine 一路从 2 爬到 47+。书里一句话点题:goroutine 便宜但不是免费,
没有退出机制的 goroutine 是慢性资源泄漏13。
(第 3 章的 done channel、第 9 章的 context 取消,都是这病的药——第 13 章还有
Go 1.26 的 goroutine leak profile 工具专门抓它。)
7. 调度器:G-M-P,以及为什么加速不是线性的¶
调度器的职责一句话:把 goroutine(G)映射到操作系统线程(M)上执行, 保证公平、均衡、低切换开销。14 三个字母模型15:
- G(goroutine):轻量执行单元,只占一份栈和执行状态;
- M(machine):真正的 OS 线程;
- P(processor):逻辑处理器——可运行 G 的队列挂在 P 上,P 再被分给 M。
一个 P: [ runnable G1, G2, G3, … ] 另一个 P: [ G7, G8, … ]
│ 绑定一个 M(OS 线程) │ 绑定一个 M
▼ ▼
CPU 核 1 CPU 核 2
空闲的 P 会从忙碌的 P 那里「偷」(work stealing)G 来跑
图说:G 比 M 多得多——成千上万 G 复用到 GOMAXPROCS 个 P 上、再复用到线程上。 这就是第 3 章「开 goroutine 很便宜」的全部底气。负载均衡靠 work stealing: 闲 P 从忙 P 的队列尾偷任务16。
旋钮② GOMAXPROCS:同时跑 Go 代码的线程上限¶
GOMAXPROCS 决定同一时刻最多几条线程在跑 Go 代码(默认值 = 逻辑 CPU 数)。 书上实验:4 条各算 100 万次累加的 goroutine,M4(14 核)机器上先设 1 再设 1417:
GOMAXPROCS=1: 1.20325ms ← 4 条 goroutine 在一条线程上排队,实为串行
GOMAXPROCS=14: 369.208µs ← 真正并行
→ Multi-threaded was 3.26x faster!
3.26 倍,不是 14 倍——书里给了四条原因,这是本节最诚实的地方18: ① 只有 4 个 worker,并行度上限就是 4; ② 调度器自身有管理开销;③ 4 条 goroutine 抢共享缓存和内存带宽; ④ 建 goroutine、起定时器、收结果这些活永远是串行的。
8. 旋钮③ PGO:让编译器看看真实运行¶
PGO(Profile-Guided Optimization,配置文件引导优化):先用插桩版程序跑一遍, 采一份「哪些代码热」的剖面(profile),再拿剖面指导第二次编译。 编译器据此做内联热函数、按热路径排布指令、优化分支预测19。
书上的例子把「为什么有用」讲得很白:pgoSimple.go 循环 10000 次, 随机数恰为 0 才走 coldPath(万分之一),其余全走 hotPath。 静态编译器不知道哪个分支热,只能一视同仁;有了剖面, 它就知道该把 hotPath 内联、把热分支的指令排得挨在一起20。 同时书里坦白:这个玩具例子里 PGO 的收益「minimal」, 真实收益出现在复杂控制流、间接调用、接口分发密集的代码里21。 (具体怎么用 benchmark 量化 PGO,放到 06 号拆解的 benchmark 一节讲。)
9. make 和 new:两个分配原语的分工¶
一句话版本:make 只服务切片、map、channel,并把内部结构初始化到能用; new 服务任何类型,只分配零值内存、返回指针,初始化是你自己的事22。
p := new(int) → *int,值 0 「给你一个空盒子,放什么你定」
s := make([]int, 5) → 长度 5 的切片,元素全 0,底层数组已配好
m := make(map[string]int) → 哈希表内部结构已建好的空 map
ch := make(chan int, 3) → 缓冲 3 的 channel
new(map[string]int) → 编译期就拒绝:map 的内部结构没人建,给了指针也是废的
图说:new(map...) 不行的原因在 03 号拆解埋过——nil map 没有哈希桶,
make 的「意见」就是替你把这些桶建出来。心理图像(书里的原比喻):
new 是一张白纸,make 是一本装订好的笔记本23。
10. 作者的判断与证据¶
| 判断 | 性质 | 依据 |
|---|---|---|
| Go 的 GC 停顿亚毫秒、开销 1–2% CPU | 作者陈述(定性,无出处) | 误解纠偏一节原段4;这是出版时(2025–26)的合理区间,非定律 |
| 性能极限场景选 Rust/Zig/C | 作者立场,与第 1 章一致 | 第 1 章四方权衡表呼应 |
| 先结构后锁、先测量后优化 | 贯穿主张 | 第 3 章矩阵乘法 + 本章「Go 不用 profile」纠偏 |
| PGO 在简单代码上收益极小 | 作者坦白 + 解释了三条原因 | 原文逐条列举21 |
| goroutine 便宜不免费 | 语言事实 + 实测 | leaksGoroutines 输出 NumGoroutine 2→47+13 |
11. 边界与局限¶
- GC 一节没有 pprof 的实战连接。 GOGC 旋钮怎么和
-memprofile配合调, 本章没串起来(误解一节提了工具名,用法在 06 号拆解的 benchmark 一节)。 - 调度器没讲抢占(preemption)细节。 协作式 vs 信号式抢占的历史一笔带过, 长循环 goroutine 饿死别人的场景没有演示。
- 写屏障只讲了指针写。 栈上对象的特殊处理、混合写屏障这些实现细节都略过了 (对教学书合理,但「标记完全并发」的表述别当成字面真)。
- 「1–2% CPU」这类数字没有引用来源——按③类来源看,它是作者经验值, 不同负载差异可以很大,拿它当预算依据前要自己测。
- PGO 一节没给完整命令序列(生成剖面、
-pgo编译的具体步骤在 benchmark 一节才齐)。
12. 可带走的¶
- 执行链五站:go build → 静态二进制 → OS 加载 → 运行时初始化 → main()。
- GC 三定语:并发、非分代、标记-清扫;三色(白/灰/黑)是可达性证明的进度条。
- 写屏障:黑对象指向白对象时白立即染灰——并发标记不漏活对象的唯一保险。
- GOGC = 堆增长触发 GC 的百分比:调小省内存费 CPU,调大反之;实验里 GC 次数和峰值内存各差约一倍。
- Go 的泄漏 = 「可达但无用」:卡死的 goroutine 握着引用,强跑 GC 也收不走; goroutine 自身也会泄漏——便宜不是免费。
- G-M-P:G 多 M 少,队列挂在 P 上,闲 P 偷忙 P 的活;GOMAXPROCS 卡的是「跑 Go 代码的线程数」。
- 加速比 ≠ 核数:worker 数、调度开销、内存带宽、串行部分,四条上限叠在一起 (4 worker 在 14 核上只有 3.26×)。
- PGO:跑一遍采剖面 → 拿剖面重编译;简单代码收益可忽略,复杂控制流才是它的主场。
- make 初始化好给你用(切片/map/channel),new 只给零值内存返回指针;new(map) 是编译错误。
13. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 章节定位:从学到懂 | Advanced Go | text/40-ch04.txt:2(搜「novice asks what a」) |
| 执行链五站 | The Go program execution process | text/41-fm-the-go-program-execution-process.txt:5(搜「linked using go build」) · text/42-fm-common-misconceptions-about-go.txt:2(搜「os.Exit」) |
| 七大误解逐条纠偏 | Common misconceptions about Go | text/42-fm-common-misconceptions-about-go.txt:21(搜「garbage collection」) · :35(搜「not all concurrency」) · :47(搜「dangerous myth」) |
| GC 三定语、三色算法 | The Go garbage collector | text/43-fm-the-go-garbage-collector.txt:20(搜「concurrent, non-generational」) · :33(搜「considered white」) |
| User/Profile 标记例子 | The Go garbage collector | text/43-fm-the-go-garbage-collector.txt:54(搜「User object」) |
| 写屏障 | The Go garbage collector | text/43-fm-the-go-garbage-collector.txt:47(搜「write barrier」) |
| GOGC 语义与两组对比输出 | The Go garbage collector | text/43-fm-the-go-garbage-collector.txt:71(搜「frequency of garbage collection」) · :111(搜「GOGC=5」) · :128(搜「GOGC=50」) |
| 引用泄漏实验(强跑 GC 收不走) | The Go garbage collector | text/43-fm-the-go-garbage-collector.txt:194(搜「leakyFunction」) · :249(搜「should be 11」) |
| goroutine 泄漏实验 | The Go garbage collector | text/43-fm-the-go-garbage-collector.txt:293(搜「50 goroutines」) · text/44-fm-the-go-scheduler.txt:5(搜「NumGoroutine」) |
| G-M-P 与 work stealing | The Go scheduler | text/44-fm-the-go-scheduler.txt:37(搜「G-M-P」) · :46(搜「work stealing」) |
| GOMAXPROCS 实验与 3.26× | The Go scheduler | text/44-fm-the-go-scheduler.txt:76(搜「GOMAXPROCS(1)」) · :138(搜「3.26x faster」) |
| 加速比非线性的四条原因 | The Go scheduler | text/45-fm-profile-guided-optimization-pgo.txt:1(搜「not 14×」) |
| PGO 原理与「简单代码收益小」 | Profile-Guided Optimization (PGO) | text/45-fm-profile-guided-optimization-pgo.txt:31(搜「two-step dance」) · :72(搜「typical and expected」) |
| make 与 new 的分工 | make and new | text/46-fm-make-and-new.txt:14(搜「serve different purposes」) · :22(搜「blank sheet of paper」) |
-
出处:「Advanced Go」第 2–4 段(
text/40-ch04.txt:2,搜「novice asks what a」)。「The novice asks what a language can do; the experienced developer begins to ask why it behaves the way it does」。 ↩ -
出处:「The Go program execution process」第 2–9 段(
text/41-fm-the-go-program-execution-process.txt:5,搜「linked using go build」)。静态链接二进制、OS 加载、运行时初始化、main() 的顺序在同段。 ↩ -
出处:「Common misconceptions about Go」开头(
text/42-fm-common-misconceptions-about-go.txt:2,搜「os.Exit」)。「The program concludes with a program exit, either by main() returning or via an explicit call to os.Exit()」。 ↩ -
出处:「Common misconceptions about Go」(
text/42-fm-common-misconceptions-about-go.txt:21,搜「garbage collection」)。GC 开销「often in the range of 1–2% CPU usage under load」、渠道过度使用、profile 必要、编译器优化、goroutine 非线程、内存可见性、综合权衡,七条见第 21–84 段。 ↩↩ -
出处:「The Go garbage collector」第 13–32 段(
text/43-fm-the-go-garbage-collector.txt:20,搜「concurrent, non-generational」)。三个定语、并发清扫(lazy sweeping)、亚毫秒停顿、动态节奏调整全在同段。 ↩ -
出处:「The Go garbage collector」第 33–46 段(
text/43-fm-the-go-garbage-collector.txt:33,搜「considered white」)。白色候选、灰色可达未扫描、黑色已处理的定义;根集合的构成(栈上活跃变量、全局变量、运行时引用)在同段。 ↩ -
出处:「The Go garbage collector」第 38–46 段(
text/43-fm-the-go-garbage-collector.txt:38,搜「scanning their pointers」)。「until no gray objects remain, at which point all remaining white objects are unreachable」。 ↩ -
出处:「The Go garbage collector」第 47–53 段(
text/43-fm-the-go-garbage-collector.txt:47,搜「write barrier」)。「whenever a black object gains a reference to a white object, that white object is immediately shaded gray」。 ↩ -
出处:「The Go garbage collector」第 70–77 段(
text/43-fm-the-go-garbage-collector.txt:71,搜「frequency of garbage collection」)。GOGC 低值(2/20)激进、高值(100/200)保守的两段说明。 ↩ -
出处:「The Go garbage collector」(
text/43-fm-the-go-garbage-collector.txt:80,搜「func main」;:111,搜「GOGC=5」;:128,搜「GOGC=50」;:146,搜「sawtooth」)。实验代码、两组完整输出与逐段对比解释。 ↩ -
出处:「The Go garbage collector」第 166–186 段(
text/43-fm-the-go-garbage-collector.txt:167,搜「memory leaks can still occur」)。「preventing the GC from reclaiming them」。 ↩ -
出处:「The Go garbage collector」(
text/43-fm-the-go-garbage-collector.txt:194,搜「leakyFunction」;:236,搜「Memory leak example」;:249,搜「should be 11」;:267,搜「Forcing 3 GC cycles」)。输出中 Alloc 归零而 goroutine 数保持 11 的证据链。 ↩ -
出处:「The Go garbage collector」第 279–322 段(
text/43-fm-the-go-garbage-collector.txt:293,搜「Launch 50 goroutines」)与「The Go scheduler」开头(text/44-fm-the-go-scheduler.txt:5,搜「NumGoroutine」)。「goroutines are cheap but not free」。 ↩↩ -
出处:「The Go scheduler」第 28–36 段(
text/44-fm-the-go-scheduler.txt:29,搜「mapping goroutines onto OS threads」)。 ↩ -
出处:「The Go scheduler」第 37–45 段(
text/44-fm-the-go-scheduler.txt:37,搜「G-M-P」)。「G represents goroutines, M represents machine threads, and P represents processors」。 ↩ -
出处:「The Go scheduler」第 46–53 段(
text/44-fm-the-go-scheduler.txt:46,搜「work stealing」)。「idle processors take tasks from others」。 ↩ -
出处:「The Go scheduler」(
text/44-fm-the-go-scheduler.txt:76,搜「GOMAXPROCS(1)」;:138,搜「Number of CPUs: 14」)。完整输出含 1.20325ms、369.208µs、3.26x。 ↩ -
出处:「The Go scheduler」(
text/45-fm-profile-guided-optimization-pgo.txt:1,搜「not 14×」)。四条原因(worker 数、调度开销、内存带宽、串行部分)逐条列出。 ↩ -
出处:「Profile-Guided Optimization (PGO)」第 23–37 段(
text/45-fm-profile-guided-optimization-pgo.txt:31,搜「two-step dance」;:31,搜「two-step dance」)。「first, you generate the profile… second, you use that profile to guide a subsequent… compilation」。 ↩ -
出处:「Profile-Guided Optimization (PGO)」(
text/45-fm-profile-guided-optimization-pgo.txt:42,搜「func main」;:57,搜「hotPath() dominates」)。冷热路径的构造与编译器「treats both paths as equally likely」的问题。 ↩ -
出处:「Profile-Guided Optimization (PGO)」第 71–82 段(
text/45-fm-profile-guided-optimization-pgo.txt:72,搜「typical and expected」)。三条原因列举;「more dramatic benefits in complex code bases」在text/46-fm-make-and-new.txt:1。 ↩↩ -
出处:「make and new」第 13–29 段(
text/46-fm-make-and-new.txt:14,搜「serve different purposes」;:20,搜「only with slices, maps, and channels」)。 ↩ -
出处:「make and new」第 22–23 段(
text/46-fm-make-and-new.txt:22,搜「blank sheet of paper」)。「you can think of new as giving you a blank sheet of paper, while make hands you a fully bound notebook」。 ↩