在真正啃完Go语言调度这块内容之前,我一直觉得“协程调度”就是把goroutine打包丢给系统线程去跑,直到有次线上服务因为goroutine数量失控把延迟拖垮,我才回头把G-M-P模型从头到尾捋了一遍。后来再看并发代码,很多之前靠经验和运气排查的问题,其实都能用调度模型直接解释清楚。今天这篇就围绕Go语言协程调度展开,把Go语言的G-M-P模型从角色定义、调度循环、可观测手段,一直聊到工程写法上那些真正值得避开的坑。
我会先讲清楚G、M、P三个角色分别管什么,再跟你走一遍“一次go func()发生什么”的完整路线,然后用GODEBUG和trace这类工具把调度行为拉到眼前,最后落到真实项目里最容易让调度器吃力的三类场景。中间我会穿插一些个人排查经历,尽量让每个结论都有代码或者观测依据,而不是空谈理论。
1. 先从三张身份卡说起:G、M、P分别管理什么
很多人看G-M-P模型,第一反应是用“G是协程、M是线程、P是处理器”来记。这句话没错,但它太简略,会让人忽略一个关键事实:在Go的调度体系里,真正决定“并发能力”的单位不是G也不是M,而是P。
1.1 G:一张会自己长大的栈,外加一套完整状态机
G代表一个goroutine。但和常见任务对象不一样,它不是一个简单函数闭包,而是一个带栈、带寄存器上下文、带当前状态的结构体。一个G在被创建时,最让人惊讶的特点是它不需要一开始就分配大块栈空间,现代Go运行时的初始栈很小,常见的是2KB左右,之后随调用深度动态增长。栈不够用的时候,runtime会把它整体搬到更大的内存区域,这个过程叫栈扩容;反过来,如果跑完一个深调用后栈长期闲置,也可能收缩。这套机制是“goroutine可以大规模创建”的基础——很多系统线程之所以无法开到几十万级,一个重要原因就是每个线程默认栈太大。
真正让我觉得G像“状态机”而非“任务”,是因为在调度器内部,G会在一系列状态之间流转。核心状态包括 _Gidle(刚被分配还未初始化)、_Grunnable(已经放入可运行队列、等着被调度)、_Grunning(正在某个M上执行)、_Gsyscall(正在执行系统调用)、_Gwaiting(被阻塞在channel、锁或网络等待上)、_Gdead(执行完或刚被回收)。状态与状态之间的跳转,就是调度器所有逻辑的入口。比如一个G从 _Grunning 变成 _Gwaiting,通常发生在它调用 channel <-、<-channel、sync.Mutex.Lock() 或 time.Sleep() 的时候;一旦条件满足,它又会被唤醒回到 _Grunnable。
这个视角很有用。排查性能问题时,如果你能看到大量goroutine停在 _Gwaiting,那说明阻塞主导了程序;如果大量goroutine都在 _Grunnable 但迟迟不进入 _Grunning,那才更像调度本身在排队。很多网上文章把“goroutine太多”和“调度慢”画等号,其实是把两件事混在一起了。
1.2 P:工位比工人更能说明问题
我习惯把M比作“工人”,P比作“工位”,G比作“要处理的活”。工人再多,工位只有那么多,真正同时干活的任务数不会超过工位数。P数量由 GOMAXPROCS 决定,默认值等于CPU逻辑核数。这个限制非常关键:就算你创建了一百万个goroutine,真正能在同一时刻处于 _Grunning 状态的,不会超过P的数量。
每个P手里维护一个本地可运行队列,常见资料里说它的容量是256。这个本地队列是调度的核心资产,因为大多数goroutine被创建时,会优先塞进“当前P自己的队列”,而不是一个需要加锁的公共全局队列。如果所有goroutine都丢进一个全局队列,那么每个M取任务都要竞争一把大锁,P的引入本质上是为了把队列拆散、减少锁冲突。
P上还藏着一个容易被忽略的字段:runnext。调度器在放入一个新的可运行G时,会优先把它放到runnext这个单槽位里,而不是本地队列尾部。下一次调度时,当前P会先看runnext,如果有就运行它。这个设计为什么会存在?因为它给了新创建的goroutine一个“插队”的机会,在并发的父子任务场景下,新创建的goroutine往往携带了最新需要处理的数据,尽快运行它通常能让延迟更低。当然,“插队”意味着不公平,但Go调度器从来不是为绝对的公平设计的,它更在意整体吞吐和延迟。
理解P的意义之后,很多经验就能对上号了:如果你想在CPU密集任务上通过把 GOMAXPROCS 调到远大于核数来提速,大概率没用,因为可并行的P没增加,多出来的只是排队和切换开销。
1.3 M:系统线程本体,以及为什么它会“没P可拿”
M是操作系统线程在Go运行时的抽象。每个M要执行Go代码,必须先绑定一个P。但实际运行中,M的数量可以远大于P的数量。比如一个M因为阻塞系统调用暂时把P让出来了,Go运行时为了不浪费这个P,会让另一个空闲的M接手这个P继续跑goroutine,于是线程总数就可能超过P总数。
更值得
