Runtime 文章很容易变成名词表:G-M-P、work stealing、mcache、三色标记、写屏障、Netpoller。每个词都能背两句,真正排查 CPU 打满、GC 频繁或 goroutine 泄漏时却串不起来。

我更愿意把 runtime 看成一组相互影响的机制:调度器决定 goroutine 在哪个线程上运行,分配器为对象找内存,GC 回收不再可达的堆对象,Netpoller 让网络等待不长期占住 OS 线程。

本文以 Go 1.26.1 为准。Runtime 是内部实现,字段布局、阈值和算法都可能继续变化。

G、M 和 P 分别解决什么

  • G 是 goroutine 的 runtime 表示,保存栈、调度状态、等待原因和要继续执行的位置。
  • M 对应一个 OS 线程。M 真正执行 Go 代码或 runtime 代码。
  • P 持有执行 Go 代码所需的调度与分配上下文,包括本地运行队列和 mcache。P 的数量由 GOMAXPROCS 控制。

M 要执行 Go 代码必须关联一个 P。当 M 陷入可能长时间阻塞的系统调用时,runtime 可以把 P 交给另一个 M,让其他 G 继续运行。

GMP

可运行 G 放在哪里

每个 P 有本地运行队列,runtime 还有全局运行队列。调度器大致会从这些地方找任务:

  1. 当前 P 的 runnext,用于优先运行刚准备好的 G。
  2. 当前 P 的本地队列。
  3. 按调度节奏从全局队列取一批 G,避免全局任务饥饿。
  4. Netpoller 返回的已就绪 G。
  5. 从其他 P 偷取一部分本地任务。

这不是一个业务可见的严格优先级队列。调度器会为了公平、局部性和计时器等因素调整路径,代码不能依赖两个 goroutine 的具体执行顺序。

goroutine 如何让出 CPU

G 可能在下面这些情况下停止当前运行:

  • channel、Mutex、Cond 或 WaitGroup 上阻塞。
  • 网络 I/O 没有就绪,转交 Netpoller。
  • 调用 runtime.Gosched 主动让出。
  • 进入阻塞系统调用,M 可能与 P 分离。
  • 被调度器抢占。

Go 1.14 起支持基于信号的异步抢占,长时间运行且不主动调用 runtime 的计算循环也能被抢占。这改善了调度公平性和 GC 安全点问题,但 CPU 密集任务仍然会真正消耗 CPU。抢占不会把无界计算变成免费资源。

goroutine 栈:小起点,动态增长

goroutine 的栈不需要像传统线程那样一开始就保留大块固定空间。Runtime 使用可增长栈,栈空间不足时分配更大的栈并调整指针,空间长期空闲时也可以收缩。

这是 Go 可以创建很多 goroutine 的原因之一,但“goroutine 很轻”不等于数量可以没有上限。每个 G 还有运行时元数据、可能的 timer、channel 等待节点和业务引用。十万个泄漏的 goroutine 照样会留住大量内存。

内存分配:从 P 本地缓存开始

Go 的堆分配器按对象大小和是否包含指针划分 span class。常见路径是:

P.mcache -> mcentral -> mheap -> operating system
  • mcache 属于 P,为常用 span class 保留可用 span,小对象分配的热路径通常不需要全局锁。
  • mcentral 管理某个 span class 的部分使用和空闲 span,为 mcache 补充。
  • mheap 管理页和大块堆内存,必要时向操作系统申请或归还页。

不含指针且小于 16 字节的小对象还可能走 tiny allocator,多个小对象共用一个分配块,减少分配器元数据和空间浪费。

大对象、大 slice 和高频临时对象都可能增加堆活跃字节和 GC 工作量。优化时先看 allocation profile,不要一看到 new 就认定有堆分配。编译器可能把 new(T) 产生的对象放在栈上。

逃逸分析是编译器工作

变量在栈还是堆上分配,不是由 new&T{} 或“传指针”这一个语法决定。编译器根据值的生命周期和指针流向做逃逸分析。

type User struct {
    ID int64
}

func local() *User {
    u := User{ID: 1}
    return &u
}

u 的地址被返回,通常需要比 local 调用栈活得更久,因此会逃逸。编译器的决定可以直接查看:

go build -gcflags='all=-m=2' ./...

输出很多时,先只分析目标 package,再关注 escapes to heapmoved to heap 和无法内联的原因。

常见逃逸来源包括:

  • 返回局部对象的指针,或把指针保存到更长生命周期的结构中。
  • 闭包捕获的变量需要在外层函数返回后继续存活。
  • interface 装箱或反射路径让编译器无法证明值不逃逸。
  • slice 或数组大小无法在编译期确定,或超过编译器的栈分配边界。

