需要预先了解:


1. 调度器:GMP 模型深入

goroutine 与并发初探 里给出了 GMP 的概览。这一篇展开内部结构:G 里存了什么、调度器怎么选下一个 G、work stealing 和 hand-off 如何工作、抢占是怎么实现的,以及为什么网络 IO 不会占住线程。


1.1 G、M、P 的职责

  G   G   G   G            G = goroutine(任务:栈 + 状态 + 调度信息)
  |   |   |   |
  v   v   v   v
+---------------+          P = 逻辑处理器(本地队列 + 资源配额)
| P    runq     |
+---------------+
        |
        v
+---------------+          M = 内核线程(真正执行指令)
| M             |
+---------------+
结构 内容
G 栈指针、栈边界、状态、所属 M、等待原因等
M 操作系统线程,与内核线程一一对应
P 本地运行队列 runq、mcache、mcentral 缓存、状态

一个 M 必须绑定一个 P 才能执行 G。GOMAXPROCS 决定 P 的数量,也就决定了同时执行用户代码的 M 的上限。

G 的状态在运行时会经历 _Grunnable(可运行)、_Grunning(运行中)、_Gwaiting(等待,如阻塞在 channel)、_Gsyscall(系统调用中)、_Gdead(已结束)等。


1.2 调度主循环

每个 M 的主循环大致是:

schedule():
  1. 从当前 P 的本地队列取一个 G
  2. 本地队列空 → 从全局队列取
  3. 全局队列也空 → 检查 netpoller 是否有就绪的 G
  4. 还没有 → 从其他 P 偷(work stealing)
  5. 实在没有 → 休眠,等待唤醒

execute(g):
  切换到 g 的栈,开始运行

取 G 的优先级是 本地队列 → 全局队列 → netpoller → 其他 P。本地队列优先是因为它无锁、访问最快。

本地队列的容量是 256。当队列满时,新创建的 G 会有一半被转移到全局队列,避免本地队列无限增长。


1.3 work stealing

当某个 P 的本地队列为空时,它会随机选择另一个 P,偷走对方一半的 G(准确地说,偷 n/2 个):

P1 队列: [G1 G2 G3 G4 G5 G6]        P2 队列: []

偷取后:
P1 队列: [G1 G2 G3]                 P2 队列: [G4 G5 G6]

偷一半而不是偷一个,是为了减少偷取的频率:一次偷取能支撑一段时间的执行。

如果所有 P 都没有活干,M 会进入休眠(挂到空闲 M 列表),等待新的 G 到来时被唤醒。这个"唤醒"由 netpoller 或新建 goroutine 时触发。


1.4 hand-off

当 M 因为系统调用阻塞时,它绑定的 P 不能干等——否则一个阻塞的调用就会浪费一个 P。调度器的做法是 hand-off:

M1(执行 G1,进入系统调用)
    │
    ├── 释放 P1
    │
    └── P1 被交给另一个 M2(或新建 M),继续执行其他 G

M1 的系统调用返回后:
    ├── 尝试重新获取一个空闲 P
    └── 如果拿不到,G1 被放到全局队列,M1 休眠

这就是"阻塞系统调用不会卡住整个程序"的原因。文件 IO、syscall.Read 这类调用走的是这条路径。


1.5 抢占演进

调度器必须能抢占长时间运行的 goroutine,否则一个死循环就会独占 P。

Go 1.14 之前:协作式抢占。 编译器在函数入口插入检查点,当栈需要增长或发生 GC 时触发抢占。如果一个 goroutine 里没有任何函数调用(比如 for {} 或纯计算的紧循环),它就不会遇到检查点,从而长期占用 P。

Go 1.14 起:基于信号的异步抢占。 运行时通过 SIGURG 信号中断线程,把 goroutine 的栈信息保存下来,让出 P。即使是没有函数调用的紧循环也能被抢占:

runtime.GOMAXPROCS(1)

go func() {
	for i := 0; ; i++ {
		// 没有函数调用的紧循环
	}
}()

time.Sleep(50 * time.Millisecond)
fmt.Println("主 goroutine 被调度了,说明异步抢占生效")
主 goroutine 被调度了,说明异步抢占生效
当前 goroutine 数: 2

GOMAXPROCS=1 时只有 1 个 P,紧循环的 goroutine 仍被抢占,主 goroutine 才有机会运行。


1.6 sysmon

