自从我手头一个高并发服务在压测时出现诡异的吞吐量抖动,我才真正下决心把 Go 调度器的 GPM 模型从头到尾啃了一遍。之前零零散散看过不少文章,对 G、P、M 这三个字母都能说上一两句,但一旦遇到实际性能问题——比如为什么 GOMAXPROCS 设成 8 反而比设 16 时要稳?为什么某个 goroutine 卡住了会拖累整个服务?——就发现自己对底层的理解还是太浅。这篇文章我打算从头梳理一遍 GPM 模型,把调度器的设计动机、核心数据结构、调度循环的完整过程,以及我在实际项目中踩过的和调度器相关的坑都写出来。
这篇内容适合几类人看:正在学 Go 语言、想从“会用 goroutine”进阶到“理解 goroutine”的中级开发者;准备面试、需要系统梳理 GPM 相关知识点的求职者;以及像我一样在实际项目里遇到过 goroutine 数量失控、服务响应变慢、CPU 利用率上不去等问题的排障人员。我会尽量把每个机制背后的“为什么”也讲清楚,而不只是浮在“是什么”的层面。
1. 调度器的源头:多线程并发之痛与 Go 的解法
要理解 GPM 模型,得先回到最原始的问题:操作系统线程做并发,到底哪里不够用?
1.1 内核线程作为并发原语的三个硬伤
在 Go 出现之前,C/C++/Java 时代做高并发服务,主流方案是“一个连接一个线程”或“一个任务一个线程”。这套方案在连接数少的时候没什么问题,但一旦并发规模上来,内核线程的三大硬伤就暴露出来了。
第一个硬伤是创建和销毁的成本高。每次创建线程都要陷入内核态,分配内核栈、建立线程控制块(TCB)、完成一系列初始化工作。我记得 Linux 下 pthread_create 的耗时通常在几十微秒级别,虽然单看不吓人,但如果是瞬时涌入大量请求,每个请求都要创建线程,那光是线程创建就能把服务拖垮。
第二个硬伤是上下文切换开销大。线程切换涉及用户态到内核态的切换、寄存器现场的保存与恢复、内核栈的切换、CPU 缓存(cache)的失效等。一次线程上下文切换需要几百纳秒到几微秒不等,而现代 CPU 一个时钟周期才不到 1 纳秒——也就意味着一次线程切换浪费掉了成千上万个 CPU 周期。高并发场景下大量时间都耗在了切换而不是干活上。
第三个硬伤是内存占用高。内核线程默认栈空间通常是 1MB 到 8MB,即便你用线程池控制线程数量,每个线程占用的虚拟内存也不会少。一万个线程就是几个 GB 的虚拟内存开销,再加上调度负担,整个系统会变得非常沉重。
1.2 Go 的解法:用户态调度,让并发原语变轻
Go 的解决办法是——不在操作系统线程这个层面做并发,而是自己实现一个用户态调度器,把“小而轻的 goroutine”调度到“数量可控的操作系统线程”上执行。
你这个可以类比成:内核线程是公司里的工位,goroutine 是员工手里的任务。员工(M,操作系统线程)数量是有限的,但任务(G,goroutine)可以成千上万。调度器(P,处理器)负责决定哪个员工手头该干哪个任务。任务切换远比重换工位要快得多,因为整个过程都发生在用户态,不需要打扰操作系统。
goroutine 初识栈空间只有 2KB(Go 1.2 之前是 4KB),按需增长,极大降低了内存占用。一次 goroutine 切换的开销虽然也有几百纳秒,但它和操作系统线程切换最大的区别在于:不需要陷入内核态、不涉及 CPU 缓存的全量失效,整个调度过程是协作式的,由 Go 运行时自己掌控。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GPM 三步走:三大角色各管什么、为什么必须分成三层
聊 GPM 模型,第一步是把这三个字母代表什么彻底搞明白。这里我先给一个整体的角色定义,再逐个拆开讲细节。
2.1 G(Goroutine):被调度的最小单元
G 是 goroutine 的抽象,它是 Go 运行时调度的基本执行单元。每个 G 在运行时里对应一个 runtime.g 结构体,这个结构体里记录了什么?
- 栈信息:包括栈的低位地址、高位地址、当前栈指针位置等。G 的栈是动态增长的,初始只有 2KB 左右,用完了通过
morestack机制扩容。 - 上下文信息(gobuf):保存了当前执行状态的寄存器快照,保存了 CPU 的
rsp、rip等关键寄存器的值。G 被切换出去时把这些值存下来,再被调度回来时恢复现场,就能从上次执行的位置继续跑。 - 状态:G 有多个生命周期状态,比如
_Gidle、_Grunnable、_Grunning、_Gsyscall、_Gwaiting、_Gdead等,后面展开讲。 - 关联的 M:G 当前被哪个 M 执行,绑定在哪个 P 上。
每个 G 结构体几十字节,再加上初始栈空间 2KB,几万个 goroutine 同时存在也就占用几十 MB 内存,这在线程方案下是不可想象的。
2.2 P(Processor):逻辑处理器,真正决定并行度的角色
P 在中文技术社区里常被翻译成“处理器”或“上下文”,它代表的是一个“执行 goroutine 所需的环境和资源集合”。这是 GPM 模型里最关键、也最容易误解的一层。
P 的数量由环境变量 GOMAXPROCS 控制,默认等于 CPU 逻辑核心数。真正同时执行 goroutine 的并行度不会超过 P 的数量。哪怕你的机器有 64 核,如果 GOMAXPROCS=1,那么同时只有一个 goroutine 在真正跑。
每个 P 维护着一个本地可运行队列(local run queue),里面存放着等待被调度的 G。为什么要在每个 P 上维护一个本地队列而不是搞一个全局队列?这是调度器设计的核心优化之一——减少锁竞争。如果所有 P 都从同一个全局队列里取 G,那么每个 P 取任务时都要抢同一把锁,并发高了锁竞争会成为瓶颈。本地队列让大多数调度操作都能在无锁或极低竞争的情况下完成。
runtime.p 结构体里还有一个 runnext 字段,它指向一个特殊的“下一个待运行 G”。这个字段用于提升调度的实时性:当某个 G 被唤醒或者新创建一个 G 时,如果当前 P 发现自己的本地队列满了,runnext 就会派上用场,我们会在后面的调度循环里细讲。
2.3 M(Machine):真正干活的操作系统线程
M 是操作系统线程的抽象,对应 runtime.m 结构体。M 需要绑定一个 P 才能执行 G,也就是说,一个 M 手里必须有一个“任务来源”(P 的本地队列或全局队列),才能不断取出 G 来执行。
M 和 P 的数量关系值得注意:M 的数量不一定等于 P 的数量。调度器会保证“有活干”的 M 数量不超过 P 的数量,但因为阻塞、系统调用等原因,某些 M 可能处于“脱离了 P”的等待状态,这时调度器会创建新的 M 来补充,导致 M 的总数大于 P 的总数。
每个 M 内部维护着一个自旋状态(spinning)的标记。所谓自旋,就是 M 找不到 G 可以执行时的状态,它不会立刻休眠,而是会空转一小段时间去“偷”活干——从其他 P 的本地队列里偷 G,或者从全局队列里取 G。自旋的 M 是为了降低调度延迟:如果 M 直接休眠,等新任务到来时再唤醒,唤醒过程会引入额外延迟,高并发场景下这种延迟不可接受。
2.4 为什么不能直接让 G 跑在线程上,非要加一层 P?
很多初学者会问:既然 M 是操作系统线程,G 是任务,那直接把 G 调度到 M 上不就行了?为什么中间非要加一个 P?
这个问题要结合 Go 的并行模型来回答。在 Go 1.0 及之前的早期实现里,调度器确实没有 P 这一层,G 直接调度到 M 上。但那个时代 GOMAXPROCS 概念都还不成熟,程序默认只用一个线程执行所有 goroutine,根本发挥不了多核优势。
引入 P 之后,调度器达成了一个关键目标:把“并行度控制”和“线程管理”解耦。P 的数量决定了并行度,这是一个用户可配置、语义明确的数字;而 M 的数量是动态变化的,由运行时根据阻塞情况自动调整。如果直接让 G 绑定 M,那么并行度就和 M 的数量耦合在一起,调度器每次创建/销毁线程都会影响并行度,很难控制。
另一个重要原因是系统调用处理。当某个 G 发起阻塞式系统调用时,它所在的 M 会被“卡住”。如果没有 P 这一层,调度器将陷入尴尬:M 卡住了,但系统里还有一堆等待运行的 G,怎么办?只能再创建一批新线程来跑这些 G,可等系统调用返回后,原来的 M 恢复运行,又会出现多于并行度的线程在抢 CPU,上下文切换飙升。有了 P 就不一样了——M 进入系统调用前,会先把 P 释放掉(或者说把 P 交给别的 M),P 仍然保持“有效”状态继续调度其他 G,等系统调用返回后这个 M 再去重新寻找一个空闲的 P。整个机制我们会在第 4 节详细展开。
3. 调度循环:GPM 是怎么协同运转的
角色定义清楚了,接下来是整个模型的核心:调度循环(schedule loop)。这一节我会按“一个 G 从创建到执行到退出”的生命周期来走一遍完整流程,期间涉及的所有状态切换和队列流转都拆开讲。
3.1 G 的生命周期状态机
G 在整个生命周期里会经历多个状态,我先用一张表把核心状态罗列出来,然后重点讲几个关键的转换路径。
| 状态 | 含义 | 进入方式 | 离开方式 |
|---|---|---|---|
_Gidle |
空闲态,刚从池中取出或新建 | 从 P 的空闲 G 列表获取 | 初始化后进入 _Grunnable |
_Grunnable |
可运行态,等待被 M 执行 | go 语句创建、G 被唤醒、当前 G 让出 | 被调度选中,进入 _Grunning |
_Grunning |
运行态,正在某个 M 上执行 | 被调度器选中 | 退出、阻塞、让出、被抢占 |
_Gsyscall |
系统调用态,当前 G 正在执行阻塞式系统调用 | 发起阻塞式系统调用 | 系统调用返回,重新进入 _Grunnable |
_Gwaiting |
等待态,因 channel 操作、锁等待、网络 IO 阻塞等挂起 | channel 收发阻塞、mutex 阻塞、网络收发等 | 对应事件触发后被唤醒,进入 _Grunnable |
_Gdead |
死亡态,G 执行完毕或初始化失败 | 函数返回、panic 未恢复 | 被放回空闲 G 列表复用或销毁 |
理解状态机是理解调度器的钥匙。你写代码时一个看起来“简单”的 ch <- v 操作,背后可能就是 G 从 _Grunning 到 _Gwaiting 再到 _Grunnable 的两次状态转换,以及一次发生在运行时深处的上下文切换。
3.2 用 go 关键字创建 G 时,运行时做了什么
当我们写下一行 go func() { ... }(),运行时并不是简单地把这个函数丢到某个队列里就完事了。它走的路径是这样的:
- 从当前 P 的空闲 G 列表中尝试获取一个空闲的 G 结构体(
_Gidle状态的),如果没有空闲的,就调用malg新分配一个,初始栈 2KB。 - 把函数参数、调用方信息、返回地址等写入 G 的栈。
- 初始化 G 的 gobuf,设置 PC(程序计数器)指向函数的入口地址(实际上是指向
goexit之前的某个启动函数,这个细节下面讲)。 - 把 G 的状态从
_Gidle切换为_Grunnable。 - 将 G 放入当前 P 的本地运行队列。如果本地队列满了,就放入全局队列。
- 如果当前有空闲的 P 且没有自旋的 M,就会唤醒或创建一个 M 来执行这个 G。
这里有一个容易被忽略的细节:每个 G 真正执行完毕后都会跑到 goexit 函数——即使你的业务函数正常 return 了,运行时也会在这个 return 之后插入一次对 goexit 的调用。这个 goexit 负责完成 G 的收尾:把状态改为 _Gdead、归还栈、把 G 结构体放回空闲列表,然后重新进入调度循环。这也是为什么 G 的“退出”并不像线程退出那样从内核态走一轮,它只是回到用户态调度器手里被回收复用。
3.3 主调度循环:findRunnable 函数
P 绑定到 M 之后,会进入一个循环体,核心的执行逻辑在 runtime.schedule() 函数里,而 schedule() 又调用 findRunnable() 来获取下一个要执行的 G。整个调度循环的框架是:
- 第一步,从绑定 P 的本地运行队列中取一个 G。
- 第二步,本地队列为空时,从全局队列中取一批 G(具体是一次取一批,而不是只取一个,目的是一次性补充本地队列,减少全局锁的竞争频率)。
- 第三步,全局队列也为空时,执行工作窃取(work stealing),从其他 P 的本地队列中偷取一半 G。
- 第四步,如果到处都找不到 G,则让 M 进入自旋或休眠状态,等待新的任务到来或被唤醒。
findRunnable 的名字起得非常直白——它就是一个“到处找活干”的函数。查找优先级大致是:本地队列 → 全局队列 → 网络轮询器(netpoller) → 工作窃取。为什么网络轮询器排在全局队列之后?因为有网络 IO 唤醒的 G 通常已经准备好了才被放入可运行队列,优先级可以靠后一点;而全局队列里的 G 已经处于 _Grunnable 状态,是“立即可执行”的任务。
3.4 本地队列和全局队列:两级调度如何协同
P 的本地队列是一个环形缓冲区,最多能容纳 256 个 G。全局运行队列是一个链表,由一把全局锁保护。两级队列的设计是为了兼顾局部性和公平性:
- 局部性优先:新创建的 G、被唤醒的 G 优先放入当前 P 的本地队列,执行时缓存命中率更高,无锁访问更快。
- 公平性兜底:当 P 的本地队列满了(超过 256 个),新来的 G 会被放入全局队列。同时,调度器还会定期检查全局队列中的 G,将一部分搬回本地队列执行,避免全局队列里的 G“饿死”。
你可能会问:为什么不把本地队列容量设得更大?这是因为本地队列本身是每个 P 私有的,容量设计要权衡内存开销、负载均衡频率、公平性等多个因素。256 这个数字在绝大多数场景下足够承载突发任务,同时又能让工作窃取机制在“队列较满”时有机会分摊负载。
3.5 工作窃取(Work Stealing):让每个 P 都忙起来
如果某个 P 的本地队列空了,它不会坐等其他任务从天上掉下来,而是会去“抢”别的 P 的任务。这在工作窃取调度器里叫 work stealing,是 Go 调度器在多核负载均衡上的核心机制。
具体流程是:
- 当前 P 随机选择一个目标 P(有随机化防止所有 P 都抢同一个目标)。
- 从目标 P 的本地队列尾部窃取一半的 G(至少一个)。
- 如果目标 P 队列也为空,继续随机选择下一个,最多尝试一定次数(
workStealingOrder里有一系列随机候选)。 - 如果偷了一圈都没偷到,就从全局队列里取,取不到再检查网络轮询器。
- 全部没有收获,M 进入自旋状态。自旋的 M 会一直循环尝试,直到获取到任务或空闲 P 数量为零。
为什么偷一半?这是一个很有意思的设计。只偷一个的话,如果目标 P 的队列里有大量任务,当前 P 很快又会陷入无事可做的状态,偷的效率太低;全部抢走又会对目标 P 不公平,还可能导致两个 P 之间频繁竞争。偷一半是一个经验上最优的折中——读研那会儿看论文,IBM 的 work stealing 设计论文里也是这么干的,Go 运行时把这个策略保持了下来。
3.6 主动让出与抢占式调度:协作式与抢占式的统一
Go 调度器历史上经历过一次重大变化:从 Go 1.13 之前的“基本协作式”升级为 Go 1.14 起的“基于信号的抢占式调度”(asynchronous preemption)。
老的调度器是协作式的,意思是 G 必须自己主动让出 CPU——比如执行到函数调用、GC 标记、阻塞操作等时机——才会有机会被切换。这带来一个问题:如果某个 goroutine 里写了一个死循环且循环体内没有任何会触发调度的事件(比如 for {}),它就能一直霸占着 M 不放,其他 goroutine(包括 GC 的 goroutine)全部饿死,这就是著名的“for 死循环卡死整个 Go 程序”的问题。
Go 1.14 引入了基于信号的抢占机制:调度器会定期向正在执行 G 的线程发送信号,在信号处理函数里检查当前 G 是否已经运行了超过 forcePreemptNS(默认 10ms),如果超时就设置抢占标记,并改变当前指令流的执行路径,让 G 在安全点被切走。这大幅缓解了死循环导致的调度问题。
但需要注意,抢占并不总能在任意指令处发生。在 syscall 等不允许抢占的环节里,G 依然不会被强制切走,这也正是需要 P 和 M 解耦、需要 hand off 机制的原因之一。
4. 遇到阻塞和系统调用时,调度器如何“紧急刹车”
这一节讲的是调度器面对最棘手场景——阻塞——时的应对策略。goroutine 里的“阻塞”分红黑两道:用户态的 channel 阻塞、mutex 阻塞,以及系统调用阻塞。不同种类的阻塞,调度器的处理方式完全不同。
4.1 用户态阻塞:channel 收发与锁等待
当你写的代码执行到 ch <- v 而没有任何 goroutine 在接收时,或者执行到 <-ch 而 channel 为空时,当前 G 会进入 _Gwaiting 状态。此时调度器的动作是:
- 挂起当前的 G,把它从 P 的本地队列中拿掉,放入 channel 内部的等待队列。
- 让出 P:当前 M 不再执行任何 G,回到调度循环重新取一个 G 来执行。
- 当另一端有 goroutine 往 channel 里发送或接收数据时,等待的 G 会被唤醒。注意:唤醒的优先级高于放入本地队列,它会被放到 P 的
runnext字段里,意味着紧接着就会被执行。
用户态阻塞的一个关键特点是:它只阻塞 G,不阻塞 M。M 在 G 睡着后立刻可以转向执行其他 G,这保证了即便有成千上万个 goroutine 同时阻塞在 channel 上,也只有极少数 M 会被占用。
mutex 的阻塞机制类似,但有一点区别:当一个 goroutine 尝试获取一个已被其他 goroutine 持有的 sync.Mutex 时,会先自旋一小会儿(默认自旋次数由运行时控制,通常是在多核且 GOMAXPROCS > 1 时才会自旋),自旋一两个时间片之后才真正挂起。自旋的意义在于,锁通常被持有者很快释放,如果每次都直接挂起、再被唤醒,往返调度的开销反而更大。
4.2 网络 IO:非阻塞 IO 加网络轮询器
所有基于 net 包的网络 IO 操作(如 conn.Read)本质上是非阻塞的。看 Go 源码你会发现,net.Conn 的底层 fd 被设置成了非阻塞模式。那么问题来了:非阻塞模式下 Read 如果没有数据会直接返回 EAGAIN,Go 是怎么让你写的代码体会不到“非阻塞”粒度、依然可以像写同步代码那样调 Read 的?
答案在网络轮询器(netpoller)上。当 Read 遇到 EAGAIN 时,运行时会把当前 G 封装成一个网络轮询任务注册到 netpoller 里(epoll/kqueue/io_uring 的封装),然后 G 挂起进入 _Gwaiting。netpoller 在后台通过操作系统提供的多路复用机制等待 fd 变为可读/可写。一旦事件就绪,对应的 G 会被唤醒并放入运行队列。
这套机制的厉害之处是:网络 IO 阻塞的是一个 goroutine,而不是一个线程。处理一万个并发网络连接也只需要几个系统线程,这正是 Go 在服务端领域能叫板传统线程模型的根本原因。
4.3 同步系统调用:抢占 P、让 M 继续等
如果 goroutine 里执行的是真正的阻塞式系统调用,比如 syscall.Open、time.Sleep 对应的底层的 nanosleep 等怎么办?这类调用无法通过非阻塞模式规避,M 真的会被卡住。
此时调度器的策略非常巧妙:
- G 进入
_Gsyscall状态,M 即将被系统调用阻塞。 - 进入系统调用前,运行时执行
exitsyscall准备逻辑:销毁当前 M 和 P 的绑定关系。P 会进入_Pdefer或直接尝试寻找一个新的 M 来接管。 - 调度器会优先把这个 P 转交给一个正在自旋等待的 M(如果有的话),让新 M 继续执行 P 本地队列里的 G。
- 如果没有自旋 M,且 P 本地队列里还有任务,调度器会创建一个新的 M 来接管 P。
- 当系统调用返回,原来的 M 去寻找一个空闲的 P:如果找到了,直接绑定并继续执行当前 G(相当于 G 从
_Gsyscall回_Grunning);如果没找到,G 会被放入全局队列,M 进入休眠或空闲状态等待下一次被唤醒。
一句话总结这套机制:M 可以被系统调用卡住,但 P 不能被浪费。P 代表的是并行资源,在 M 被阻塞期间必须保持工作能力。
4.4 让出 CPU:Gosched 调动全局队列
还有一个不那么“阻塞”但用得非常频繁的场景:主动让出 CPU。runtime.Gosched() 会让当前 G 主动放弃 M,重新进入 _Grunnable 状态并放到全局队列尾部。
我记得刚开始学 Go 的时候,有人告诉我用 runtime.Gosched() 来解决 CPU 密集型任务的竞争问题。当时没太理解为什么放到全局队列而不是本地队列——后来才明白,这是为了公平性。如果放在本地队列,这个刚让出 CPU 的 G 大概率会被同一个 P 立刻再次取到,相当于让出等于没让。而放进全局队列并被本地队列优先取用的策略正好形成了“公平调度”的基线:如果一个 P 的本地队列长期不空,全局队列里的 G 有机会被分配到其他 P 上执行。Gosched 被放在全局队列里,也是为了让其他 P 有更大的概率“偷”到它。
5. 调度器的实际应用:GOMAXPROCS 调优与几个隐蔽的坑
搞懂了 GPM 模型的原理,最后落到实战:怎么利用这些原理来发现问题、调优性能。
5.1 GOMAXPROCS 不是越大越好:一次压测抖动引发的思考
我之前在压测一个 HTTP 网关服务时发现一个奇怪现象:把 GOMAXPROCS 从 8 调大到 16(服务器是 16 核),QPS 没按预期上涨,反而出现了明显的抖动,P99 延迟从 50ms 跳到了 200ms 以上。为什么?
我当时第一反应是锁竞争。用 pprof 抓火焰图,发现确实有大片的时间消耗在 sync.(*Mutex).Lock 上。但再细想,锁竞争的原因是业务代码里的一个全局 map 并发读多写少的设计,跟 GOMAXPROCS 有什么关系?
其实是这样的:当 GOMAXPROCS 变大时,同时执行的 P 变多了,业务代码里对共享数据的并发访问频率随之上升,锁竞争自然更激烈。再加上这个服务里有大量短连接 HTTP 请求,每个请求要处理大量网络 IO,GOMAXPROCS 增大后调度器在更多 P 之间做网络轮询和负载均衡,开销也在涨。
后来我把那个全局 map 换成分片锁(shard lock),再调大 GOMAXPROCS,P99 稳定下来了。这个案例给我的教训是:GOMAXPROCS 决定的是并行度上限,但业务代码的并发安全设计才是决定能否从并行度中获利的前提。如果你的程序有重度共享锁,并行度越高,锁竞争越大,反而拖累性能。
5.2 M 的数量失控与 debug.SetMaxThreads
默认情况下 Go 会对 M 的数量设置一个上限,默认值 10000。如果程序里的同步系统调用太多,导致频繁创建新的 M,M 数量会快速上涨。虽然绝大多数场景下 M 数量到不了 10000,但一旦触发会直接导致程序崩溃(fatal error: thread exhaustion)。
我记得有个同事的程序是在内网环境里调用一个很慢的存储服务,而且用了同步的 net.Conn.Write 配合一个超大缓冲区去发送数据,结果并发一上来,大量 M 被阻塞在内核态的 socket 写缓冲上,P 不断被释放、新 M 不断被创建,最终 M 数量逼近上限,整个服务崩了。
排查这类问题的经验是两层:第一层看 runtime/debug 包提供的方法,如果你明确知道某个场景会频繁创建线程且 M 数量较大,可以用 debug.SetMaxThreads 设置一个更小的上限,让问题提前暴露而不是等到 10000 才崩;但更根本的做法是检查代码里是否用了不合理的同步系统调用模式,能改成 netpoller 支持的异步模式就尽量改。
5.3 用 go tool trace 观察调度细节
很多人在排查调度问题时会用 go tool pprof,但它只能给你一个“某一时刻程序在干什么”的采样快照,看不到调度的时序。真正能看到 GPM 调度行为全貌的是 go tool trace。
操作方式不复杂:
- 在代码里加
trace.Start(f)和trace.Stop(),把 trace 数据写到文件。 - 运行
go tool trace trace.out,会打开一个 web 界面。 - 在 “Goroutine analysis” 视图中,可以查看每个 goroutine 从创建到结束的完整状态时间线,哪段时间是
_Grunnable等待执行、哪段时间在_Gsyscall被阻塞等。
我调一个“goroutine 数量暴涨但 CPU 利用率低”的问题时,就靠 trace 发现有一批 goroutine 长时间停在 _Gwaiting 状态,等一个永远不会被发布到 channel 的错误通知。说白了是业务逻辑上的死锁,但如果你不借助 trace 把 goroutine 状态扒出来,很难凭直觉定位。
5.4 注意运行时中的“系统线程被占用”场景
最后再聊一个比较容易踩的点:CGO 调用。Go 里调用 C 函数时,会创建一个新的 M 来执行 C 代码,而且这个 M 在 C 代码执行期间不能被调度器调度其他 G。如果 C 函数耗时很长,且并发调用量很大,M 数量会飙升。这就回到了我们在 4.3 节讲的同步系统调用的处理逻辑——CGO 调用会让出 P,让其他 M 接管,但 P 本身也可能会反复交接,整个调度器的负担会明显加重。性能敏感场景下慎用 CGO,这真的不是一句空话。
6. 调度器设计的四个底层原则
接触 GPM 模型越久,越发现它在设计上透着几条非常本质的原则。把这几条原则讲透,它对你在其他并发框架(比如 Java 的虚拟线程、Rust 的 Tokio)上进行迁移理解也特别有帮助。
原则一:不要用内核对并发负责,而是让用户态自己掌握并发原语。 操作系统线程是给通用多任务设计的,但 Go 的并发模型是专门为高并发 IO、低延迟调度的场景设计的,与其在系统调用层做文章,不如在运行时里造自己的“轻量线程”。
原则二:用两级队列解耦局部性与公平性。 P 的本地队列无锁快,但容易被某个 P 独占;全局队列公平,但竞争高。两者结合,再在公平性受到威胁时用工作窃取和全局队列补充,就同时满足了高频调度场景的性能和长期运行的公平性。
原则三:阻塞的是任务,不是资源。 channel、网络 IO、锁等待,这些阻塞只影响 G 本身,P 和 M 都还能继续执行其他任务。只有真正的同步系统调用才会阻塞 M——即便如此,P 也会被转交给其他 M,资源照样不浪费。
原则四:一切调度决策都要让“空转的 M”尽快参与平衡。 工作窃取、自旋睡眠、hand off,各种机制本质上都是为了让系统在“有任务,但某个 M 闲着”和“所有 M 都忙,还有任务排队”之间自动求平衡,避免资源闲置。
我记得看 Go 调度器源码注释时,作者也明确说过,调度器设计的核心目标是“让 CPU 保持忙碌,同时保证公平性”。这句话可以作为整个调度器行为的总纲。你在排障、调优、设计系统时,拿它去对照手头遇到的问题,很多困惑都能解开。
这次深挖 GPM 模型之后,我最大的体会是:Go 语言把“并发编程”的门槛降得很低,但你要把它用好,该啃的底层知识一样不能少。调度器这些机制平时你感知不到,可一旦线上出了问题,对模型的理解就是你和“玄学排障”之间唯一的区别。