“传指针一定更快”和“传值一定不逃逸”都不成立。小结构体的值拷贝可能很便宜,大结构体或需要共享修改时则可能更适合指针。用 benchmark 和 profile 决定,不用口诀。

并发 GC:大部分工作与用户代码同时进行

Go GC 是并发、非分代、非移动的 tracing collector。标记过程从 root 出发遍历可达对象,不可达的 span 在后续扫描中回收。

常见三色模型可以用来理解标记状态:

  • 白色:尚未确认可达。
  • 灰色:已可达,但它引用的对象还没扫描完。
  • 黑色:已可达,引用也扫描完成。

用户 goroutine 在并发标记期间仍然会修改指针。Runtime 通过混合写屏障保存标记不变式,避免已可达对象因并发修改被漏标。

“并发 GC”不表示完全没有 STW。一轮 GC 仍然需要短暂停止世界来开启写屏障、启动标记,并在标记结束时完成状态切换。另外,并发标记本身会消耗 CPU,分配过快时用户 goroutine 还可能被要求做 mark assist。

GOGC 和内存上限

GOGC 控制下一轮 GC 的堆增长目标。较小的 GOGC 降低堆内存,但会更频繁地付出 GC CPU;较大的 GOGC 减少 GC 频率,但允许更大的堆。

GOMEMLIMITdebug.SetMemoryLimit 为 runtime 提供软内存上限。它不是容器 OOM 的硬隔离,也不包含所有 cgo 和内核资源。设置时要给非 Go 堆内存留余量。

GOGC=100 GOMEMLIMIT=768MiB ./service

如果内存上限远低于应用的实际 live heap,runtime 只能越来越频繁地 GC,最终出现 GC thrashing。限制不会把必须存活的对象变小。

Netpoller:网络阻塞不等于线程阻塞

Go 的 net 包将非阻塞网络文件描述符注册到 runtime Netpoller。在 Linux 上底层使用 epoll,macOS/BSD 使用 kqueue,Windows 使用 IOCP。

conn.Read 暂时没有数据时,当前 G 会挂起并记录在 poll descriptor 的等待状态中,M 可以运行其他 G。操作系统通知 fd 就绪后,Netpoller 将对应 G 变回 runnable,交还调度器。

G calls Read
  -> fd not ready
  -> G waits in netpoll
  -> M runs another G
  -> epoll/kqueue reports readiness
  -> G becomes runnable

这个模型主要适用于可轮询的网络 fd。普通磁盘文件 I/O、某些 syscall 和 cgo 调用可能真正阻塞 M。Runtime 可以增加或复用其他 M 维持 Go 代码运行,但大量阻塞调用仍然会增加线程数和调度成本。

怎么观察 runtime

理解 runtime 要从具体问题入手,再选择对应的观察工具。

pprof

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/allocs
go tool pprof http://localhost:6060/debug/pprof/goroutine
go tool pprof http://localhost:6060/debug/pprof/mutex
go tool pprof http://localhost:6060/debug/pprof/block
  • CPU profile 看时间花在哪里。
  • heap 看当前仍存活的堆对象,alloc 看累计分配。
  • goroutine 看数量与等待栈,mutex/block 看锁和阻塞热点。

runtime/trace

Execution trace 适合看 goroutine 在 runnable、running、syscall 和 waiting 状态之间如何变化,也能看 GC、network blocking 和 scheduler latency。

go test -run '^$' -bench . -trace trace.out ./path/to/pkg
go tool trace trace.out

runtime/metrics

runtime/metrics 提供版本化的 runtime 指标,比解析 runtime.MemStats 或文本输出更适合程序化采集。选指标时仍要回到问题:是 live heap 变大、分配速率变快、goroutine 在增长,还是 scheduler latency 升高。

把机制串起来

一个高并发网络服务可能经历这样的链路:

  1. Netpoller 唤醒等待 socket 的 G,调度器将它放回 P 的运行队列。
  2. G 解码请求并分配临时对象,小对象多数从 P 的 mcache 获取。
  3. 某些对象因闭包或异步队列持有而逃逸到堆。
  4. 分配速率过高推动 GC 提早开始,mark worker 和 mutator assist 消耗 CPU。
  5. CPU 竞争使 runnable G 等待变长,最终反映在接口尾延迟上。

这时只把 GOMAXPROCS 调大、增加 sync.Pool 或把 GOGC 调高都可能治标不治本。先用 profile 确认分配来源,再用 trace 看调度与阻塞,通常比直接改 runtime 参数更可靠。

参考源码与文档