幕 III · 三种活法CH 16进度 16/25

Go:goroutine 与 GMP —— 把调度器搬进用户态

Kotlin 用编译器解决问题(把函数变成状态机)。Python 用解决问题(一个事件循环 + async 语法)。Go 选了第三条路,也是最激进的一条:把整个调度器搬进 runtime,然后让语言层面假装什么都没发生。结果是:Go 里没有 async,没有 await,没有 suspend没有函数染色。你写的 conn.Read() 看起来就是一个朴素的阻塞调用 —— 而它跑起来是一次 epoll 事件循环上的 park。这是 Go 在并发上最漂亮的一手,也是它最大的产品决策。

GMPwork stealingnetpoller异步抢占GOMAXPROCS

goroutine:为什么它这么便宜

go doWork()      // 就这样。没有 async,没有 Future,没有线程池。

// 这一行做的事:
//   1. 在堆上分配一个 g 结构体(几百字节)
//   2. 给它一个 2KB 的初始栈
//   3. 塞进当前 P 的本地运行队列(无锁!)
//   总耗时:几百纳秒。

// 对比:new Thread().start() 要几十微秒 + 1MB 栈。
◆ 定论 · 2KB 的栈,怎么够用

它不够用。所以它会长。

Go 编译器在每个函数的开头插入了一段检查代码(叫 morestack 检查):

CMPQ  SP, 16(R14)     ; 当前栈指针 vs 栈边界
JBE   morestack       ; 快撑爆了? → 去扩容

扩容的做法:分配一块 2 倍大的新栈,把老栈的内容整个拷过去,然后修正所有指向老栈的指针。(最后这一步是 Go 能做到而 C 做不到的原因 —— Go 有精确的类型信息,知道哪些是指针。)

后果:goroutine 的栈是「按需生长」的。大多数 goroutine 一辈子只用几 KB。而线程必须从第一天就预留最坏情况的空间。

这就是 2KB vs 1MB 的全部秘密。(顺带:栈也会「缩」—— GC 时如果发现栈用得少,会收回去。)

GMP:三个字母,一套调度器

下面这个调度台分六幕。请一幕一幕点过去 —— 第 ④ 和第 ⑤ 幕是 Go 设计的精华。

为什么 M 和 P 必须分开

这是整个 GMP 设计里最不直观、也最关键的一点。为什么不能只有「线程」和「goroutine」两层?

因为有一件事会毁掉一切:阻塞的系统调用

如果没有 P:一个 syscall 拖死所有人假想的两层设计
线程 M1 跑 G3 G3 调了 read(file) → 陷进内核,M1 动不了了
G1, G2 … 在 M1 的队列里排着队,一起饿死
问题 如果「运行队列」直接挂在线程上,那这个线程一被内核卡住,它队列里的所有 goroutine 就跟着一起死。
有了 P:队列跟着 P 走,P 可以换个线程Go 的真实设计
M1(陷住了) 跑 G3 G3 卡在 syscall 里 —— 让它卡着,无所谓
P1(被摘走) 挂在 M1 上 摘! 交给 M3
M3(接手) (新建 / 复用) 拿着 P1,继续跑 G1、G2 …
这就是 P 存在的全部理由 P 是「运行队列 + 执行许可证」的载体。它可以从一个被卡住的 M 上摘下来,转交给另一个 M
干这件事的是 sysmon —— 一个不需要 P 就能跑的后台监控线程,每隔一小段时间扫一遍:「谁在 syscall 里待超过 20µs 了?把它的 P 抢过来。」
结果:一个 goroutine 被内核卡住,不会连累它的兄弟。

Go 最漂亮的一手:netpoller

但上面那套(摘 P、换 M)是有成本的 —— 要建线程、要做交接。如果每个网络请求都走这条路,一万个连接还是要一万个线程。

所以网络 IO 根本不走这条路。

// 你写的:
n, err := conn.Read(buf)     // 看起来是个朴素的阻塞调用

