编程语言研究书库

从源码到进程、垃圾回收器的三色标记、G-M-P 调度器,以及 GOGC/GOMAXPROCS/PGO 三个旋钮

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:

  1. go build 把源码编译并链接成一个静态链接二进制(所有依赖打进一个文件, 回想 01 号拆解的静态链接);
  2. 操作系统加载器(OS loader)把这个二进制装进内存、启动它;
  3. Go 运行时初始化——GC、调度器这些基础设施在这一步就位, 然后才轮到你的 main();
  4. main() 跑起来之后:GC 管内存,goroutine 和调度器管并发执行, goroutine 通过系统调用层做文件、网络 I/O;
  5. 程序退出: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. 可带走的

  1. 执行链五站:go build → 静态二进制 → OS 加载 → 运行时初始化 → main()。
  2. GC 三定语:并发、非分代、标记-清扫;三色(白/灰/黑)是可达性证明的进度条。
  3. 写屏障:黑对象指向白对象时白立即染灰——并发标记不漏活对象的唯一保险。
  4. GOGC = 堆增长触发 GC 的百分比:调小省内存费 CPU,调大反之;实验里 GC 次数和峰值内存各差约一倍。
  5. Go 的泄漏 = 「可达但无用」:卡死的 goroutine 握着引用,强跑 GC 也收不走; goroutine 自身也会泄漏——便宜不是免费。
  6. G-M-P:G 多 M 少,队列挂在 P 上,闲 P 偷忙 P 的活;GOMAXPROCS 卡的是「跑 Go 代码的线程数」。
  7. 加速比 ≠ 核数:worker 数、调度开销、内存带宽、串行部分,四条上限叠在一起 (4 worker 在 14 核上只有 3.26×)。
  8. PGO:跑一遍采剖面 → 拿剖面重编译;简单代码收益可忽略,复杂控制流才是它的主场。
  9. make 初始化好给你用(切片/map/channel),new 只给零值内存返回指针;new(map) 是编译错误。

13. 原文地图

主题原书章原文位置
章节定位:从学到懂Advanced Gotext/40-ch04.txt:2(搜「novice asks what a」)
执行链五站The Go program execution processtext/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 Gotext/42-fm-common-misconceptions-about-go.txt:21(搜「garbage collection」) · :35(搜「not all concurrency」) · :47(搜「dangerous myth」)
GC 三定语、三色算法The Go garbage collectortext/43-fm-the-go-garbage-collector.txt:20(搜「concurrent, non-generational」) · :33(搜「considered white」)
User/Profile 标记例子The Go garbage collectortext/43-fm-the-go-garbage-collector.txt:54(搜「User object」)
写屏障The Go garbage collectortext/43-fm-the-go-garbage-collector.txt:47(搜「write barrier」)
GOGC 语义与两组对比输出The Go garbage collectortext/43-fm-the-go-garbage-collector.txt:71(搜「frequency of garbage collection」) · :111(搜「GOGC=5」) · :128(搜「GOGC=50」)
引用泄漏实验(强跑 GC 收不走)The Go garbage collectortext/43-fm-the-go-garbage-collector.txt:194(搜「leakyFunction」) · :249(搜「should be 11」)
goroutine 泄漏实验The Go garbage collectortext/43-fm-the-go-garbage-collector.txt:293(搜「50 goroutines」) · text/44-fm-the-go-scheduler.txt:5(搜「NumGoroutine」)
G-M-P 与 work stealingThe Go schedulertext/44-fm-the-go-scheduler.txt:37(搜「G-M-P」) · :46(搜「work stealing」)
GOMAXPROCS 实验与 3.26×The Go schedulertext/44-fm-the-go-scheduler.txt:76(搜「GOMAXPROCS(1)」) · :138(搜「3.26x faster」)
加速比非线性的四条原因The Go schedulertext/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 newtext/46-fm-make-and-new.txt:14(搜「serve different purposes」) · :22(搜「blank sheet of paper」)

Footnotes

  1. 出处:「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」。 ↩

  2. 出处:「The Go program execution process」第 2–9 段(text/41-fm-the-go-program-execution-process.txt:5,搜「linked using go build」)。静态链接二进制、OS 加载、运行时初始化、main() 的顺序在同段。 ↩

  3. 出处:「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()」。 ↩

  4. 出处:「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 段。 ↩ ↩2

  5. 出处:「The Go garbage collector」第 13–32 段(text/43-fm-the-go-garbage-collector.txt:20,搜「concurrent, non-generational」)。三个定语、并发清扫(lazy sweeping)、亚毫秒停顿、动态节奏调整全在同段。 ↩

  6. 出处:「The Go garbage collector」第 33–46 段(text/43-fm-the-go-garbage-collector.txt:33,搜「considered white」)。白色候选、灰色可达未扫描、黑色已处理的定义;根集合的构成(栈上活跃变量、全局变量、运行时引用)在同段。 ↩

  7. 出处:「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」。 ↩

  8. 出处:「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」。 ↩

  9. 出处:「The Go garbage collector」第 70–77 段(text/43-fm-the-go-garbage-collector.txt:71,搜「frequency of garbage collection」)。GOGC 低值(2/20)激进、高值(100/200)保守的两段说明。 ↩

  10. 出处:「The Go garbage collector」(text/43-fm-the-go-garbage-collector.txt:80,搜「func main」;:111,搜「GOGC=5」;:128,搜「GOGC=50」;:146,搜「sawtooth」)。实验代码、两组完整输出与逐段对比解释。 ↩

  11. 出处:「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」。 ↩

  12. 出处:「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 的证据链。 ↩

  13. 出处:「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」。 ↩ ↩2

  14. 出处:「The Go scheduler」第 28–36 段(text/44-fm-the-go-scheduler.txt:29,搜「mapping goroutines onto OS threads」)。 ↩

  15. 出处:「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」。 ↩

  16. 出处:「The Go scheduler」第 46–53 段(text/44-fm-the-go-scheduler.txt:46,搜「work stealing」)。「idle processors take tasks from others」。 ↩

  17. 出处:「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。 ↩

  18. 出处:「The Go scheduler」(text/45-fm-profile-guided-optimization-pgo.txt:1,搜「not 14×」)。四条原因(worker 数、调度开销、内存带宽、串行部分)逐条列出。 ↩

  19. 出处:「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」。 ↩

  20. 出处:「Profile-Guided Optimization (PGO)」(text/45-fm-profile-guided-optimization-pgo.txt:42,搜「func main」;:57,搜「hotPath() dominates」)。冷热路径的构造与编译器「treats both paths as equally likely」的问题。 ↩

  21. 出处:「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。 ↩ ↩2

  22. 出处:「make and new」第 13–29 段(text/46-fm-make-and-new.txt:14,搜「serve different purposes」;:20,搜「only with slices, maps, and channels」)。 ↩

  23. 出处:「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」。 ↩

On this page