你写了一个 for 循环往里扔十万个 goroutine,Go 一点都不怵,内存也没见涨多少,程序跑得飞快。这时候隔壁那位用 Java 写同样逻辑的同事脸都绿了——他说他开 100 个线程就已经内存报警了。
但真正的问题是:Go 凭什么敢这么干?
不是靠魔法,靠的是 Go 语言自己实现的一套调度器。这套东西叫 GMP,三个字母,三种角色,把「海量并发」这件事变成了流水线上的搬运工游戏。
这篇咱们把它拆开看,顺便聊聊几个让线上服务半夜报警的坑。
一、先说结论:goroutine 便宜,但「便宜」是有代价的
先纠正一个流传很广的误解:goroutine 不是不要钱,它只是把成本从「栈内存」换成了「调度开销」。
每个 goroutine 初始栈只有 2KB,而且是可以动态增长的。线程栈呢?默认 1MB 起,而且往往一次性分配到位。你开 100 个线程,光栈就是 100MB;开 10 万个 goroutine,初始栈加起来也就 200MB 不到。
这就是 Go 敢让你「随便开」底气所在。但反过来说:
goroutine 便宜,不代表 goroutine 泄漏不致命。一个泄漏的 goroutine 会带着它的栈、它引用的所有对象一起赖着不走。你以为你只是「忘了关一个 channel」,实际上你在给内存放血。
后面我会专门讲这个。现在先回到调度器本身。
二、G、M、P:一场精妙的三角关系
GMP 里的三个字母,对应三种实体:
- G(goroutine):你要跑的活儿。相当于车间里的任务单。
- M(machine):真正干活的 OS 线程。相当于工人。
- P(processor):逻辑处理器,是 Go 运行时用来「圈地」的许可证。有 P 你才能调度 G,没有 P,M 只能干瞪眼。
为什么要单独搞个 P?
因为 Go 1.1 之前是 G-M 两层结构,M 直接和 G 绑定,多个 M 抢同一个全局队列,加锁。上下文切换的损耗全花在锁上。后来官方加了 P,把「本地任务队列」这一层给独立出来——每个 P 有一个自己的私有队列(local run queue),调度时优先从本地队列取,避免争抢。
关键的绑定规则是:一个 M 在同一时刻最多绑定一个 P。而 P 的数量由 GOMAXPROCS 决定,默认等于逻辑 CPU 核心数。
所以你可以这么理解:P 是工位,M 是工人,G 是任务单。工位数(P)决定并行度,工人数(M)可以多于工位(比如有 M 卡在系统调用里),但只有占了工位的工人才有资格从任务池里领活。
三、任务怎么流转:本地队列优先,然后「偷」
Go 调度器的取任务顺序大致是这样的:
- 当前 P 的私有队列(local runq)
- 其他 P 的私有队列——「work stealing」,去偷一半
- 全局队列(global runq)
- 网络轮询器(netpoll)——等 I/O 就绪的 G
「偷一半」这个策略很有意思:偷的时候只拿别人队列的一半,剩下一半留在原地。因为偷多了会导致两个 P 互相偷来偷去,抖动起来 CPU 全在内核态空转,得不偿失。
而 runnext 槽位的存在更是经典设计:新创建的 goroutine 或者刚从系统调用返回的 goroutine,会被塞进当前 P 的 runnext 槽。这个槽位优先级最高,且被拿走时会「顶」掉原来槽位里的那个任务——所以叫 runnext 而不是 queue[0]。
// 看看当前 P 的私有队列长什么样(gdb 里可以看 runtime.allp)
func dumpLocalQueue() {
n := runtime.NumGoroutine()
fmt.Println("goroutine 数:", n)
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
}
全局队列不是垃圾场,它只承担两个用途:一是 go 语句在某些情况下(新 G 没有可用的 P、或者需要立即唤醒 M)会直接把 G 塞进全局队列;二是 go 语言的 runqput 判断出本地队列已经很长(超过 256 个)时,为了防止某个 P 的本地队列无限膨胀,会把一部分任务连同新任务一起扔进全局队列。
这就是所谓的「均衡策略」:本地快、全局兜底、netpoll 收尾。三级流水线,谁也不闲着。
四、抢占:Go 是怎么抢回 CPU 的
你写了个死循环:
for {
// 空转
}
如果是 Go 1.13 之前,这玩意儿能把一个 P 占死,其他 goroutine 全饿着——Go 没有抢占机制,协作式调度,你不让出,别人就上不来。这在 1.13 之前是个著名的坑,社区里流传着各种「十行代码复现」的段子。
现在不一样了。Go 1.14 引入了异步抢占(async preemption),机制很朴素:运行时给每个 P 装一个定时器(sysmon 在检查),发现某个 M 跑的时间太长了,就在系统调用层面给它发信号,然后把它踢下线,让别的 M 接手它手里的 G。
同时保留了原有的协作式让出点:函数调用、channel 收发、锁竞争、内存分配触发 GC 都会检查 preempt 标志。
所以准确的说法是:Go 现在既有协作式抢占,也有异步抢占,异步抢占是兜底,防止有代码死不让步。
而 sysmon 是这一切的后台保镖。它是一个独立于所有 P 之外的 goroutine,绑定在系统线程上,做三件事:
- 没有 P 空闲时,从全局队列偷任务
- 抢占长时间运行的 G
- 检查网络轮询器,把就绪的 G 唤醒
sysmon 的存在让 Go 的调度器在「P 全被占满」的情况下依然能自我救场。这也是为什么 runtime.LockOSThread 是个偏门的、慎用的 API——它会把 G 焊死在 M 上,而 M 是可以被 sysmon 抢走的。
五、坑一:GOMAXPROCS 和容器
这是近年最经典的生产事故之一。
Go 1.25 之前,runtime 通过 sched_getaffinity 来确定可用的 CPU 数量。但在容器环境里,如果 cgroup 的 CPU 限额(cpu.max)设置得比宿主机可见的核心数小,Go 看到的却是宿主机全部核心。
后果就是:一个限额只有 2 核的容器,Go 起了 64 个 P,调度器疯狂调度、疯狂 GC,而实际只有 2 核可用。结果是 CPU 被调度开销吃满,延迟飙升,你的 HPA 看着 CPU 指标一脸茫然。
Go 1.25 开始,runtime 会主动读取 cgroup 的 CPU 限额来确定默认 GOMAXPROCS,这个坑算是被官方堵上了。但在 1.25 之前的版本,你有两个选择:
// 方案一:显式设置,别让它瞎猜
runtime.GOMAXPROCS(2)
// 方案二:容器启动时直接给
// GOMAXPROCS=2 /app
// 方案三:用 automaxprocs 这类库自动读 cgroup
import _ "go.uber.org/automaxprocs"
另外注意:GOMAXPROCS 不是越大越好。它是并行度,不是并发度。你如果有 1000 个 goroutine 在等 I/O,那 P 的数量对它们没影响;如果你有 1000 个 goroutine 在死命算 CPU,那 P 越多,GC 的并发回收就越猛,内存占用会上去。一般让 P = 物理核心数就对了。
六、坑二:goroutine 泄漏
前面提过,这是 Go 服务最常见的慢性死亡方式。典型场景:
// 反面教材:没人接收,goroutine 永远卡在 ch <- v
func worker(ch chan int) {
for v := range ch {
process(v)
}
}
func bad() {
ch := make(chan int)
go worker(ch)
ch <- 1
// 函数返回,ch 没人 close,worker 永远阻塞在这
}
更隐蔽的版本是用 channel 做信号量,只发不收:
func bad2(reqs chan *Request) {
sem := make(chan struct{}, 10)
for _, r := range reqs {
sem <- struct{}{} // 满了就永久阻塞
go handle(r)
}
}
排查泄漏其实不难,pprof 的 goroutine profile 加上一个「等了几秒再看一次」的差分就能定位:
import (
"runtime/pprof"
"time"
"os"
)
func dumpGoroutines(tag string) {
f, _ := os.Create("goroutine-" + tag + ".pprof")
defer f.Close()
pprof.Lookup("goroutine").WriteTo(f, 1)
time.Sleep(10 * time.Second)
}
然后对比两次 dump,增长的 goroutine 栈顶就是泄漏点。context.Context 配合 cancel 是最常用的解药,几乎所有会阻塞的第三方库都支持 ctx.Done(),用起来能省掉 80% 的泄漏事故。
七、坑三:I/O 阻塞时,线程去哪了
一个容易误解的点:goroutine 发起阻塞 I/O 时,占着它的 M 会怎样?
答案是:M 会让出 P,然后去干别的。Go 运行时底层用的是 netpoll(epoll/kqueue 的封装),I/O 没就绪时,M 会把 G 挂起,释放 P 给别的 G 用;I/O 就绪后,netpoller 线程把 G 放回就绪队列,交给某个空闲 M。
这就是 Go 用少量线程扛住海量网络请求的秘诀:
阻塞的从来不是线程,是 goroutine。线程是稀缺的、贵的(栈大、切换重),goroutine 是廉价的、可堆的。所以设计上一律让 goroutine 去阻塞,线程永远保持可运行。
但有两个例外要小心:
- cgo 调用:M 进入 cgo 后不可被抢占,而且会占着 P(除非用了
runtime.LockOSThread的特殊模式)。cgo 调用多了,调度器会被拖垮。这也是为什么用 SQLite、或者某些图像库时性能会莫名其妙地塌。 - 文件 I/O:Go 的文件读写虽然会进入 netpoll 风格的等待,但很多平台实现上并不是真正的异步通知,阻塞时会真正挂起线程。所以「大量并发写文件」的场景,Go 并不比 Java 有优势。
八、调度器的观测手段
调度器不是黑盒。几个必备的调试开关:
# 每 1000ms 打印一次调度器状态
GODEBUG=schedtrace=1000 ./app
# 更详细:看每个 P 的队列长度、抢占次数
GODEBUG=scheddetail=1,schedtrace=1000 ./app
# 关闭异步抢占,强制协作式(排查诡异死锁时有用)
GODEBUG=asyncpreemptoff=1 ./app
schedtrace 的输出大概长这样:
SCHED 0: 0ms SCHED 0: 0ms
GOMAXPROCS=8 GOMAXPROCS=8
P idle: 0/0/0/1/0/0/0/0 M idle: 7 Ms: 8
S idle: 0 S total: 0
G waiting: 0 G running: 3 P idle: 5
CHAN 0 (0) CHAN 1 (0) CHAN 2 (0)
MC: 0/0/0/0 MC wait: 0
P 0: (g0) c0 m0 i0
P 1: (g1) c1 m1 i0
...
重点看三个数:G waiting(等待被调度的数量)、G running(真正在跑的)、M idle(闲着的 OS 线程)。如果 G waiting 长期很高而 CPU 很闲,说明调度出问题了——通常是某个 G 卡在无法被抢占的地方,比如 cgo 或者死循环里的汇编。
再配合 pprof 的 goroutine profile、block profile、mutex profile 三个视角,基本能把调度问题定位干净。
九、写在最后
GMP 的设计其实回答了一个很朴素的问题:怎么在语言层面把并发成本降到几乎为零,同时还能真正用满多核。
它的答案是:把「轻量任务」和「昂贵资源」(OS 线程)解耦,用 P 做局部队列减少争抢,用 work stealing 保证负载均衡,用 netpoll 把阻塞 I/O 挪出线程,用 sysmon + 异步抢占兜底防止单点饿死。
但再好的调度器也救不了坏代码。回头看那些最容易出事的场景,其实都跟调度器本身无关,而是:
- goroutine 起了没有收(泄漏)
- context 没有透传(取消不掉)
- 无脑加锁或者无脑开 goroutine
- 在容器里没管 GOMAXPROCS
工具链给你的是能力,不是免死金牌。用好 go tool pprof、go tool trace、GODEBUG=schedtrace,这才是真正能把并发写稳的路径。
毕竟,goroutine 只是让你轻松地同时干很多事,但每一件事干得好不好,还得看你自己。
—— 完 ——