Go调度循环深度解析:GMP模型、Work Stealing与Sysmon抢占
第一次完整读 runtime/proc.go 里的调度循环时,我脑子里冒出的画面是一个永远停不下来的工头:几十个工人站在流水线边,物料一到,他冲上去把活派给最合适的人;物料没到,他也不会干站着,而是来回巡视、催料、顺手收拾拧巴的工位。Go runtime 里的调度循环,干的就是这件事——不停歇地找出下一个能跑的 goroutine,然后塞给某个线程去执行。
这篇文章想把这个"永不停歇"拆开讲清楚。我会从 G/P/M 三者的分工开始,带你走一遍 schedule() 这个中央循环的完整路线,再重点说两个容易被忽略但极其关键的部分:任务偷取(work stealing)和 sysmon 系统监控线程。适合想深入理解 Go 调度原理、读 scheduler 源码,或者被线上 CPU 异常折腾过的人。看完之后,你至少能回答三个问题:一个 P 闲下来到底在干嘛?一个 G 卡住不动为什么也能被抢走 CPU?GOMAXPROCS 调成多少才合理?
1. 调度循环的舞台:G、P、M 谁负责找人,谁负责干活
1.1 P 是整个调度体系的"车间主任"
P(Processor)是 Go 调度器自己发明的一层抽象。它不是 CPU,但决定了一个 M 能不能同时跑 Go 代码。每个 P 都像一个"工位",工位上挂着一块任务看板:本地可运行队列 runq、指向"下一位选手"的 runnext 指针,以及当前绑定的 M。一个 M 想干活,必须先拿到一个 P。
为什么要多此一举加这层 P?你把 Go 的并发模型和操作系统的线程调度对比一下就能明白。如果所有 M 都直接从全局队列拿任务,那队列上的锁会变成整个 runtime 的瓶颈。P 把"任务分发"从"线程"中拆出来之后,M 只负责执行,P 负责维护本地任务队列。M 和 P 可以随意解绑、重新绑定,调度器由此获得了极大的灵活性。比如某个 M 陷入系统调用回不来,P 可以立刻解绑旧 M,换一个新 M 上岗,任务一点没耽误。
1.2 G 是任务卡片,状态变化就是整个循环的轨迹
每个 goroutine 对应一个 runtime.g 结构,你可以把它理解成一张"任务卡片"。这张卡片在调度循环中的流转路径,就是我接下来所有章节的线索。关键状态有这么几个:
| G 状态 | 含义 | 在调度循环中的典型位置 |
|---|---|---|
| _Gidle | 刚创建,还没入队 | newproc 分配之后 |
| _Grunnable | 已入队,等待运行 | 本地队列、全局队列 |
| _Grunning | 正在 M 上执行 | execute() 之后 |
| _Gwaiting | 等待 IO、锁、channel | 被 park 到等待队列 |
| _Gsyscall | 陷入系统调用 | syscall 进入/退出时有特殊处理 |
| _Gpreempted | 被抢占,等待重新入队 | 抢占流程完成后 |
调度循环的每一轮,本质上就是把若干张卡片从 _Grunnable 推到 _Grunning,再把阻塞的卡片挪到 _Gwaiting,事件到了再挪回 _Grunnable。这里面最需要小心的是 _Gsyscall 和 _Gpreempted,因为它们都涉及到 P 和 M 的重新分配。你如果看调度器源码,会发现 syscall 入口和抢占入口的判断逻辑是最密集的,因为这两个地方最容易出现"P 闲着但 M 不在"的情况。
1.3 M 是真正干活的工人,但工人数量不等于并行度
M 是操作系统线程,职责非常单纯:执行 Go 代码、处理线程上的信号。M 的存活数量和 P 没有严格对应,Go 完全可能创建比 P 多很多的 M,但真正能同时执行 Go 代码的 M,最多只有 P 的数量。原因很简单:要跑 Go 代码,M 必须先持有 P;拿不到 P,M 就只能自旋找活或者休眠。
新手最容易在这里把概念搅浑:以为 GOMAXPROCS 控制"线程数"或者"goroutine 数"。它控制的其实是 P 的数量。你写 runtime.NumGoroutine() 看到的是 G 的数量,和"同一个时间点有几个线程在并行"完全是两码事。并行度永远看 P,而不是看 G。这个认知不纠正,后面读调度代码会一路磕绊。
2. 一走完 schedule() 就等于走完一轮点名:取G的优先级和判断顺序
2.1 schedule() 是每个P启动后一直重复的中央循环
当 M 绑好 P,正式进入工作状态后,它会调 runtime.schedule()。这个函数是调度循环的心脏,代码在 $GOROOT/src/runtime/proc.go 里。一轮调度的大体顺序如下,我做了简化,实际源码还有 GC 标记、tracing 等旁路:
execute() 是真正切换 goroutine 执行现场的动作。它先把 G 的状态标记为 _Grunning,绑定到当前 M,然后通过 gogo 跳进 goroutine 的函数栈开始跑。这个过程在汇编层面就是一次上下文切换,开销被控制得很低。
建议你打开源码看一次,不用全懂,只看函数名和注释,就能感受到调度器在每一步都在问同一个问题:"下一个活是谁?"读多了之后你会发现,调度器本质上就是一个巨大的状态机推进器。
2.2 取G的优先级:runnext先跑,本地队列其次,全局队列殿后
一个 P 的本地队列是长度 256 的环形数组 runq,除此之外还有个特殊的 runnext 指针。runnext 的语义是"新鲜出锅的任务优先伺候"。当前 G 主动让出之前,会优先把下一个要跑的 G 放进 runnext,调度器再进来时直接拿它,不用走队列。
这个设计对 go func() 这种顺序创建任务非常友好,新创建的 G 能很快被调度到,保持了调度的局部性。但要注意,Go 调度绝不保证 FIFO。如果你在业务里依赖 goroutine 的执行顺序,那是要吃大亏的。正确做法是明确用 channel 或锁来控制依赖关系。
全局队列是 sched.runq,它的优先级更低。P 并不是每轮都去全局队列取任务,而是在本地队列连续空转若干次(schedtick 计数)后才去取一次,而且一次拿 min(len(globq), GOMAXPROCS) 个。这样做的目的只有一个:把多个 P 对全局队列锁的争抢降到最低。锁竞争这东西,在并发量起来之后是致命的。
另外还有一条很关键的路:netpoll。网络 fd 就绪的 G 会通过 netpoll() 被捞回来,插进调度队列。对 IO 密集型服务来说,这条路径几乎每轮都在起作用。所以一轮调度完整优先级大体是:
- runnext
- 本地 runq
- 全局 runq(带节流)
- netpoll 就绪任务
- work stealing(偷别的 P)
- 继续轮询或休眠等待
这一串顺序,就是调度循环每轮"点名"的完整名单。
2.3 execute() 之后:G 什么时候回到调度器
G 一旦被执行,会一直跑到下面几种情况之一发生:
- 主动让出:调用
runtime.Gosched() - 被抢占:异步抢占信号触发栈安全检查
- 阻塞:channel 等待、mutex 等待、syscall
- 函数返回:goroutine 结束,进入
goexit()清理
无论走哪条路,最终都会回到调度器。主动让出的 G 会被塞回可运行队列;阻塞的 G 会被 park 到等待队列;syscall 的 G 在退出系统调用后还要做 P 的重新绑定判断。回到调度器之后,schedule() 继续下一轮,M 又开始找下一个 G。这就是"循环"的字面含义:一个 P 只要没被销毁,它绑定的 M 就一直在执行"取 G -> 执行 -> 再取 G"的循环,根本不带停的。
3. 本地队列空转时,P 怎么靠 work stealing 把任务匀过来
3.1 为什么会出现"有的 P 忙死,有的 P 空死"
goroutine 创建时,默认会放到当前 P 的本地队列。如果创建得非常密集,任务往往会集中在某一个或几个 P 上,其他 P 的队列很快就空了。假设空闲的 P 只盯着自己的 runq 看,那它马上就会变成"坐在工位上等料"的状态,而旁边某条流水线已经堆成一团。
调度循环解决这个问题的思路非常朴素:你等不到料,就去别人那搬料。 这个动作由 findRunnable() 里的 stealWork() 完成。它会随机挑一个目标 P,查看对方 runq 的长度,如果发现有任务,一次搬走一半放进自己的 runq。
为什么偷一半,而不是全搬?如果全搬走,对面马上变空,过一会儿可能又得搬回来,任务在 P 之间来回倒腾,缓存局部性被彻底破坏。偷一半能让两边的队列长度迅速接近,后续迁移次数大幅减少。这个"负载均衡只做一半"的设计,在不同调度器里都能看到,思路是通用的。
3.2 stealWork 的随机化与多轮重试
偷取顺序是随机的,原因也很实际:如果所有空闲 P 都从固定顺序扫描,第一个 P 会把第二个 P 要偷的东西清空,形成"排队抢食"。随机探测可以让冲突概率降到最低。stealWork 也不是偷一次就放弃,它会轮询好几轮,每轮随机选其他 P,直到成功或者本轮超时。
在 steal 失败且本地队列仍然为空时,findRunnable() 还会做一次"二次确认",保证这段时间里没有新任务从 netpoll 或者全局队列进来,然后才让 P 进入空闲流程。这层防范不是多余的——并发场景下,任务可能在任何一瞬间出现,调度器必须避免过早放弃。
3.3 P 空闲后到底睡不睡:M 自旋与 stopm 的取舍
如果多轮偷取仍然颗粒无收,P 会被标记为 _Pidle,M 则进入自旋状态。自旋的 M 不会马上休眠,因为线程从睡眠到唤醒的代价不小,如果任务马上就来了,频繁开关线程反而劣化性能。Go 调度器会严格限制自旋 M 的数量,只让极少数 M 空转等待,其余 M 调用 stopm() 真正休眠。
休眠的 M 什么时候被叫醒?全局任务入队、某个 P 创建了新任务需要帮助、网络事件触发等场景下,runtime 都会调 startm() 唤醒一个空闲 M。所以 M 的睡与醒本身,也是调度循环派发任务的一部分。
这个机制在线上的体现非常明显。我曾经遇到过一台容器里的 Go 服务,压测 RPS 并不高,CPU 使用率却直接打满。查下来是容器被 cgroup 限制了 2 核,但 Go 默认按宿主机核数创建 P,空闲 P 驱动着自旋 M 一直空转。大量 CPU 被调度器自己的"找人干活"消耗了,真正的业务反而没跑多少。这就是调度循环对业务最直接的一次教训。
4. sysmon 这个监工为什么能永不停歇:抢占、P 卡死与唤醒逻辑
4.1 sysmon 是和 P 无关的独立线程
前面讲的调度循环,再快也有个前提:需要有人来推进。但普通的 G 都依赖 P,而 P 可能被某个长期运行的 G 霸占,也可能被阻塞的系统调用拖住。这时候谁来做"纠偏"?答案是 sysmon。
sysmon 是 runtime 启动时创建的独立监视线程,它和前面所有 M 最大的区别就是:不依赖 P 也能跑。它本身就是纯 native 监控代码,不执行 goroutine,也不需要持有 P。这个特征决定了它可以在"主调度体系"快被卡住的时候,从外部伸进手来强制干预。
sysmon 的运行模式是一个大循环:醒来、检查各种情况、处理、再睡。睡眠间隔并不是固定的,刚开始大约是 20 微秒,之后根据负载逐步拉长,最多到 10 毫秒左右。这种"短间隔高频检查 + 长间隔低频确认"的动态方式,既能保证发现问题的及时性,又不会把 CPU 烧在日常巡检上。
4.2 sysmon 在盯四件事
sysmon 的职责可以用一张表说清楚:
| sysmon 检查项 | 行为 | 对应调度的哪个窟窿 |
|---|---|---|
| 单个 G 运行时间超过约 10ms | 向对应 M 发送抢占信号 | 防止一个 G 独占 P |
| P 在 _Psyscall 状态停留太久 | 把 P 从当前 M 手上剥离,重新分配 | 修掉 syscall 阻塞导致 P 闲置的问题 |
| netpoll 有就绪的 fd | 唤醒等待该 fd 的 goroutine | 让 IO 任务能被及时处理 |
| 距上次 GC 太久 | 触发新一轮 GC | 防止内存无限增长 |
其中前两条是最关键的。没有 sysmon 的强制干预,一个 G 如果写了个死循环且里面没有函数调用,理论上可以把整个 P 永久霸占,其他 goroutine 全部饿死。这种事故在 Go 1.14 之前是真实可能发生的。
4.3 抢占怎么实现:从协作式到基于 SIGURG 的异步抢占
说到这里的抢占机制,值得单独展开。Go 1.14 之前是协作式抢占:G 必须主动让出,或者进入函数调用链上的某个栈检查点,调度器才有机会介入。极端情况下,一个纯热点循环可以让整个 P 卡死,服务看起来像假死。
Go 1.14 引入了基于信号的异步抢占。sysmon 发现一个 G 运行了太久(默认阈值 forcePreemptNS,大约 10ms),会向它所在的 M 发送 SIGURG 信号。M 收到信号后,在 signal handler 里保存当前 goroutine 的执行现场,把它标记为 _Gpreempted,然后重新进入调度循环,让这个 G 回到工作队列末尾等待下一次调度。
这里有一个非常重要的细节:SIGURG 的执行并不是"信号一到立刻打断",它必须等到 goroutine 回到某个安全点。具体来说,如果 goroutine 正在执行汇编代码中禁用抢占的部分,或者线程处于系统调用内核态,那抢占仍然要等。所以在极端场景下,异步抢占也不是万能的,但覆盖日常 Go 业务代码已经完全足够。
4.4 netpoll 的返程票:调度循环怎么处理 IO 等待
再看一眼 netpoll。Go 的网络 IO 很少用阻塞式系统调用,而是把 fd 注册到 epoll / kqueue 上,G 自身进入 _Gwaiting 状态。数据到了之后,sysmon 或者在每次 findRunnable() 里的 netpoll 会把就绪的 G 捞回队列。这个过程被很多文章称为"异步 IO 不阻塞线程",本质就是调度循环把等待 IO 的 G 挪出工位,不让它占住 P,等事件就绪再放回来。
这也是为什么基于 Go 写的网络服务,即便连接数很高,线程数也能保持很低。你打开一个 Go 服务的 goroutine 指标,看到的几万甚至几十万 G,绝大多数都在 _Gwaiting 里等网络事件,真正占着 P 执行代码的只有 GOMAXPROCS 个。理解这一点后,再看 Go 高并发,视角会完全不一样。
5. 看懂调度循环之后,对 Go 开发者的三个实际影响
5.1 GOMAXPROCS 在容器环境下必须显式设置
第三章讲到的那个坑,在真实业务里的出现概率非常高。Go 默认的 P 数量来自 runtime.NumCPU(),它读取的是宿主机可见的 CPU 数,而不是容器 cgroup 配额。当容器限额小于宿主机核数时,Go 会创建比实际可用资源多很多的 P,空闲 P 驱动 M 自旋,把 CPU 限额打满且消耗在空转上。
解决方式通常有两种:一种是在容器编排里给 Go 进程显式设置环境变量 GOMAXPROCS=<配额>;另一种是用 automaxprocs 这类库在启动时自动读取 cgroup 设置。我的个人习惯是能显式设置就显式设置,运维的确定性要握在自己手里。
注意:不要为了省事在代码里写死
runtime.GOMAXPROCS(8),这个数字应该等于容器真实配额,而不是拍脑袋决定的。写死了数值,换一台 16 核的机器也只会用到 8 核,换个 4 核容器又直接超配,两头都不合适。
5.2 不要按"线程模型"的思路去调整调度参数
刚接触调度循环的人,很容易把问题简化成"GOMAXPROCS 越大越好"。实际上这个判断要分场景。纯计算型任务,并行度超过 CPU 核数只会增加线程切换和自旋成本,性能不升反降;IO 密集型任务,P 的数量更多确实意味着更快的响应能力,但 P 过多也会增加调度和唤醒的内耗。因此,不要盲目调大 GOMAXPROCS,先看清业务的瓶颈到底在 CPU 还是在等待上。
runtime.Gosched() 同样是双刃剑。它看似是"礼貌地让出 CPU",但频繁调用会让 G 不断回到队列尾部,增加调度循环的处理量,反而把整体响应拖差。真实业务里如果频繁想用 Gosched,大概率是设计出了问题,优先考虑把逻辑改成事件驱动或者分批执行,而不是手动干预调度器。
5.3 读调度源码的入口建议
如果你想跳进源码验证这篇文章里的说法,建议直接打开 $GOROOT/src/runtime/proc.go,按这个顺序读:
newproc(创建 G)-> schedule(主循环)-> findRunnable(找活核心)-> execute(执行切换)-> goexit(退出清理)-> sysmon + retake(抢占监控)。
我自己的阅读习惯是:先扫函数名和注释,再抓数据结构和队列定义,最后才抠细节实现。Go runtime 的代码注释质量很高,几乎每个关键函数都会解释"为什么这么做"。对调度循环来说,几个数据结构是必看的:p.runq、p.runnext、sched.runq、sched.gFree。把这几个结构在脑子里串成一条线,调度循环的运转逻辑就通了。
最后补充一个排查经验。线上服务如果 CPU 使用率高但吞吐不涨,我一般按这个顺序查:先看容器 CPU 配额和 GOMAXPROCS 是否匹配,再看 pprof 采样里有没有大量自旋锁或者 runtime.futex 等待,最后用 GODEBUG=schedtrace=1000,scheddetail=1 跑一下,直接观察 P 的分布和 steal 频率。调度循环的异常很少是 runtime 自己的 bug,绝大多数时候是我们给的资源参数不正确。把这套思维建立起来,比记住任何一条配置命令都更有用。