1. 一次空循环拖垮整个进程:先看调度器到底在调度什么
去年调一个内部工具时碰到一个现象,到现在印象都很深:一段看起来人畜无害的代码,在 Go 1.13 上跑,进程直接卡死;同样代码换到 Go 1.14 之后跑,虽然 CPU 高,但进程还能动弹。代码长这样:
go复制func main() {
go func() {
for {}
}()
select {}
}
往下分析之前,先说结论:不是编译器出了问题,也不是死锁检测失效,而是 Go 调度器在靠“协作式抢占”工作的年代,拿一个不触发任何调度的空循环毫无办法。要真正理解这个案例,就必须把 Go 的协程调度模型和操作系统线程调度这两套东西放在一起看。
Go 的调度不是简单的“一个 goroutine 对应一个线程”,也不是“协程跑在共享线程池里”这么一句能糊弄过去的。它在用户态重新实现了一整套调度逻辑:G 是协程,M 是操作系统线程,P 是运行 Go 代码所需的处理器资源。三个字母各司其职,缺一不可。这套模型本质上解决的是“如何用少量线程承载海量协程,同时又不让锁竞争和缓存抖动把性能吃光”的问题。
这篇文章我会按自己的理解,把这套调度器从设计动机、核心结构、调度循环,到抢占、阻塞 IO、观测调参,完整过一遍。它不是源码逐行讲解,但我会把关键机制、状态流转和踩过的坑讲透,适合已经写过一段时间 Go、想深入理解运行时行为的读者。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GMP 模型里每个角色都不白给:G、M、P 的职责边界
2.1 G:用户态上下文,不只是“一段函数”
G 代表一个 goroutine 的完整运行上下文。在运行时源码里,g 结构体里保存了栈信息(stack)、栈增长哨兵(stackguard0)、当前的 m 指针、调度寄存器上下文(g.sched)、状态字段 atomicstatus 等。
这里最容易被忽略的一点是:G 有自己独立的状态机。常见的状态大体有:
_Gidle:刚分配,还没初始化_Grunnable:可运行,等着被调度_Grunning:正在某个 M 上执行_Gwaiting:阻塞在锁、channel、IO 或系统调用上_Gsyscall:正在执行系统调用_Gdead:已退出,等待复用
G 默认初始栈只有 2KB 左右,需要扩容时通过 morestack 拷贝到更大的栈上。这比操作系统线程动辄 MB 级别的栈空间小得多,所以“开一万个 goroutine”在内存上是可行的,但代价是 GC 在扫描活跃 goroutine 栈时,数量越多、扫描时间越长,它不是完全免费的。
2.2 M:操作系统线程的封装,负责真正执行
M 直接对应一个操作系统线程。每个 M 内部有两个重要的 G 指针:curg 表示当前正在执行的用户 goroutine,g0 是专门用于运行调度代码和运行时函数的系统栈。
g0 是个很容易被忽略但极其关键的存在。调度器本身也是代码,它不能在用户 G 的栈上运行,因为用户栈随时可能扩容、收缩或者被抢占。Go 的解决方案是:每个 M 分配一个独立的调度栈,所有调度逻辑(比如 schedule()、栈切换的汇编入口)都在这个 g0 栈上执行。
之所以说 M 和 OS 线程是“皮套”关系,是因为它没有独立的调度权。M 必须绑定一个 P 才能执行 Go 代码;没有 P 的 M 要么在自旋找活儿干,要么直接休眠,等待被唤醒。
2.3 P:调度资源的集装箱,控制并行度
P 是 processor 的缩写,但把它理解成“跑道”或者“工作台”更准确。P 的数量由 GOMAXPROCS 控制,它决定了同一时刻最多有多少个 goroutine 能真正并行执行。
每个 P 上有几样关键资源:
- 本地可运行队列
runq:容量为 256 的环形数组,用于存放等待执行的 G runnext:一个单独的槽位,优先执行被标记为“下一个”的 Gmcache:内存分配缓存,每个 P 持有独立的一份,避免分配内存时频繁抢全局锁- timer、GC 相关的一些字段
P 的状态也有一套流转,比如 _Pidle、_Prunning、_Psyscall、_Pgcstop、_Pdead。运行时会根据这些状态决定是否可以从 M 上卸下 P、转交给其他 M。
2.4 没有 P 行不行?理解它存在的意义
很多人初学 GMP 时都会问:既然 M 是线程,G 是协程,那 P 是不是多余的?我在很长一段时间里也觉得 P 只是用来限制并行的“计数器”。实际上,P 至少解决了三个层面的问题。
第一,并行度可控。GOMAXPROCS 直接决定 P 的数量,进而决定同时有几个线程在跑用户代码。调整并行度不需要频繁创建和销毁线程,调度器可以通过轮转 P 的归属来让 M“换岗”。
第二,本地队列减少锁竞争。如果所有 G 都堆在一个全局链表中,每次调度都要抢一把全局锁。有了 P 之后,绝大多数调
