上一节讲了怎么让运行时「开口说话」。这一节解决另一个前置问题:当你要看源码时,从哪个文件进。src/runtime 是 Go 标准库中最大的包,漫无目的地打开 proc.go(8176 行)只会劝退。正确做法是先建立一张地图:知道哪几个文件承载了哪个子系统,然后按问题选一条路线。
本节要回答:runtime 源码怎么切分,一个具体问题该走哪条路线? 结论是:runtime 的 587 个文件里,真正需要反复读的不超过 20 个;调度、分配、GC、编译器支撑四条路线各有 3~5 个「入口函数」,记住入口比记住结构体字段重要得多。
1.2.1 实验:量化 runtime 目录
先量规模,再谈路线。统计口径为 Go 1.27.0 工具链自带的 src/runtime:
GOROOT27="$(GOTOOLCHAIN=go1.27.0 go env GOROOT)"
cd "$GOROOT27/src/runtime"
ls -1 *.go | wc -l
ls -1 *.go | grep -v _test | wc -l
ls -1 *_test.go | wc -l
cat $(ls *.go | grep -v _test) | wc -l
587
452
135
112843
顶层 .go 文件 587 个,其中非测试 452 个、测试 135 个;非测试代码约 11.3 万行。加上子目录与汇编,总量约 15.5 万行。看最大的几个文件,能立刻看出子系统的「重心」在哪:
wc -l $(ls *.go | grep -v _test) | sort -rn | grep -v ' total$' | head -8
8176 proc.go
3030 mheap.go
2485 malloc.go
2349 mgc.go
2232 malloc_generated.go
1977 mbitmap.go
1852 traceback.go
1806 mgcmark.go
proc.go 一枝独秀——调度器、goroutine 生命周期、sysmon 全在里面,光顶层函数就有 244 个。接下来是内存(mheap.go/malloc.go/mbitmap.go)和 GC(mgc.go/mgcmark.go)。再看几个常被引用的「小文件」:
for f in runtime2.go mcache.go mcentral.go stack.go preempt.go signal_unix.go netpoll.go trace.go lock_futex.go; do
printf "%-16s %5s\n" "$f" "$(wc -l < $f)"
done
runtime2.go 1520
mcache.go 391
mcentral.go 259
stack.go 1459
preempt.go 497
signal_unix.go 1474
netpoll.go 733
trace.go 1223
lock_futex.go 163
runtime2.go 里没有几行「逻辑」,它装的是 g/m/p/mspan 等核心结构体定义。mcache.go 只有 391 行,却是分配快路径的全部。汇编在 asm_arm64.s(1362 行)/asm_amd64.s(1668 行)。
子目录各管一块,规模如下:
ls -d */
_mkmalloc/ asan/ cgo/ coverage/ debug/ metrics/
msan/ pprof/ race/ secret/ testdata/ trace/
其中 trace(12 个文件)、debug(10 个)、metrics(7 个)、pprof(27 个)、race(14 个)、secret(8 个)。_mkmalloc/ 是生成 malloc_generated.go 的代码生成器,读分配器时不必看它。
一个常见的过时认知要纠正:1.27 里没有 mspan.go。type mspan struct 定义在 mheap.go:422,位图操作在 mbitmap.go。凭记忆找文件名会白跑一趟——这也是为什么本卷坚持「先 grep 再写」。
顺带说一句测试文件的价值:135 个 _test.go 不只是单元测试,很多行为契约写在里面(例如 cgroup_linux_test.go 就固化了容器感知 GOMAXPROCS 的期望值)。读某个函数拿不准语义时,先 grep 它的测试,往往比读实现更快。
1.2.2 源码:四个子系统的入口函数
runtime 的函数命名有强烈的一致性,记住入口就能按图索骥。先确认核心结构体的位置,它们决定了你读调度/分配代码前必须先打开哪个文件:
grep -n "^type g struct\|^type m struct\|^type p struct\|^type schedt struct" runtime2.go
grep -n "^type mspan struct" mheap.go
runtime2.go:471:type g struct {
runtime2.go:616:type m struct {
runtime2.go:774:type p struct {
runtime2.go:932:type schedt struct {
mheap.go:422:type mspan struct {
下面是四条路线各自的入口,全部实测自本机 $GOROOT27:
grep -n "^func schedule\|^func findRunnable\|^func newproc\b\|^func sysmon\|^func mstart\b" proc.go
1864:func mstart()
3404:func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
4150:func schedule() {
5334:func newproc(fn *funcval) {
6537:func sysmon() {
路线一(调度):从 proc.go:schedule 进入,它调用 findRunnable(proc.go:3404)找下一个可运行的 goroutine,找不到就调用 stealWork(proc.go:3843)去别的 P 偷。goroutine 的诞生在 newproc(proc.go:5334),线程的诞生在 mstart(proc.go:1864),后台监控在 sysmon(proc.go:6537)。第 2 章就走这条路线。
路线二(分配):入口是 malloc.go:mallocgc,三级结构在 mcache.go(线程缓存,快路径)、mcentral.go(中心缓存)、mheap.go(页堆,慢路径)。size class 表由 malloc_generated.go 生成,位图在 mbitmap.go。第 4 章走这条。
1.27 的一个细节值得单独指出:mallocgc 本身是分发器,真正的分配逻辑被代码生成器拆成了「按 size class 一份」的函数,放在 malloc_generated.go。跨文件确认入口时,grep 会一次列出几十个 mallocgc* 函数,别被吓到——你只需要看 malloc.go:1067 那个主入口:
grep -rn "^func mallocgc(" *.go
grep -rn "^func gcStart(\|^func gopark(\|^func execute(" *.go
malloc.go:1067:func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
mgc.go:733:func gcStart(trigger gcTrigger) {
proc.go:457:func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceReason traceBlockReason, traceskip int) {
proc.go:3346:func execute(gp *g, inheritTime bool) {
路线三(GC):入口是 mgc.go:gcStart,标记在 mgcmark.go,清扫在 mgcsweep.go,pacing 在 mgcpacer.go(1537 行,决定下一轮 GC 何时开始)。第 6、7 章走这条。
路线四(编译器支撑):runtime 侧看 stack.go(栈增长)、preempt.go(异步抢占)、signal_unix.go(信号)、netpoll.go(网络轮询),但真正的编译决策在 src/cmd/compile/internal/。那里的子目录有 40 多个,与运行时行为最相关的是:
cd "$GOROOT27/src/cmd/compile/internal"
wc -l escape/escape.go inline/inl.go ssa/compile.go
677 escape/escape.go
1364 inline/inl.go
638 ssa/compile.go
2679 total
逃逸分析在 escape/,内联在 inline/(文件名是 inl.go,不是 inline.go),SSA 在 ssa/。第 8、9 章走这条。
一个走读示例:从 schedule 往下。 光记住入口还不够,得知道「顺着调用链走」具体长什么样。以路线一为例,先列出 schedule 的调用点,再列出 findRunnable 的调用点:
grep -n "findRunnable()" proc.go
grep -n "schedule()" proc.go | head
3404:func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
4179: gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available
1955: schedule()
4150:func schedule() {
4319: schedule()
4360: schedule()
4449: schedule()
4486: schedule()
4515: schedule()
5154: schedule() // Never returns.
读法:schedule 只被调用 8 次,其中 proc.go:1955 是 mstart1 里线程启动后的第一次调度,其余几处是 gopark/gosched 之后重新进入调度循环——换句话说,调度循环的闭环就是 schedule → findRunnable → execute → (goroutine 阻塞/退出)→ schedule。找到这条闭环,proc.go 剩下的 8000 行就都能挂到它上面。
1.2.3 决策:四条路线与入口清单
| 你的问题 | 路线 | 入口函数(文件:函数) | 顺带要看的文件 |
|---|---|---|---|
| goroutine 怎么被调度 | 一 | proc.go:schedule、proc.go:findRunnable | runtime2.go(g/m/p)、preempt.go |
| 对象分配走哪条路径 | 二 | malloc.go:mallocgc | mcache.go、mcentral.go、mheap.go、mbitmap.go |
| GC 什么时候开始、暂停多久 | 三 | mgc.go:gcStart | mgcmark.go、mgcsweep.go、mgcpacer.go |
| 这个变量为什么逃逸/没内联 | 四 | cmd/compile/internal/inline/inl.go | escape/escape.go、ssa/compile.go、stack.go |
| 网络 I/O 阻塞了谁 | 一/四 | netpoll.go:netpoll | runtime2.go(g 的 waitreason) |
为了少走弯路,这里再给一张「文件 → 职责」速查表,覆盖 1.2.1 里量出来的所有重点文件:
| 文件 | 职责 | 什么时候打开 |
|---|---|---|
runtime2.go | g/m/p/schedt 结构体与状态常量 | 读任何调度代码之前 |
proc.go | 调度循环、goroutine 生命周期、sysmon | 路线一 |
stack.go | 栈增长、连续栈复制 | 路线一/四 |
preempt.go | 协作式与异步抢占的判定 | 路线一 |
malloc.go | mallocgc 分配入口、GC 触发点 | 路线二 |
mcache.go / mcentral.go / mheap.go | 三级分配结构 | 路线二 |
mbitmap.go | 堆位图与 size class 位运算 | 路线二 |
mgc.go | GC 周期主控、gcStart | 路线三 |
mgcmark.go / mgcsweep.go | 标记与清扫 | 路线三 |
mgcpacer.go | GC pacing,决定下一轮何时开始 | 路线三/调优 |
netpoll.go | 网络 I/O 轮询与 goroutine 阻塞 | 网络延迟问题 |
signal_unix.go | 信号处理,异步抢占的实现载体 | 路线一/四 |
三条读源码的方法论:
- 从入口函数往下读,不要从头读。 打开文件先
grep "^func"列出所有函数,找到入口,再顺着调用链走。proc.go的 8176 行里,你一次只需要读一个函数。 - 结构体先看
runtime2.go。g/m/p的定义都在那里(g在 :471,m在 :616,p在 :774,全局schedt在 :932)。读任何调度代码前,先把这三个结构体的字段注释扫一遍。 - 用本卷的
文件:函数定位,不要背行号。 行号会随版本变,函数名不会。本卷所有引用都写成文件:函数,你在自己版本里grep一次即可复现。
最后一条提醒:目录名不等于子系统名。trace.go 是 runtime 内部的事件记录,trace/trace.go 才是 runtime/trace 包面向用户的 API;pprof/ 是 runtime/pprof,而 runtime 自己那套 MemStats 在 mstats.go、gctrace 的打印在 mgc.go(mprof.go 管的是 block/mutex 剖析)。混读这两个会浪费很多时间。
把这一节的方法论压缩成一份可执行的清单,读任何 runtime 代码前照做:
- 先定问题。 把「我想搞懂调度」拆成「我想知道一个阻塞的 goroutine 什么时候被重新放回队列」——问题越具体,路线越短。
- 先 grep 入口,再打开文件。
grep -n "^func" 文件拿到函数清单,找到入口,别从第 1 行读。 - 确认结构体。 打开
runtime2.go对应的type,把字段和注释扫一遍,再回到逻辑代码。 - 顺调用链走 2~3 跳。 入口 → 它调用的关键函数 → 那个函数的关键分支,通常就够回答问题了。
- 用本节的
文件:函数对照自己的版本。 行号会漂移,函数名稳定。 - 把结论写回实验。 源码读到的假设,用第 1.1 节的工具去验证——这是本卷与「读源码消遣」的分界线。
还有一个容易忽略的对应关系:你在源码里读到的每个状态迁移,几乎都能在 runtime/trace 里找到对应的事件。gopark(proc.go:457)对应 trace 里的 GoBlock,execute(proc.go:3346)对应 GoStart,newproc 对应 GoCreate。所以读源码和看 trace 是同一件事的两个方向:源码告诉你「为什么」,trace 告诉你「多久、多少次」。本卷后面的每一节都同时给这两样。
最后是四条路线的推荐阅读顺序。如果你没有具体的排障目标,按这个顺序读收益最高:
- 先路线一(调度)。 它是 runtime 的主干,
schedule/findRunnable/gopark/execute这四个函数构成了整本书的骨架,第 2 章会把它们串起来。 - 再路线二(分配)。 分配是 GC 的输入,理解
mallocgc才能理解第 6 章说的「分配速率决定 GC 频率」。 - 然后路线三(GC)。 有了调度与分配的基础,
gcStart/mgcmark/mgcpacer的协作才讲得通。 - 最后路线四(编译器)。 它解释的是「为什么这段代码会走上面三条路线里的某一条」,属于回头看的一层。
- 每一节读完,回到 1.1 的决策表选一个工具验证一次。 只读不验,等于没读。
阅读导航:上一节:1.1 实验驱动方法:GODEBUG、pprof、trace 与 SSA dump · 下一节:1.3 复现基线:环境与基准约定 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。