sysmon 是一个不绑定 P 的后台线程,周期性(初始 20µs,最长 10ms)检查运行时状态,职责包括:

  • 检查是否有 goroutine 长时间运行,触发抢占;
  • 检查 netpoller 是否有就绪的网络事件;
  • 回收长时间未使用的 P 和 M;
  • 检查是否到了 2 分钟的定时 GC;
  • 检测死锁并输出 fatal error: all goroutines are asleep - deadlock!。

因为它不需要 P,即使所有 P 都忙于用户代码,sysmon 也能继续工作。


1.7 netpoller

网络 IO 是并发程序的主要等待来源。如果每个网络读写都阻塞一个 M,几千个连接就会耗尽线程。

netpoller 利用操作系统提供的多路复用机制(Linux 上是 epoll,macOS 上是 kqueue,Windows 上是 IOCP),把网络文件描述符统一管理:

goroutine 发起 Read
      │
      ├── 数据没就绪 → 把 fd 注册到 netpoller,G 进入等待,M 被释放
      │
      └── 数据就绪 → netpoller 唤醒对应的 G,放回运行队列

所以"阻塞的网络 IO"实际上不占用 M:G 被挂起,M 去执行其他任务。这就是 Go 能轻松支撑数十万并发连接的关键。

普通文件 IO 不走 netpoller(大多数平台的文件读写总是"就绪"的),而是走 hand-off 路径。


1.8 常见场景下发生了什么

场景 调度行为
阻塞在 channel 收发 G 进入 _Gwaiting,挂到 channel 的等待队列,M 去找别的 G
阻塞在网络 IO G 挂到 netpoller,M 被释放;数据就绪后 G 被唤醒
阻塞在文件 IO / 系统调用 发生 hand-off:P 交给其他 M,M 自己阻塞在系统调用里
time.Sleep G 挂到定时器上,到期后被放回运行队列
cgo 调用 M 进入系统调用式的阻塞,同样触发 hand-off;频繁 cgo 会导致 M 数量膨胀

观察调度状态可以用 GODEBUG=schedtrace:

GODEBUG=schedtrace=500 go run .
SCHED 505ms: gomaxprocs=24 idleprocs=24 threads=31 spinningthreads=0 needspinning=0 idlethreads=26 runqueue=0 [ 0 0 ... ] schedticks=[ 99 102 93 82 97 82 7 7 ... ]
字段 含义
gomaxprocs P 的数量
idleprocs 空闲的 P 数量
threads 创建的 M 总数
spinningthreads 正在自旋寻找工作的 M(说明有 P 闲着但找不到活)
runqueue 全局队列里的 G 数量
[...] 每个 P 的本地队列长度
schedticks 每个 P 的调度次数

1.9 GOMAXPROCS 与容器环境

GOMAXPROCS 决定并行的上限。用 4 个 CPU 密集 goroutine 测试:

GOMAXPROCS= 1 耗时 46.1102ms
GOMAXPROCS= 4 耗时 13.6885ms
GOMAXPROCS=24 耗时 14.7212ms

从 1 到 4 接近线性加速;再增大没有收益,因为只有 4 个 goroutine。并行度受限于"可运行的 goroutine 数"和"CPU 核数"两者中较小的那个。

在容器里有一个历史坑:早期版本 GOMAXPROCS 默认取宿主机的 CPU 数,而不是容器被限制的 CPU 配额。比如容器 --cpus=2,但宿主机有 64 核,程序会起 64 个 P,导致严重的调度抖动。

  • Go 1.25 起,运行时会感知 cgroup CPU 限制,自动把 GOMAXPROCS 调整到容器配额;
  • 更早的版本可以用 uber-go/automaxprocs 这类库在启动时读取 cgroup 限制并设置 GOMAXPROCS。

手动设置的方式:

runtime.GOMAXPROCS(4)

注:把 GOMAXPROCS 设得比 CPU 核数大,对 CPU 密集任务没有好处(只增加切换开销),但对 IO 密集任务通常也无必要——IO 等待由 netpoller 处理,不需要额外的 P。


1.10 练习

  1. 用 GODEBUG=schedtrace=1000 观察一个启动 100 个 goroutine 的程序,解读 idleprocs、runqueue 和每 P 队列长度的变化。
  2. 写一个 CPU 密集的程序,分别用 GOMAXPROCS=1 和 GOMAXPROCS=NumCPU 运行,记录耗时并解释差异。
  3. 对比"阻塞在 time.Sleep"与"阻塞在 os.Read(管道)"两种情况下 runtime.NumGoroutine() 与线程数的变化,说明调度器处理方式的不同。