Go:goroutine 与 GMP —— 把调度器搬进用户态
Kotlin 用编译器解决问题(把函数变成状态机)。Python 用库解决问题(一个事件循环 + async 语法)。Go 选了第三条路,也是最激进的一条:把整个调度器搬进 runtime,然后让语言层面假装什么都没发生。结果是:Go 里没有 async,没有 await,没有 suspend,没有函数染色。你写的 conn.Read() 看起来就是一个朴素的阻塞调用 —— 而它跑起来是一次 epoll 事件循环上的 park。这是 Go 在并发上最漂亮的一手,也是它最大的产品决策。
goroutine:为什么它这么便宜
go doWork() // 就这样。没有 async,没有 Future,没有线程池。 // 这一行做的事: // 1. 在堆上分配一个 g 结构体(几百字节) // 2. 给它一个 2KB 的初始栈 // 3. 塞进当前 P 的本地运行队列(无锁!) // 总耗时:几百纳秒。 // 对比:new Thread().start() 要几十微秒 + 1MB 栈。
它不够用。所以它会长。
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」两层?
因为有一件事会毁掉一切:阻塞的系统调用。
干这件事的是
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() 那一行【继续往下执行】
这是 Go 的核心魔术,而且它是在一个你看不见的层面完成的。
回到第 3 章的四象限表 —— Go 的 conn.Read() 坐在第 ② 格(同步风格 + 非阻塞行为):
- 代码看起来:同步、阻塞、朴素。没有 async,没有 await,没有回调,没有染色。
- 跑起来:epoll 事件循环 + 用户态调度。十万个连接,几个线程。
Kotlin 用 suspend 关键字买到这个(代价:染色)。
Python 用 async/await 买到这个(代价:染色 + 生态分裂)。
Go 什么都不用写 —— 因为它从第一天就把这件事做进了 runtime 和标准库。
这是「从零设计一门并发语言」才有的奢侈。Kotlin 要兼容 JVM,Python 要兼容三十年的 C 扩展 —— 它们都没有这个自由。
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 踢下来
// → 换别人跑
| 后果 | 调度类型 | |
|---|---|---|
| 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 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 里最强的同步原语。