// runtime 实际做的:
//   1. 这个 fd 是【非阻塞】的(Go 在创建 socket 时就设了 O_NONBLOCK)
//   2. 试着 read() → 返回 EAGAIN(没数据)
//   3. 把 fd 注册进 netpoller —— 【这就是第 5 章那个 epoll_ctl】
//   4. gopark(): 把当前 goroutine 挂起,状态改成 _Gwaiting
//   5. 【M 立刻回去从 P 的队列里取下一个 G 来跑】← 关键!
//
//   ... 数据到了 ...
//
//   6. 某个 M 在调度循环里调 netpoll() → epoll_wait() → 「3 号 fd 好了」
//   7. 把那个 goroutine 塞回运行队列
//   8. 它被调度到,从 conn.Read() 那一行【继续往下执行】
◆ 定论 · 你写同步代码,跑的是 epoll

这是 Go 的核心魔术,而且它是在一个你看不见的层面完成的。

回到第 3 章的四象限表 —— Go 的 conn.Read() 坐在第 ② 格(同步风格 + 非阻塞行为):

  • 代码看起来:同步、阻塞、朴素。没有 async,没有 await,没有回调,没有染色。
  • 跑起来:epoll 事件循环 + 用户态调度。十万个连接,几个线程。

Kotlin 用 suspend 关键字买到这个(代价:染色)。
Python 用 async/await 买到这个(代价:染色 + 生态分裂)。
Go 什么都不用写 —— 因为它从第一天就把这件事做进了 runtime 和标准库。

这是「从零设计一门并发语言」才有的奢侈。Kotlin 要兼容 JVM,Python 要兼容三十年的 C 扩展 —— 它们都没有这个自由。

⚠ 陷阱 · 但文件 IO 不走 netpoller

epoll 对普通文件无效(第 5 章说过:内核认为普通文件的 fd「永远就绪」)。

所以 os.File.Read()真的阻塞系统调用 —— 它走的是「摘 P、换 M」那条路。

后果:如果你有大量的、慢的磁盘 IO(比如读很多小文件、或者一个慢的 NFS),Go 会疯狂创建 OS 线程(每个被卡住的 syscall 一个)。你可能会撞到 runtime: program exceeds 10000-thread limit

这不是 Go 的锅,是 Linux 的锅 —— 在 io_uring 普及之前,没有人能优雅地做异步文件 IO。(Node、Python 的做法一样:偷偷用线程池。)

抢占:Go 和其他协程系统最大的体质差别

回到第 3 章那个问题:一个不 yield 的 CPU 死循环,会怎样?

go func() {
    for {              // 死循环,没有函数调用,没有 channel 操作
        x++
    }
}()
// Go 1.13 及之前:这会【永久霸占一个 P】。
//   如果 GOMAXPROCS=1,整个程序卡死 —— 连 GC 都跑不了(GC 要 STW,而它停不下来)。
//   这是协作式调度的经典噩梦。

// Go 1.14 起:【异步抢占】
//   sysmon 发现这个 G 跑了超过 10ms
//   → 给它所在的 M 发一个 SIGURG 信号
//   → 信号处理函数在【任意指令边界】把 G 踢下来
//   → 换别人跑
◈ 三语 · 一个 CPU 死循环的下场
后果调度类型
Go 1.14+ 基本没事。10ms 后被信号踢下来。 抢占式(信号驱动)
Kotlin 占死一个 Default 线程(8 核 = 只有 8 个)。而且连取消都取消不掉 协作式(只在挂起点让出)
Python asyncio 整个事件循环卡死。所有请求一起超时。 协作式(只在 await 让出)

这是 Go 一个被低估的优势:它对「写得不好的代码」更宽容。

在 Kotlin/Python 里,「不要在协程里做 CPU 密集的活」是一条你必须自己遵守的纪律。在 Go 里,runtime 帮你兜底了。

(但不要滥用这个宽容:抢占有成本,而且 GC 的 STW 阶段仍然需要所有 goroutine 到达安全点。)

