Go调度循环深度解析:GMP模型、Work Stealing与抢占机制
2026/9/16 2:07:01 网站建设 项目流程

写调度循环这个话题,先从一个我自己的真实感受说起。早几年我用 Go 写一个同时挂着几万个 websocket 长连接的服务,进程内 goroutine 数量常年飘在十万以上,但top里的 CPU 占用反而很低。当时的第一反应是“Go 是不是很懒”,后来啃了 runtime 源码才发现,恰恰是为了“又快又省地找人干活”,调度循环在背后做了大量功课。goroutine 本质上是用户态的执行单元,操作系统只认线程不认 goroutine,中间这层“翻译”工作,全部压在调度循环身上。这篇文章我尽量把这条循环链路上的关键节点全部拆开讲清楚,源码级别的地方会标注函数名,能帮助你在踩并发坑的时候更快定位问题。

1. 调度循环解决的根本矛盾:操作系统线程与 goroutine 之间的鸿沟

1.1 线程是昂贵的基础设施,goroutine 是廉价的“任务单”

先问一个很基础但特别容易被忽略的问题:为什么 Go 不直接一个 goroutine 对应一个线程?

因为线程很贵。创建一个线程要陷入内核态,分配内核栈、建立线程控制块,切走切回时要保存一整组寄存器、修改页表亲和性,线程一多还要面对上下文切换导致 CPU 缓存(L1/L2/TLB)大面积失效。业内做过很多基准测试,单线程切换成本能到微秒级别,而 CPU 跑一条指令才皮秒级。这个量级差距意味着,如果程序里有大量轻量级并发单元,用线程去硬扛,绝大多数时间会花在“切换现场”而不是“真正干活”上。

goroutine 的初始栈只有 2KB 左右(64 位平台),扩容按需增长,创建和销毁完全在用户态完成,不碰内核。它更像一张“任务单”:写上函数入口、参数和栈指针,然后排队等待被执行。真正执行任务单的是线程,但线程怎么分配、任务单怎么流转、某个线程空闲时如何找到下一张任务单,这就是调度循环的全部职责。

1.2 GMP 三角色:谁负责执行、谁负责调度、谁负责干活

Go runtime 的调度模型常被简称为 GMP,三个角色各管一摊:

角色名称典型数量核心职责
GGoroutine动态增减,取决于业务并发一段需要被执行的用户代码及其上下文(栈、寄存器状态)
PProcessor固定,等于 GOMAXPROCS持有本地运行队列、定时器堆、GC 相关字段,是调度发生的“工作台”
MMachine动态增减,最大受系统限制操作系统线程,负责真正执行 g0 上的调度逻辑和 G 上的用户代码

可以这样理解:P 是“岗位”,M 是“工人”,G 是“任务单”。一个工人必须先站在某个岗位上,才能从岗位的队列里取出任务单来执行。岗位数量是固定的,由GOMAXPROCS决定,一般情况下不要超过 CPU 核心数,否则就是在多个物理核上反复横跳。工人数量则是动态的,runtime 会根据任务量决定是休眠空闲工人还是招聘新工人。任务单数量更是完全不设上限,几十万 goroutine 在这个模型里只是常规操作。

1.3 从“线程池”到“调度循环”:设计思路的一次升维

传统的线程池模型,核心机制是“任务队列 + 若干固定线程”:任务来了入队,线程从队列取任务执行。这套模型的问题在于,任务状态无法细粒度表达——一个任务在网络 IO 上阻塞,整个线程就废了,哪怕线程池里有 100 个线程,遇到 100 个同时阻塞的任务,就完全没有计算力可用了。