调度器还有一些你该知道的细节

机制做什么为什么
本地队列 256 个上限 满了就把一半甩到全局队列 防止一个 P 囤积所有 G,让别的 P 有机会偷到
runnext 槽 新创建的 G 会被放进一个特殊的「下一个就跑我」的槽 局部性优化go f() 刚创建的 G,它要用的数据大概率还在缓存里。立刻跑它,缓存命中率最高。
每 61 次调度查一次全局队列 调度器不会一直只吃本地队列 防饥饿:否则全局队列里的 G 可能永远排不上
自旋线程 有 M 找不到活时,会先「自旋」一会儿再睡 赌马上会有新 G 出现 —— 省掉一次「睡下去再被唤醒」的开销
GOMAXPROCS 容器感知 Go 1.25 起:自动读 cgroup 的 CPU limit 这是一个等了很多年的修复。以前你的 pod 只有 0.5 核,但 Go 看到宿主机的 96 核,于是建 96 个 P、疯狂被 cgroup 限流。
(1.25 之前的标准做法:引入 uber-go/automaxprocs。)

怎么看 goroutine 在干嘛

$ GODEBUG=schedtrace=1000 ./myapp
SCHED 1004ms: gomaxprocs=8 idleprocs=6 threads=12 spinningthreads=1 \
              idlethreads=4 runqueue=0 [0 0 2 0 0 1 0 0]
#                                       ^^^^^^^^^^^^^^^ 每个 P 的本地队列长度
#             ^^^^^^^^ 全局队列长度
# runqueue 一直很长 → 你的 goroutine 比 P 多得多,调度器忙不过来

$ GODEBUG=schedtrace=1000,scheddetail=1 ./myapp   # 更详细,每个 G 都列出来

# 抓所有 goroutine 的栈(生产环境排查泄漏的利器,下一章详谈):
$ curl localhost:6060/debug/pprof/goroutine?debug=2

# 竞态检测(上线前必跑!):
$ go test -race ./...
$ go build -race    # 生产环境不要开:慢 2~20 倍,内存涨 5~10 倍
🕘 掌故 · Go 调度器不是一开始就这样的

Go 1.0(2012)的调度器只有 G 和 M,没有 P:一把全局锁护着一个全局运行队列。所有 M 抢同一把锁 —— 核越多越慢(第 4 章那条掉头向下的曲线)。

Go 1.1(2013),Dmitry Vyukov 引入了 P,带来了本地队列和 work stealing。这是 Go 并发性能的一次质变。

Go 1.14(2020),异步抢占。终于解决了「死循环卡死 GC」这个从 1.0 就存在的问题。

教训:连 Go 团队都花了八年,才把调度器做到今天这样。而这八年里,用户的代码一行都不用改 —— 这就是「把复杂度藏进 runtime」的红利。

✚ 动手

1. 开 100 万个 goroutine(每个 time.Sleep(time.Hour)),看内存占用。然后用 Java 试着开 100 万个线程。这个对比会让你记住这一章。

2. strace -f -e trace=epoll_wait,epoll_ctl 跑一个 Go HTTP 服务。你会看到 epoll_ctl(ADD) 在每次 Accept 后出现 —— 那就是 netpoller 在注册 fd。第 5 章的那个系统调用,一路撑到了这里。

3. 跑一次 GODEBUG=schedtrace=1000,看你的 runqueue 有多长。如果它一直不是 0 —— 你的 goroutine 太多了,或者有 goroutine 在霸占 P。

4. GOMAXPROCS 设成 1,跑你的服务。它应该还是能正常工作(只是慢)。如果它挂了 —— 你的代码里藏着一个对并行度的隐含依赖,那是个 bug。

下一章:调度器讲完了,讲 Go 的另一半 —— channel。「不要用共享内存来通信」这句口号,落到代码上是什么样,以及为什么无缓冲 channel 是 Go 里最强的同步原语。