调度循环换了个思路:把“执行单元”和“操作系统线程”彻底解耦。G 的状态可以非常细粒度地流转,比如运行中、可运行、等待 IO、等待锁、系统调用中、已退出。一旦 G 因为某件事阻塞,M 不会陪着它一起等,而是立刻回到调度循环,从队列里捞下一个可运行的 G 继续执行。换句话说,调度循环的使命不是“把一个 G 执行完”,而是“永远保证有空闲的 M 时,能立刻找到一个新的 G 去填上”。这就是后来所有机制——work stealing、网络轮询、抢占式调度——出现的根本出发点。

2. 调度主循环的骨架:schedule → execute → run → 回到 schedule

2.1 schedule() 的入口:在本地运行队列里碰运气

调度循环的“心脏”是runtime/proc.go里的schedule()函数。每个 M 在完成手头工作之后,都会跳回这里寻找下一个可以执行的 G。源码我用逻辑伪代码还原一下核心链路:

func schedule() { mp := getg().m pp := mp.p.ptr() // 某些特殊状态需要先处理:GC 等待、被 GC 标记的协程、系统调用返回等 if mp.gcwaiting != 0 { gcstopm() // 让 M 停住,等待 GC } if pp.status != _Prunning { throw("bad p status") } var gp *g var inheritTime bool // 1. 每 61 次调度左右检查一次全局队列,避免全局队列饿死 if pp.schedtick%61 == 0 { gp, inheritTime = globrunqget(pp, 1) } // 2. 优先从本地运行队列取 G if gp == nil { gp, inheritTime = runqget(pp) } // 3. 本地队列没有,进入 findrunnable 全面寻找 if gp == nil { gp, inheritTime = findrunnable() } // 找到 G 之后,就交给 execute 去运行 execute(gp, inheritTime) }

注意第 1 步那个%61的条件:如果每次调度都先盯全局队列,本地队列的延迟就会高;如果一直不看全局队列,大量被投递到全局队列的 G 可能饿死。用“每 61 次调度看一次”的折中策略,算是调度器里一个很有意思的工程细节。

runqget则从当前 P 持有的本地运行队列取值。本地队列是一个无锁环形队列,容量 256,之所以无锁,是因为正常情况下只有当前 P 所属的 M 会对它做 pop 操作,锁开销能省则省。只有 work stealing 或处理全局投递时,才会出现其他 M 来并发访问的情况,那时候再用更重的机制兜底。

2.2 execute() 与 gogo():一次彻底的栈上下文切换

找到 G 之后,调用execute(gp, inheritTime)。这个函数做的事情包括:

  • 把 G 的状态从_Grunnable改成_Grunning
  • 更新 P 和 M 之间的绑定关系
  • 调用汇编函数gogo()完成真正的上下文切换

gogo的汇编实现本质上就是一次现场恢复:从gp.sched中取出之前保存的sppcbp,以及各通用寄存器的值,写入当前 CPU;然后跳转到gp.sched.pc指向的指令。也就是说,goroutine 不是“启动”出来的,而是“恢复现场”出来的。第一次启动时,runtime 会预先构造好一个假的现场,让pc指向goexit函数之后的位置,用户代码像是“从暂停状态被继续执行”一样跑起来。

这里有个经常被误解的点:M 与 G 之间不存在一对一绑定。同一个 M 可以执行千万个不同的 G;同一个 G 也可能在多次阻塞后被不同的 M 接着跑。一切都以现场保存与恢复为基础,这也是调度循环能够持续工作的底层能力。

2.3 goroutine 退出时,循环并不会结束

一个 G 执行完自己的函数后,接下来发生了什么?如果你写过go func() {}(),没有显式地 looping,但整个流程并不会因为函数结束而终止。

用户函数返回后,编译器会插入对runtime.goexit的调用。goexit真正做的是把当前 G 的清理工作做完:标注 G 已退出、把栈还回内存池、执行该执行的defer,然后通过mcall(goexit0)切回 M 的 g0 栈。goexit0的最后一步,就是再次调用schedule()

这就是调度循环“永不停歇”的第一个层面:它不是一个只跑一次的主循环,而是靠“函数退出后再跳回来”这种方式,把循环闭合。只要 M 还活着,它就永远在schedule → execute → run → goexit → schedule这个闭环里打转。

2.4 主动让出:Gosched、channel 阻塞与系统调用如何回到调度器

并不是所有 G 都运行到函数返回才让出 CPU。更多情况下,G 会在中途主动或被强制地退出运行状态,把 M 还给调度循环。

常见的主动让出路径有三条:

  1. runtime.Gosched()显式让出:G 会把自己的状态改成_Grunnable,直接丢回当前 P 的本地队列尾部,然后立即调用schedule()。这个操作适合那种“我虽然有计算能力,但想让其他 G 先跑”的场景。

  2. channel 或锁阻塞:当 G 执行ch <- v<-ch时,如果条件不满足,G 会被封装成一个等待者挂到 channel 的等待队列上,状态变为_Gwaiting,然后gopark把 G 和 M 解除关联。M 随即继续跑schedule()。这就是前面说的“阻塞不占用线程”的实现基础。

  3. 进入系统调用:G 执行syscall时,runtime 无法预知内核什么时候返回。如果这个系统调用很快(比如getpid),就直接占用当前 M 等它返回;如果 runtime 判断系统调用可能较慢,当前 P 会被标记为_Psyscall,而 M 继续等系统调用返回。这时候其他 M 可以通过 handoff 机制接管这个 P,继续从它的本地队列里取 G 执行,从而避免整个工作台空转。

无论哪条路径,本质都是同一件事:把 M 从“被某个 G 绑死”的状态中解放出来,重新回到调度循环去排下一个任务单。

3. findrunnable:当本地队列空空如也,调度器开始满世界找活

3.1 查找顺序背后的现实逻辑:先近后远

findrunnable()是调度循环里最复杂、也最能体现“找人干活”精神的一个函数。本地运行队列(runq)没有 G 时,M 并不会立刻绝望,而是按顺序扩大搜索半径:

  1. 再次尝试从本地 runq 取 G——因为等待期间可能又有新 G 入队;
  2. 检查全局运行队列,取一批 G 到当前 P;
  3. 检查netpoll,看是否有网络事件就绪的 G 可以立刻激活;
  4. 从其他 P 的本地 runq 中窃取任务;
  5. 仍找不到,就检查 GC 标记队列、检查定时器堆;
  6. 全部落空,M 才考虑休眠。

这个顺序很有讲究:本地队列最快,全局队列次之,网络轮询通常也能拿到一批就绪任务;而 steal 是成本最高的操作,所以放到后面。整个过程体现了调度器的一个核心原则——尽量先把全局系统里的“碎片时间”填满,再考虑让线程休息。

3.2 work stealing:从别人的 runq 里“借一半”任务

当某个 P 的本地队列空了,而另一个 P 的本地队列还有一堆积压时,调度器会执行stealWork。目标不是把所有任务搬空,而是从目标的 runq 里偷走大约一半(实际是n/2个),存量留给别人自己用,自己拿一半回去执行。这种“借一半”的设计让任务量能快速在多个 P 之间均衡,又不会让被偷的 P 立刻陷入饥饿。

源码里对应的函数是runqsteal,它会遍历其他 P 的 runq,找到任务数最多的目标后,用带锁的方式批量搬运一批 G。为什么需要带锁?因为 race:其他 P 可能同时在往队列里 push 新的 G。这个锁只在偷取瞬间持有,正常运行时 pop 不碰锁,所以整体性能影响可控。

可以把它类比成几个柜台窗口:一个窗口排长队,另一个窗口没人排队,闲着的人会去长队窗口直接抽走一半顾客到自己的窗口办理。最终结果就是所有窗口的处理时间趋近均衡。

3.3 spinning 线程:宁可让空闲的 M 四处转,也不要唤醒新线程

findrunnable里还有一个细节非常重要:spinning标志。当一个 M 找不到工作、准备去寻找其他 P 偷任务时,它会被标记为自旋状态(spinning),意思是“我正在满世界找活”。runtime 在创建新 M 时有一个策略:如果当前已经有自旋 M 在找活了,就不再创建新的 M;反之,如果可运行的 G 数量很多但自旋 M 不够,才会考虑补新 M。

换句话说,runtime 更愿意让现有空闲线程“辛苦点四处转”,也不愿意轻易唤醒或创建新线程。因为线程创建、唤醒的系统调用成本,可能比短暂等待任务到来还要高。只有当确实存在“有活没人干”的明确信号时,才会增加线程。

这种设计在源码里的体现是wakep()函数:它会在发现全局系统里有可运行 G 且自旋 M 不足时,尝试唤醒一个休眠的 M 或创建一个新的 M,让调度循环在更短的时间内得到补充。

3.4 实在找不到活:M 进入休眠,并交出 P

如果经过上述所有努力,系统里真的一个可运行的 G 都没有,M 会执行stopm():交出当前的 P(把 P 的状态改为_Pidle,放回空闲 P 列表),然后通过park把自己(M)挂到某个休眠队列上,进入内核级阻塞状态。

这个休眠不是永久的。触发唤醒的条件包括:新 G 被创建、定时器到期、网络事件就绪、GC 工作需要、其他线程投递任务等。一旦有事件发生,runtime 会找到合适的 M 唤醒它,让它重新进入调度循环。

这也是“永不停歇”的第二个层面:调度循环在宏观上看起来一直在运行,实际上是由“工作→休眠→唤醒→工作”这样的事件驱动链组成。没有任何一个时刻系统在空转,全都在寻找下一个可执行的 G,找不到就睡,等有人喊活再起来。

4. sysmon、netpoller 与异步抢占:叫醒调度循环的三股外力

4.1 sysmon:整个 runtime 的后台大管家

调度循环不能只靠“G 自己让出”来维持公平。万一某个 G 是死循环,永远不主动让出怎么办?这时候就需要一个外部监控者——sysmon

sysmon是一个独立的系统监控线程,从 runtime 启动起就一直存在,不绑定任何 P。它的轮询间隔在 10ms 到 20ms 之间动态调整:系统比较忙时,间隔偏向 10ms;系统空闲时,间隔可拉长到 20ms。它的职责很杂,包括:

  • 检查是否有 P 在_Psyscall状态停留过久,触发 handoff;
  • 检查是否有 G 运行时间超过 10ms,触发抢占;
  • 检查netpoll是否有已就绪的网络事件;
  • 检查是否需要触发 GC;
  • 检查定时器堆,看是否有到期的 timer。

一句话总结:sysmon就像车间里的安全巡视员,不用自己干活,但时刻盯着有没有人占着岗位长时间不放手,并出手把卡壳的地方疏通。

4.2 netpoller:为什么网络 IO 不会卡死调度循环

Go 之所以能支撑海量连接,调度循环能保证“干活不停”,最大功臣是netpoller机制。

当一个 goroutine 执行conn.Read(buf),底层 fd 会被注册到操作系统的事件驱动机制上(Linux 上是epoll,macOS/BSD 上是kqueue,Windows 上是IOCP)。goroutine 随即进入_Gwaiting状态,挂到等待队列,所属 M 立刻回到调度循环干别的活。等到 fd 可读事件真正到达,netpoll会从事件集合里取出对应的 goroutine,把它们标为_Grunnable并放入运行队列,等待某个 M 拾起执行。

也就是说,在网络 IO 期间,线程资源零浪费。十万个连接、十万个 goroutine 全部阻塞在Read上,最坏情况也只是系统用epoll_wait在一个线程里统一等待,而其他线程继续执行计算任务。调度循环之所以能在高并发网络场景下依然“有人可找”,靠的就是 netpoller 把 IO 等待从线程执行路径里彻底摘除。

4.3 10ms 抢占:异步抢占与安全点的关系

从 Go 1.14 开始,调度器引入了基于信号的异步抢占机制:sysmon发现某个 G 已经连续运行超过 10ms,就会向它所在的 M 发送一个信号(SIGURG),触发该 M 的信号处理函数,在代码的安全点(safe point)插入中断,保存现场,然后把 G 的状态改回_Grunnable并放入队列,调度循环重新运行。

为什么需要“安全点”?因为任意指令位置被抢占可能破坏运行时的一致性。编译器会在函数调用、循环等位置插入抢占检查指令(stack check),这些位置就是安全点。异步抢占机制大幅改善了之前“for {} 死循环导致所有 P 被占死”的囧境。现在,即使是一个不含任何函数调用的无限循环,在 10ms 的切片耗尽后,也会被信号打断并强制让出 CPU。

forcePreemptNS这个常量就是 10ms。你可以把它看成调度器给每个 G 的时间片,时间一到就有专人来找你开会,强制把 CPU 交还给调度循环。

5. 调度循环在真实服务中的表现:从高并连接到性能观测

5.1 GOMAXPROCS 是并行度,不是性能倍增器

GOMAXPROCS决定的是 P 的数量,也就是同时能有多少条线程并行执行 goroutine。它的默认值是 CPU 核心数。很多初学者把 GOMAXPROCS 当成“并发上限”或者“性能旋钮”,动辄调大到几百,结果往往适得其反。

P 多的直接代价是:每个 P 要维护自己的本地运行队列、定时器堆、内存缓存(mcache),调度循环需要在更多 P 之间寻找任务,work stealing的竞争面更大,GC 的标记阶段也要扫描更多 P 的队列资源。在纯计算密集的场景下,将 P 数调到超过核心数,只会增加无谓的调度开销;在 IO 密集型场景,P 数只要能让线程满足系统调用和网络轮询的需求就够了。

如果应用跑在容器里,还有一个坑:runtime 读到的核心数往往是宿主机的物理核数,不是 cgroup 限额。这时候 GOMAXPROCS 可能远超实际可用 CPU,导致大量线程排队等待调度,反而拖垮吞吐。社区常见的解决办法是用automaxprocs这类库在启动时自动读 cgroup quota 来调整。

5.2 为什么几万个 goroutine 跑得动,线程池几十个就吃力

这个问题本质上就是调度循环设计目标的体现。

线程池模型下,每个任务占用一个线程,任务一旦发生阻塞,线程就“报废”了;如果想支持 10 万个并发连接,线程数量就得奔着 10 万去,而线程上下文切换的成本会指数级上升。goroutine 模型下,阻塞只影响 G 本身,M 会重新回到调度循环,所以“十万 G 同时挂起”和“几百 G 同时挂起”对线程资源来说几乎没差别。

代价当然也有:goroutine 的调度循环本身需要额外 CPU。每个 G 创建、让出、恢复都要执行几百条指令。所以 goroutine 不是没有成本,而是成本被压缩到了远低于线程切换的程度。工程上一条经验是:如果每个 goroutine 的真实计算量极小(微秒级),且创建频率极高(每秒百万次),调度开销会变成热点,这种情况才需要考虑复用 goroutine 或调整设计。

5.3 websocket 长连接服务为什么天然适配这个模型

回到文章开头那个 websocket 场景。每个连接需要维持一个读写循环:

for { msg, err := conn.ReadMessage() if err != nil { // handle disconnect return } // process msg }

如果没有调度循环的能力支持,要为 5 万个连接维持 5 万个阻塞读,线程池方案基本会当场崩溃。但在 Go 里,conn.ReadMessage阻塞的是 goroutine,而不是线程;阻塞期间 goroutine 挂在 netpoller 的等待队列里,等消息到达后,由网络事件把 goroutine 重新投递到调度队列,由任一个空闲的 M 接着跑。

这种“一人一个 goroutine,阻塞了就被调度器替换掉”的模式,是长连接服务在 Go 里如此自然的根本原因。我后来在监控面板上能明显看到:连接数从 1 万涨到 10 万,线程数几乎不变,只有 goroutine 数在涨。这就是调度循环和 netpoller 协同工作的直接证据。

6. 我在工程中踩过的调度循环相关的坑:三次现场复盘

6.1 案例一:全局热锁把调度循环“拴”在等待队列上

有一段时间,服务延迟偶尔飙到秒级,goroutine 数也异常高。我抓了 goroutine dump,发现大量 goroutine 阻塞在一把全局sync.RWMutexLock()上,而不是在执行代码。

问题根因不是调度器,而是业务逻辑:某个低频任务往全局 map 里写入大量数据,写操作持锁时间过长,导致所有读请求的 goroutine 全部挤在锁等待队列里。从调度循环视角看,所有 G 都在_Gwaiting状态,没有 G 可运行,M 们只能不断 park/wakeup。调度循环本身无罪,但它放大了一个事实:一个让大量 G 同时阻塞的资源,会让调度循环看起来“很忙但不出活”。排查时不要只盯 runq 有没有积压,也要看 G 都卡在什么等待条件上。

6.2 案例二:把 GOMAXPROCS 调大后性能不升反降

另一个项目里,有同事在 8 核机器上把GOMAXPROCS设为 64,理由是“提高并发能力”。上线后 CPU 占用直接翻倍,请求延迟却增加了。

原因就是前面说的调度开销:64 个 P 意味着调度器要维护 64 个本地队列,findrunnable的 steal 范围扩大到 63 个目标,锁竞争概率提升;线程调度器在内核层也要在更多可运行线程间做取舍。用 pprof 看sched相关样本,能明显看到大量时间花在队列操作和任务窃取上。改成默认值 8 之后,延迟和 CPU 双双恢复正常。

6.3 案例三:time.After 循环制造的“伪调度压力”

还有一次印象深刻的排查:一个数据同步协程里写了类似这样的代码:

for { select { case <-time.After(50 * time.Millisecond): // polling logic case <-ctx.Done(): return } }

每轮循环都会调用一次time.After,意味着一秒 20 个 timer 被创建、销毁。这些 timer 并不是即时释放的,会堆在 P 的 timer heap 里,调度循环每次检查定时器时都要处理它们,造成不少无谓的 CPU 消耗。换成time.NewTicker并统一管理 ticker 之后,调度循环的负担明显下降。

这也算调度循环相关的经验:定时器是调度循环的重要唤醒源之一,大量短生命周期 timer 会间接抬高调度的噪声,在高频循环里尽量复用 ticker 而不是反复创建一次性 timer。

6.4 定位调度问题的一套实用姿势

碰到疑似调度循环导致的问题,我一般按这个顺序排查:

  1. 抓取 goroutine dump(runtime/pprof.Lookup("goroutine")SIGQUIT),看清 G 分布在哪些状态,重点是_Gwaiting_Runnable的比例;
  2. go tool pprof -http=:8080看 CPU profile,找到调度器函数(如schedulefindrunnablerunqsteal)和锁相关函数的热点;
  3. 观察GOMAXPROCS与实际 CPU 配额的关系,确认容器环境下没有被错误放大;
  4. 检查 IO 密集型 goroutine 是否走了 netpoller(通常是),有没有 goroutine 直接占用线程做慢系统调用;
  5. 最后才是看业务代码里有没有不合理的长持锁、睡眠循环、高频 timer。

这套流程帮我在好几个项目里定位过问题,也让我越来越确定:Go 调度循环本身极其健壮,“永不停歇找人干活”的目标实现得非常彻底,大多数性能故障都来自业务代码与调度模型之间的错配,而不是调度器本身的缺陷。想用好 Go,理解这条循环是绕不开的第一课。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询