07 GMP 调度器:从 GM 到 GMP,工作窃取与抢占

开篇提问

先问你一个看似简单、实则很深的问题:

Go 号称能轻松跑百万 goroutine,但你的机器只有 8 个核、8 个操作系统线程。那么问题来了:这 100 万个 goroutine,是怎么在 8 个线程上跑起来的?谁来决定”这一刻该让哪个 goroutine 上 CPU”?

答案是调度器。但真正的难点在后面这个问题:

调度器自己跑在哪?它调度的开销有多大?如果调度本身很重,100 万个 goroutine 只会更慢,不会更快——那 Go 是怎么做到”调度便宜到可以忽略”的?

这一章,我们拆开 Go 调度器的核心——GMP 模型,讲清楚它是怎么设计的、为什么从 GM 改成 GMP、以及工作窃取和抢占这两件”看不见的魔法”。


子主题一:三个字母,三种角色

GMP 模型里,三个字母是三种东西:

G(Goroutine):你要执行的”任务”。它本质是一个结构体,记录了自己的栈、程序计数器(PC)、状态(运行中/可运行/阻塞/等待)等。一个 G 就是”一个逻辑执行流”。注意:G 不是线程,它只是一堆状态数据。

M(Machine / OS Thread):真正的操作系统线程,是 CPU 的执行主体。只有 M 能真正占用 CPU 跑代码。M 上必须绑定一个 G 才能干活——M 是”干活的工人”,G 是”要干的活”。

P(Processor / 逻辑处理器):这是 Go 调度器最独特的设计。P 不是线程,也不是任务,而是一个**“执行资源”**——它维护一个”本地可运行队列”(runq),决定”哪些 G 排队等着上 CPU”。P 的数量由 GOMAXPROCS 决定,默认等于 CPU 核数。

三者的关系一句话:

G 是活,M 是工人,P 是”工位 + 待办清单”。每个工人(M)必须占一个工位(P)才能领活(G)干。

为什么要有 P 这个中间层?这正是 Go 调度器从 GM 模型改成 GMP 模型的根本原因。下一节讲。


子主题二:为什么从 GM 改成 GMP

最早的 Go 调度器是 GM 模型:一个全局的 runqueue,所有 goroutine 都往这一个大队列里塞,所有 M 都从这个全局队列里取活。为了保证线程安全,这个全局队列要加一把全局锁。

问题随之而来:所有 M 取活、放活都要抢这一把锁。 goroutine 越多、M 越多,锁竞争越激烈。当你有几十个 M、几百万个 G 频繁进出队列,这把全局锁就成了吞吐瓶颈——调度器自己成了性能天花板,这完全违背了”调度要便宜”的初衷。

而且 GM 模型还有个衍生问题:局部性差。一个 G 在 M1 上跑了一半,可能被换到 M2 上继续跑,它的数据在 M1 的 CPU 缓存里是热的,换到 M2 就全冷了。

GMP 模型就是为了解决这两个问题:

解决锁竞争——本地队列。 每个 P 有自己的本地 runqueue。G 优先进出本地队列,而本地队列是 P 独占的,不需要加锁。只有本地队列空了或满了,才去碰全局队列。锁竞争从”每次调度都抢”降到”偶尔才抢”。

解决局部性——P 是稳定的执行上下文。 前面第三章讲过,mcache(内存分配缓存)挂在 P 上,现在调度队列也挂在 P 上。一个 G 在某个 P 上运行时,它的数据、它的分配缓存都热在这个 P 相关的上下文里。把”工作队列”和”内存缓存”都绑在 P 上,就最大化地保留了局部性。

所以 P 的本质,是一句话:把”会发生竞争的共享资源”(队列、缓存),按”稳定的执行单元”(P)拆开,让绝大多数操作都在”无锁的本地”完成。


子主题三:一次 goroutine 的完整生命周期

理解了三个角色,我们走一遍一个 goroutine 从出生到死亡的完整旅程,你会更直观地看到调度器在干嘛:

  1. go func() 创建一个 G,把它塞进当前 P 的本地队列(如果本地队列满了,才塞全局队列)。
  2. 某个 M 抢到了这个 P,从 P 的本地队列头部取出这个 G。
  3. M 切换执行上下文,开始跑这个 G 的代码。
  4. G 跑着跑着,发生了阻塞(比如 syscall、channel 阻塞、sleep、io)——这时调度器介入:
    • 如果是系统调用阻塞:M 和 G 一起”卡”在 syscall 里,P 被释放,交给别的 M 继续调度其他 G;等 syscall 返回,G 再重新找 P 或 M 继续跑。这样一个 G 的阻塞不会占用一个 P,其他 G 还能跑。
    • 如果是channel/锁等运行时阻塞:G 被摘下来、挂到等待队列,M 不闲着,立刻去 P 的队列里取下一个 G 跑。
  5. G 跑完或 return,被回收,M 继续取下一个 G。

看到这里你就明白了:Go 并发”便宜”的真相,是”阻塞时让出 M、不让 OS 线程空等”。 操作系统线程之所以贵,是因为线程阻塞时线程就真的挂在那了;而 goroutine 阻塞时,M 可以去跑别的 goroutine,一个 M 能”复用”成千上万个 G 的阻塞间隙。这就是”百万 goroutine 跑在 8 个线程上”的完整机制。


子主题四:工作窃取——平衡负载的魔法

本地队列解决了锁竞争,但带来了新问题:负载不均。

假设你有 4 个 P,其中 P1 的队列堆了 100 个 G,P3 的队列空了。如果各 P 各扫门前雪,P3 就只能干看着,P1 累死——整体吞吐被拖累。

Go 的解法是工作窃取(work stealing):

  • 当一个 P 的本地队列空了,它就去”偷”别的 P 的队列。
  • 偷有讲究:本地队列是”自己从头部取”,小偷从”尾部偷”。这样窃取者和被窃取者操作的是队列的两端,冲突最小。
  • 如果所有 P 的本地队列都空了,再去全局队列取。

工作窃取让负载在 P 之间自动流动,而且几乎不需要中心协调——每个 P 只在自己”饿”的时候去偷,饿的 P 越多,偷得越勤,系统自然趋于平衡。这是一个非常优雅的去中心化负载均衡设计。

这里又有个精妙的细节:为什么自己从头部取、别人从尾部偷? 因为这样能保证”被偷的往往是被窃取者还没来得及跑的、队列尾部较冷的 G”,而窃取者自己最近在跑的 G(队列头部)不容易被偷走,保护了局部性。这个”两端操作”的设计,和很多无锁队列(work-stealing dequeue)如出一辙。


子主题五:抢占——防止一个 goroutine 霸占 CPU

工作窃取解决了”队列不均”,但还有一个更阴险的问题:如果一个 goroutine 死循环、或者跑了很久不阻塞、不让出 CPU,其他 goroutine 怎么办?

在早期 Go 里,这个问题很严重——抢占只发生在”函数调用边界”。也就是说,一个 goroutine 只有在调用函数(有函数序言的检查点)时,才可能被调度器”请下 CPU”。如果你的 goroutine 写了个 for {}(空转死循环,不调用任何函数),它就能永远霸占一个 M,把那个核烧满,别的 goroutine 饿死。

Go 1.14 引入了基于信号的异步抢占(asynchronous preemption):操作系统定时器/信号(SIGURG)定期打断正在运行的 goroutine,即使它在死循环里,也能被抢占。

这个改动看似不起眼,实则意义重大:

  • 死循环不再能拖垮整个程序;
  • 调度延迟大幅下降(一个 G 的等待时间从”取决于别人的函数调用”变成”最多一个抢占周期”);
  • 为后续的 GC 停顿、诊断(如 goroutine dump)提供了可靠保障——你能中断一个死循环的 goroutine 了。

异步抢占的实现很有意思:信号处理函数里不干重活,只设置一个标志,等 goroutine 回到安全点(safe point)时才真正让出。这又是”把复杂操作推迟到安全点”的经典手法。


子主题六:netpoll——“一个连接一个 goroutine”的秘密

这里必须提前埋一个伏笔(第十六章会展开):Go 网络编程推崇”一个连接一个 goroutine”,每个连接读写的 goroutine 会在 conn.Read() 上阻塞。如果每个阻塞的 goroutine 都占一个 OS 线程,百万连接就是百万线程,又贵回去了。

Go 的答案是 netpoll(网络轮询器):底层的 socket 全部注册到 epoll(Linux)/kqueue(macOS)/IOCP(Windows)上。当一个 goroutine 在 Read() 上阻塞时,它其实是被挂起、注册到 netpoll,不占 M;等 epoll 报告”这个 socket 有数据可读了”,netpoll 再把这个 goroutine 唤醒、放回队列。所以”一个连接一个 goroutine”的成本,只是一个挂在 epoll 上的 goroutine,而不是一个 OS 线程。

这就是 GMP + netpoll 咬合出的完整图景:M 负责 CPU 执行,P 负责无锁调度,G 负责逻辑任务,netpoll 负责把”等待 I/O 的 G”从 M 上摘下来挂起、有数据时再唤醒。 四者配合,才有了”百万连接、百万 goroutine、几十个线程”的奇迹。


子主题七:G 的完整状态机——调度器眼里的”生老病死”

前面我们把 G 粗糙地说成”运行中/可运行/阻塞”三种。但真实的状态机比这细得多,而理解它,你就理解了”调度器到底在为谁付费”。

Go 运行时给 G 定义了一组状态(runtime/runtime2.go 里的常量),我们把值得记住的捋一遍:

状态含义它在哪儿
_Gidle刚从 gfree 链表里被分配出来,还没初始化不在任何队列
_Grunnable有活能干,在等 CPU本地 runq / 全局 runq / runnext 槽位
_Grunning正在某个 M 上跑,占着一个 PM 上
_Gsyscall正在执行系统调用M 陷在内核里,P 已解绑
_Gwaiting运行时阻塞(channel、锁、sleep、netpoll…)不在任何队列里
_Gpreempted被抢占,让出了 CPU等被重新放进队列
_Gcopystack栈正在扩容/收缩搬迁搬迁中
_Gdead跑完了,等待被复用或回收P 的 gfree 链表

先别急着背。我问你一个能检验理解的问题:_Gwaiting 和 _Grunnable 的本质区别是什么?

不是”一个在等 I/O 一个在等 CPU”这种表面答案。本质区别是:

_Grunnable 的 G 在调度器的队列里,调度器每时每刻都在为它”付费”(它占队列长度、参与窃取判断、影响负载均衡);而 _Gwaiting 的 G 根本不在任何队列里 —— 它只是某个等待结构(channel 的 sendq/recvq、timer 堆、Mutex 的等待队列、pollDesc 的 rg/wg 字段)里挂着一个指针。调度器完全”看不见”它。

这一句是”百万 goroutine”能成立的另一半原因(另一半是栈小):调度器的开销只和”可运行的 G”数量正相关,和”活着的 G 总数”无关。 你有 100 万个 goroutine 但 99.9 万个都在 channel 上等着,那调度器眼里只有 1000 个 G,跟跑 1000 个 goroutine 的程序没区别。

那反过来问:如果一个 G 一直 _Grunnable 却很久没变成 _Grunning,说明什么? 说明它饿着了 —— 这是一个真实的故障模式,我们在子主题十三里讲怎么抓它。

再看几个关键的转换动作,记住这几个函数名,读源码时它们是路标:

  • newproc(go func()):_Gidle → _Grunnable,塞进 P 的 runnext
  • execute:_Grunnable → _Grunning,M 真正开始跑它
  • gopark:_Grunning → _Gwaiting,解开 M 和 G 的绑定,然后 M 立刻 schedule() 找下一个 G。注意:是 G 让出 M,不是 M 抛弃 G
  • goready:_Gwaiting → _Grunnable,把它塞回队列(优先 runnext),并可能 wakep() 唤醒一个睡着的 M
  • entersyscall:_Grunning → _Gsyscall;exitsyscall 反向
  • 抢占:_Grunning → _Grunnable,并且是塞到全局队列,不是本地队列(为什么?子主题十一讲)
  • goexit:_Grunning → _Gdead,丢回 P 的 gfree 链表

gopark/goready 这一对,是整个 Go 并发原语的公共底座。channel、Mutex、WaitGroup、time.Sleep、netpoll,最后统统落到这两个函数上。你在别处听说”Go 的锁、channel 都是用 gopark 实现的”,根源就在这。


子主题八:runq 的容量、runnext,以及”本地 ↔ 全局”的搬运学

子主题二只说了”每个 P 有个本地队列”。现在我们把它拆到骨头。

本地队列不是链表,是定长环形数组。 长度是固定的(运行时里是一个编译期常量,量级是几百个,具体数值随版本可能微调,别去背),头尾两个游标加一个数组就够。为什么用数组不用链表?因为是定长的、几乎无锁的、对 CPU 缓存极其友好 —— 又是一个”用一点灵活性换极致局部性”的取舍。

除了这个环形数组,P 上还额外挂了一个单独槽位:runnext。

runnext:一个槽位里的大学问

新创建的 G(go func())和被唤醒的 G(goready),默认都塞进 runnext,而原来 runnext 里那个 G 被踢进环形数组的尾部。

为什么要这么绕?想想最常见的代码长什么样:

go worker(job)   // 创建子 G
// 父 G 继续跑几行,然后往往就 wg.Wait() / <-ch 阻塞了

父 G 创建完子 G 后,通常马上就阻塞了。这时 M 去队列里取下一个 G —— 如果按 FIFO 取尾部的,取到的是”排在后面的冷 G”;而取 runnext,取到的正是刚创建的、数据还热在缓存里的那个子 G。生产者刚唤醒消费者,消费者立刻上 CPU,这是最理想的时序。

所以 runnext 的本质是:给”刚被唤醒/刚被创建的 G”一个插队特权,把局部性收益兑现成真实的 CPU 时间。

反问一句:runnext 会不会导致不公平?会。但它有一个天然的自净机制 —— 下一个被塞进 runnext 的 G 会把上一个挤到队尾。所以插队是”一次性的”,不会有人一直插队。

本地满了:批量搬运(runqputslow)

runnext 有货、环形数组也满了,新 G 没地方塞。怎么办?

直觉是”把新 G 塞全局队列”。但 Go 不这么做,它做的是 runqputslow:把本地环形数组里一半的 G(加上这个新 G)一次性搬到全局队列 sched.runq 去。

为什么是搬一半而不是搬一个?两个理由,都很硬:

  1. 摊还加锁成本。 全局队列要加 sched.lock,这是一把真正的全局锁。一次加锁搬 128 个 G,和一次加锁搬 1 个 G,加锁次数差了两个数量级。均摊下来,每个 G 承担的锁成本几乎可以忽略。这就是”批量化”对”锁竞争”的降维打击。
  2. 给本地留余粮。 只搬走一半,本地还剩一半,保持了本地的热度。要是全搬空,这个 P 下一轮就得自己去全局队列讨饭,白瞎了局部性。

顺带注意一个方向性:本地队列满 → 往全局倒;本地队列空 → 从全局和别人那儿要。 P 自己在这两种角色间来回切换,没有中心调度者。

本地空了:findRunnable 的五级查找

这是调度循环 schedule() 的核心,顺序记牢,面试和排障都用得上:

  1. 本地 runq(含 runnext):无锁,直接取。绝大多数调度在这一步就结束了。
  2. 全局队列:但不是每次都查 —— 只有当 P.schedtick 对某个质数取模为 0 时才去查一次。而且取的时候不是有多少取多少,是按 全局队列长度 / GOMAXPROCS + 1 分摊着取。
  3. netpoll:非阻塞地问一句”有没有网络 I/O 就绪的 G?“(子主题十细讲)
  4. 工作窃取:随机挑一个起点,遍历所有 P,从别人的队列尾部偷一半。
  5. 还没有:先去试试 GC 标记任务、到期的 timer、finalizer;真的一滴都没有了,M 才 stopm() 睡下去。

这里有两个细节要品:

为什么全局队列要”每 N 次查一次”,而不是每次都查? 因为查全局队列要加锁,而第 1 步本地队列是免费的。如果每次都查全局,那本地队列带来的无锁优势就被全局锁吃回去了。设一个周期,就是把全局队列的访问频率压到可忽略。

为什么取全局队列时要按 1/GOMAXPROCS 分摊,而不是全取? 反着想:如果本地一空就把全局队列全捞走,那第一个饿的 P 会把全局任务全部私有化,其他 P 仍然空着 —— 刚才是”全局饥饿”,现在是”全局被一个人独占”,只是换了种不均。分摊取,让每个饿的 P 都只拿自己那份,天然均衡。

至于那个质数周期:它的作用是和别的周期性行为错拍。 调度 tick、GC、sysmon 唤醒都有各自节拍,用一个质数做采样周期,能避免这些节拍撞在一起产生共振(想象一群人齐步走把桥走塌)。这也是为什么这类常量常常是 61 这种看着毫无道理的质数。

窃取时偷多少?为什么是”一半”?

runqsteal 偷的是被偷者队列长度的一半(从尾部数一半拿走)。

为什么不偷一个?因为偷一个的话,被偷者如果又生产了一批,你很快又饿,得再偷一次 —— 每次窃取都要遍历 allp、要加锁、要判断,窃取本身的成本不低。一次偷一半,把”再次变饿”的时间往后推很远,摊还掉窃取开销。经典的一次做够、别来回跑。

自旋线程(spinning M):一个”线程数 vs 唤醒延迟”的权衡

M 找不到活时,不是立刻睡,而是先进入自旋状态(m.spinning = true,sched.nmspinning++):在一个紧凑循环里反复尝试找活,持续一小会儿。

为什么要有这个看似浪费的中间态?因为创建/唤醒一个 OS 线程是有成本的。如果每次有 G 变 ready 都要 startm 去唤醒一个睡着的 M,唤醒延迟就吃进了每次调度。而如果有一个正在自旋的 M,它下一圈就能看见新来的 G,立刻接手,零唤醒延迟。

代价是:自旋的 M 占着 CPU 不干活。所以 Go 的限制是 —— 自旋的 M 数量不会超过同时空闲的 P 数量(自旋 M 和休眠 M 的总账由 nmspinning/needspinning 这套机制管着),而且自旋有时间上限,超时就 stopm 睡下。

一句话总结:用”少量线程短暂空转”,换”绝大多数情况下的零唤醒延迟”。 这又是一次用空间换时间的经典交易。


子主题九:系统调用路径——P 为什么必须是一个独立的存在

子主题三只说了一句”M 和 G 卡在 syscall 里,P 被释放”。但”P 被释放”这五个字里,藏着整个 GMP 最精妙的一步棋。我们把它慢放。

entersyscall:解开绑定,但不交出 P

goroutine 要进系统调用了,运行时先做 entersyscall:

  1. 保存当前 G 的 PC/SP(因为 syscall 期间可能被抢占、栈可能变)
  2. G 状态置 _Gsyscall
  3. 把 P 的状态置为 _Psyscall,但 P 还记在 m.oldp 上 —— 也就是说,M 还想留着这个 P,没主动放手
  4. 递增 syscalltick,禁止在这个点上被栈扫描和抢占

注意第 3 步的克制:P 没有被立刻交给别人。 为什么?

因为绝大多数系统调用是快的 —— 一次 read 命中页缓存、一次 getpid、一次 clock_gettime,微秒级就回来了。如果每次进 syscall 都把 P 交出去、回来再抢回来,那么 P 的交接成本(要改状态、要唤醒 M、可能要新建线程)就远远超过 syscall 本身了。这是”快路径优先”的设计:默认假设 syscall 会很快返回,不要为它付重成本。

那什么时候 P 真的被交出去?sysmon 出手

如果 syscall 真的卡住了呢?靠 sysmon。

sysmon 是一个特殊的 M —— 它不绑定 P(它干的活不需要 G,也不需要本地队列),由运行时直接创建,独立于 GOMAXPROCS 之外。它周期性地醒来,干几件事,其中一件叫 retake:遍历所有 P,看有没有 P 卡在 _Psyscall 状态超过一个阈值。有,就 handoffp —— 把这个 P 从卡住的 M 上摘下来。

摘下来给谁?两条路:

  • 有空闲的 M(睡着的)→ 直接把 P 交给它,唤醒它(startm)
  • 没有空闲 M → 新建一个 M 来接这个 P

结果就是:一个卡在 syscall 里的 G,不会占掉一份”执行配额”。 它的 M 还在内核里趴着,但那个 P 已经去服务别人了。

还有一种情况是 entersyscallblock:运行时明确知道这次调用会阻塞(某些等待类的调用),那就不等 sysmon 了,进 syscall 之前直接 handoffp,省掉一次 sysmon 的等待周期。这是”已知会慢,就别走快路径”的显式优化。

exitsyscall:回来的两条路

syscall 返回了,G 要接着跑。exitsyscall 分两条路:

快路径(exitsyscallfast):

  1. 我原来的 P 还在 _Psyscall 状态、没被 sysmon 抢走 → 直接 acquirep 拿回来接着跑。零成本,完美。
  2. 原 P 被抢走了 → 从空闲 P 列表里随便拿一个 → 也还行,接着跑。
  3. 一个空闲 P 都没有 → 说明系统现在很忙。这时不能让 M 空转等 P:把 G 状态置 _Grunnable 丢进全局队列,然后这个 M 自己 stopm() 去睡(或者去找别的活)。

慢路径:上面都不成立,就 mcall(exitsyscall0),走 schedule() 重新参与调度。

灵魂拷问:为什么被释放的是 P 而不是 M?

因为 M 没法被释放。 它已经陷在内核里了,内核眼里只有这个线程,Go 运行时对陷在内核里的线程没有任何办法 —— 你没法让一个正在执行 read 的线程”顺便去跑别的 goroutine”,它的寄存器和栈都被内核占用了。

能救的只有 P 那份”执行配额”。

所以 P 的真正本质,一句话:

P 是一份”可以脱离 M 存活的执行配额”。它能从卡住的 M 手上摘下来、贴到另一个 M 身上,从而让”某个线程卡死了”这件事不等于”一个核浪费了”。

这就是 GM 模型做不到、而 GMP 做得到的根本原因。GM 模型里 M 直接持有队列,M 卡了,队列就跟着一起陪葬。P 这一层,本质上是把”执行能力”从”执行载体”里抽象出来、变成可以被调度的资源。

实操警钟:cgo 和阻塞 syscall 是”goroutine 便宜”的例外

上面这套机制有个硬边界:P 被救走了,但 M 还被占着。

一个卡 10 分钟的 syscall,就会有一个 M 陪它卡 10 分钟。你有一万个这样的调用,就有一万个 OS 线程。而每个 M 有自己的系统栈(g0)、有内核侧的开销、还有调度器管理的成本。

所以:

  • cgo 调用被视为会阻塞的 syscall,每次调用都可能占一个 M。C 库里一个 sleep、一个锁等待、一次 DNS 解析(getaddrinfo),都能让 M 数量飙升。
  • 纯 Go 的 C 解析器(Go 自己实现的 DNS 解析)之所以被推荐,就是因为它走 netpoll 而不占 M。
  • 线程数涨到一定程度会直接 panic(runtime: failed to create new OS thread / thread limit exceeded)。这就是”百万 goroutine”神话破灭的地方。

怎么判断自己踩了?看 OS 线程数。 一个健康的纯 Go 网络服务,goroutine 数可能是几万,但线程数应该稳定在几十的量级。子主题十三讲怎么看。


子主题十:netpoll 与调度的精确咬合——挂起与唤醒的每一步

子主题六给了全景,这里放慢镜头。目标:搞清楚一个 G 是怎么”消失”的、又是怎么”回来”的,以及中间那段时间的竞态是怎么被消除的。

三层结构

业务代码  conn.Read(buf)
   ↓
internal/poll.FD —— 非阻塞 fd,读不到数据返回 EAGAIN
   ↓
pollDesc(每个 fd 一个)—— pd.rg(等读的 G)、pd.wg(等写的 G)
   ↓
netpoll —— epoll(Linux)/ kqueue(macOS/BSD)/ IOCP(Windows)

地基:fd 必须是非阻塞的

先问一个前置问题:为什么 Go 必须把网络 fd 设成非阻塞模式?

因为如果 fd 是阻塞的,那么一次 read 就直接把 M 陷进内核了 —— 内核把这个线程挂起,Go 运行时完全失去对它的控制,netpoll 根本没机会介入。“非阻塞 fd + 用户态的等待/唤醒”是 netpoll 能成立的地基。 运行时在初始化 poller 时会强制把 fd 置为非阻塞。

这也解释了为什么文件 I/O 不走 netpoll(普通文件的 fd 在 epoll 里语义不完整,且通常被认为是”总能读”的),文件读写走的是阻塞 syscall 那套路径 —— 所以大量文件 I/O 是会占 M 的。这是一个很多人不知道的坑。

挂起:五步,顺序不能错

  1. conn.Read() → 底层非阻塞 read → 返回 EAGAIN(现在没数据)
  2. internal/poll 调 runtime_pollWait(pd, 'r') → netpollblock
  3. netpollblock 先乐观地 CAS 一下 pd.rg:0 → pdReady 成功,说明事件已经到了,直接返回不睡。否则准备睡。
  4. gopark(netpollblockcommit, pd, waitReasonIOWait, ...):
    • 先把 G 状态从 _Grunning 改成 _Gwaiting
    • 再调用 unlockf(netpollblockcommit),把 pd.rg 原子地写成这个 G 的指针
    • 然后解开 M 和 G 的绑定,M 立刻 schedule() 去跑下一个 G
  5. G 现在不在任何队列里,只有 pd.rg 指向它

第 4 步那个顺序是整个 netpoll 最关键的一处竞态处理,值得单独拎出来:

为什么必须”先决定睡(置 _Gwaiting)再登记到 pd 上”,而不是反过来?

反过来想:如果先登记 pd.rg = g,再检查是否有事件、再睡 —— 那么在”登记完”和”真正睡下”之间的这个窗口里,事件到达了。netpoll 那边看到 pd.rg 有值,CAS 成 pdReady,然后去唤醒这个 G……可这个 G 还没睡呢,唤醒信号就丢了。等它真睡下,就再也没人叫它了。这就是经典的丢失唤醒(lost wakeup)。

Go 的解法是把顺序锁死:先原子地宣告”我要睡了”,再登记”睡在这儿了”。netpoll 唤醒路径只能看见”已经登记好的等待者”,于是要么它 CAS 成功把 G 拿走(G 还没睡完,那就取消睡眠直接返回),要么它发现 G 已经彻底睡下,正常唤醒。两条路都不丢。

这类”先声明意图再登记”的顺序,是并发编程里消除竞态的通用手法。你在写自己的无锁代码时会反复遇到它。

唤醒:谁去调 netpoll?

G 挂起了,M 跑别的去了。那谁去 epoll_wait 等事件?三个地方:

  1. sysmon:周期性醒来时调一次非阻塞的 netpoll(0) —— 有就绪的 G 就拿回来,没有就立刻返回继续干别的
  2. findRunnable:一个 P 实在找不到活干、准备把 M 睡下去之前,会调一次可以阻塞一段时间的 netpoll —— 既然闲着也是闲着,不如帮你等一会儿 I/O
  3. GC / STW 相关路径:保证在停机期间不会漏掉 I/O 事件

唤醒的链路:

epoll_wait 返回就绪事件
   → netpollready:对每个事件找到对应的 pollDesc
   → netpollunblock:CAS(pd.rg, G指针 → pdReady),成功就取出这个 G
   → 收集成一个 gList
   → netpollgoready:对每个 G 调 goready()
       → _Gwaiting → _Grunnable,runqput 到本地 runnext
       → 必要时 wakep() 唤醒/新建 M

sysmon 那条路径略有不同:它是在”没有 P 的上下文”里干活的,所以它用 injectglist 把 G 列表塞进全局队列,并且需要时 startm 唤醒一个 M 来接手。这就是为什么 I/O 就绪的 G 有时出现在本地队列、有时出现在全局队列。

死线(deadline)也走这条路

SetReadDeadline 设的超时,最终会变成 netpoll 等待的超时参数。超时一到,poller 同样把这个 pd 标记起来、把 G 唤醒,G 醒来发现是超时,就返回 ErrDeadlineExceeded。

这意味着:网络 I/O 的超时不需要给每个连接挂一个 timer 堆节点。 等待自带超时,成本仍然只是一个 pollDesc。这是”一个连接一个 goroutine”能撑到百万级的又一个细节。

一句话收束

现在你可以把 netpoll 和 gopark/goready 的关系看清楚了:netpoll 不是调度的替代品,它是 gopark/goready 的一个”事件源”。 它只负责回答一个问题——“哪些等待中的 G 现在可以 goready 了”——然后把 G 交还给调度器。挂起和唤醒的机制,依然复用 gopark/goready 这套公共底座。


子主题十一:两种抢占——一个是”自觉排队”,一个是”交警拦车”

子主题五讲了异步抢占的意义,这里我们把它和协作式抢占放在一起对比,并把精确的机制讲明白。这两者不是替代关系,而是分工关系。这一点很多人搞混。

协作式抢占:复用已有的栈检查,成本几乎为零

编译器在每个函数的序言(prologue)里都会插一段栈溢出检查 —— 比较栈指针和 g.stackguard0,不够就调 morestack 扩栈。这段检查本来就在,每个函数调用都会执行。

调度器想抢占某个 G 时,只需要做一件事:把它的 g.stackguard0 设成一个特殊值 stackPreempt。

于是下一次这个 G 调用任何函数,栈检查必然”失败”,走进 morestack → newstack,运行时一看:这不是真的栈溢出,这是抢占标记 → preemptone → gopreempt_m → goschedImpl:

  1. G 状态 _Grunning → _Grunnable
  2. 解开 M 和 G 的绑定(dropg)
  3. 把 G 放进全局队列(不是本地队列)
  4. M 继续 schedule() 找下一个 G

成本几乎为零 —— 它复用了一条本来就存在的指令路径,没有额外的定时器、没有信号、没有寄存器保存。这是抢占的主力。

为什么被抢占的 G 放全局队列而不是本地队列? 想一秒:如果放回本地队列头部(或 runnext),那这个 P 下一轮调度大概率又把它捞回来继续跑 —— 抢占了个寂寞。放全局队列,既给了其他 P 抢到它的机会(公平),也避免它立刻回到同一个 P 手上(有效)。一个小小的放置选择,决定了抢占是否真的有意义。

协作式的致命缺陷:它只在”函数调用”这个点生效。一个 for {} 空循环不调用任何函数,永远走不到检查点,于是永远不会被抢占 —— 它能占着一个 P 和 M 烧满一个核,别的 goroutine 全饿死,甚至 GC 的 STW 都进不去(因为 STW 也要等所有 G 到达安全点)。

异步抢占:用信号强行打断

Go 1.14 的解法是引入操作系统信号(Linux 上是 SIGURG):

sysmon 周期性醒来,检查每个 P:
  发现某个 P 上的 G 连续跑了"超过一个时间片量级"的时间
    → preemptone:
        1. gp.preempt = true
        2. gp.stackguard0 = stackPreempt(协作式的钩子也设上,双保险)
        3. signalM:给这个 G 所在的 M 发一个 SIGURG
             ↓
M 的信号处理函数收到 SIGURG,识别出是抢占信号
             ↓
插入一段异步抢占序列(asyncPreempt,一段专门生成的汇编):
    ❗ 把【所有】寄存器保存到栈上的固定位置
    ❗ 同时记录好 unwind 信息,让 GC 能精确扫描这个被中断的栈
             ↓
调用 asyncPreempt2(Go 代码):
    检查当前是否处在"不可抢占"的临界区
        —— 持有某些运行时锁、正在 cgo 调用里、在信号处理上下文中……
        —— 安全才继续,不安全就只是记下标记,等下次
             ↓
mcall(preemptPark) / gopreempt_m → G 让出,回到队列

几个必须讲透的点:

为什么是 SIGURG? 这个信号在 POSIX 里原本用于”socket 带外数据到达”,Go 借用它做用户态抢占,因为它默认不会被业务代码当作致命信号接管或终止进程。投递时用的是定向到线程的机制(tgkill/pthread_kill),确保信号落到正在跑那个 G 的那个 M 上,而不是随便一个线程。

为什么必须保存所有寄存器? 因为抢占可以发生在任意两条机器指令之间。此时这个 G 的”现场”是各个寄存器里的中间值。而 Go 的 GC 是精确 GC,它需要知道栈上每个字是不是指针、寄存器里存的是什么。如果被信号打断时寄存器状态不明,GC 就没法安全扫描这个栈。所以 asyncPreempt 那段汇编的唯一任务就是:把所有寄存器规规矩矩写到栈上,并留下能让运行时读懂的 unwind 元数据。

最关键的一点 —— “异步”到底异步在哪? 这是最常被误解的地方:

异步抢占的”异步”,指的是”信号的投递是异步的”(由内核在任意时刻打断线程),而不是”让出发生在任意指令上”。 G 仍然要走到一个安全点才会真正让出 CPU。

也就是说:信号把你拦下来了,但你还是得把车开到路边停好,不能停在马路中间。 因为停得不规矩(栈和寄存器状态不可解析),后面 GC 和栈扫描就全乱了。

为什么还需要协作式? 既然异步抢占这么强,干脆全用它不行吗?不行 —— 发信号、走内核、保存所有寄存器、再恢复,这一套的开销比”复用一次栈检查”高好几个数量级。协作式是免费的日常路径,异步是昂贵的保险丝。 让 99.99% 的抢占走免费路径,只让那 0.01% 的死循环付出信号的代价 —— 这才是完整的图景。

两者分工,一句话总结

协作式抢占异步抢占
触发点函数调用边界(栈检查)内核信号(任意指令处打断)
触发者G 自己走到检查点sysmon 主动 signalM
成本几乎为零(复用已有指令)高(信号 + 全寄存器保存)
覆盖绝大多数正常代码包含死循环在内的一切
解决什么公平:日常的时间片轮转上界:保证最坏延迟、让 STW/dump 能完成

类比: 协作式是”大家自觉排队,办完一件事就看看后面有没有人”;异步是”交警拿着哨子,看见谁堵着不走直接拦下来”。没有交警,一个无赖就能堵死整条路;只有交警没有自觉,那所有人都要为那一个无赖付出被拦停的成本。两个都要有。

还有第三种”半抢占”:GC 的辅助

顺带提一句,GC 的并发标记阶段也需要”让某个 G 停一下扫它的栈”,用的也是这套 stackguard0 = stackPreempt 的钩子加上异步信号兜底。所以异步抢占不只是为了公平,它还是 Go 能把 STW 压到极短的前提条件之一 —— 没有它,一个死循环的 G 就能让整个世界停不下来。


子主题十二:GOMAXPROCS 的调优与误区——它到底是什么,不是什么

先正名:GOMAXPROCS 限制的是什么?

它限制的是 P 的数量,也就是”最多能有多少个 goroutine 同时在 CPU 上执行”。

它不限制:

  • ❌ goroutine 的总数(你想开一百万照样开)
  • ❌ OS 线程的总数(阻塞的 syscall 会造出远超 GOMAXPROCS 的 M)
  • ❌ 程序的”并发能力”(I/O 并发由 netpoll 决定,与 P 数无关)

默认值等于运行时可见的逻辑 CPU 数(受 CPU 亲和性影响)。

误区一:“GOMAXPROCS 调大 = 性能变好”

错。P 不是免费的。每个 P 都要:

  • 被 sysmon 遍历、被调度器遍历(工作窃取要遍历 allp)
  • 持有自己的 mcache(第三章讲的内存分配缓存),P 多了,内存被切成更多份
  • 参与 GC 的并行标记协调(P 越多,协调和同步的成本越高)

GOMAXPROCS 是”并行度上限”,不是”性能旋钮”。 把它调到远超核数,得到的只有更多上下文切换、更多缓存失效、更重的 GC 协调。

误区二:“I/O 密集型程序要把 GOMAXPROCS 调大”

这可能是流传最广的错误建议,源于把 Go 当成了线程池模型。

认真想:一个阻塞在网络 I/O 上的 goroutine,占 P 吗? 不占 —— 它被 netpoll 摘下来挂起了(子主题十)。所以 I/O 密集的程序根本不需要靠”多开 P”来并发,它的并发是”零 P 成本”的。一个 GOMAXPROCS=2 的 Go 服务,照样能处理十万并发连接。

那什么时候真的需要把 GOMAXPROCS 调大?几乎只有一个场景:P 被长时间占在”不能让出”的地方。

  • 大量阻塞式系统调用(尤其 cgo)
  • 大量文件 I/O(走阻塞 syscall,不走 netpoll)

这些情况下,一个真的卡住的 M 会带走一个 P(sysmon 会救,但如果是 cgo,sysmon 对陷入 C 代码的 M 无能为力 —— G 在 cgo 里不算 _Gsyscall,运行时管不着)。所以 cgo 密集型服务往往需要把 GOMAXPROCS 调大一些,纯粹是为了留出冗余。

误区三:容器里的 CPU 核数感知 —— 最经典的线上坑

这个坑值得单独讲,因为它坑过无数人。

场景:你有一台 64 核的机器,上面跑 Kubernetes,给你的 Pod 设了 CPU limit: 2。

问题来了:你的 Go 程序看到的 NumCPU() 是多少?

历史版本的答案是:64。 因为 Go 运行时读的是”这台机器有多少个逻辑 CPU”,它不看 cgroup 的 CPU quota。于是 GOMAXPROCS = 64,运行时会开 64 个 P、几十个线程。

然后灾难发生:

cgroup 的语义是:每个周期(默认 100ms)内,你最多用 200ms 的 CPU 时间
你的 64 个线程:一个周期里每个跑 3ms,200ms 配额就烧光了
   → cgroup 把你整个进程 throttle 掉,剩下的 97ms 全部冻结

表现是什么?CPU 使用率看着不高(比如只跑了 30%),但延迟爆炸、P99 抖成锯齿、吞吐上不去。 而且这种抖动是”整进程冻结”式的 —— 所有在跑的 goroutine 一起卡住,包括那些本来在干正事的。

更要命的是:GC 是并行标记,它需要所有 P 一起上。P 有 64 个,GC 一开就瞬时拉起 64 路并行,瞬间把配额烧穿,触发 throttle。于是 GC 越频繁,被 throttle 越狠,延迟越差 —— 形成正反馈。这就是为什么这类问题往往在”内存分配一多”的时候突然爆发。

怎么解决?三档:

  1. 显式设置 GOMAXPROCS(环境变量或 runtime.GOMAXPROCS(n)),让它匹配你的 CPU limit。这是最直接的。
  2. 用第三方库自动设置(如 go.uber.org/automaxprocs),它在启动时读 cgroup 配额算出一个合理值,然后调 runtime.GOMAXPROCS。这在很长一段时间里是生产环境的标准做法。
  3. 干脆不设 CPU limit,只设 CPU request —— 让程序能用机器上的空闲算力,只在机器繁忙时才按 request 的权重被约束。这是很多团队的最终选择(代价是失去了硬上限保护)。

演进: 较新的 Go 版本(1.25 起)开始把这件事内置了 —— 运行时在 Linux 上会读取进程所在 cgroup 的 CPU 带宽限制,如果它低于可见的逻辑 CPU 数,就把 GOMAXPROCS 默认取这个较低的限额;并且在限额发生变化时(容器被动态调整)会周期性更新。但这套自动行为有两个重要边界,务必记住:

  • 它只认 CPU limit,不认 CPU request。 只设 request 的容器,运行时无从推断。
  • 一旦你手动设置了 GOMAXPROCS(环境变量或 runtime.GOMAXPROCS 调用),这套自动逻辑就全部失效。 也就是说,那个 automaxprocs 库如果你还在用,它反而会”锁死”你的配置。

一个反直觉的结论: 在 CPU limit 很小的容器里,把 GOMAXPROCS 从 64 调到 2,常常是延迟和吞吐量同时变好。因为 P 少了 → 线程少了 → throttle 少了 → GC 的并行风暴没了 → 缓存局部性还变好了。少即是多,在这里是字面意思。

最后一个提醒

runtime.GOMAXPROCS(n) 可以在运行时动态调用,但它会触发一次 stopTheWorld 来重建 P 的数组。千万别在热路径上、在 HTTP handler 里调它。 它是一次性的启动配置,不是一个可以随手拧的旋钮。


子主题十三:调度延迟的观测与诊断——三种武器,四种病症

讲了这么多机制,最后一个问题最实际:我的线上程序调度出问题了,我怎么知道?用什么看?

武器一:GODEBUG=schedtrace(轻量,可常开或灰度开)

GODEBUG=schedtrace=1000 ./myserver
# 加上 scheddetail=1 可以看到每个 P 的详情(输出量大)

每秒往 stderr 打一行,大致长这样(字段名以你实际版本为准):

SCHED 1000ms: gomaxprocs=8 idleprocs=5 threads=23 spinningthreads=1
              idlethreads=14 runqueue=0 [0 0 1 0 0 0 2 0]

怎么读:

字段含义异常信号
gomaxprocsP 的数量和容器 CPU limit 不匹配 → 子主题十二的坑
idleprocs空闲的 P长期等于 gomaxprocs → 没事干;长期为 0 且 runqueue 高 → 算力不够
threadsOS 线程总数持续几百上千 → 大量阻塞 syscall / cgo
spinningthreads自旋中的 M长期很高 → 在白白烧 CPU 找活
runqueue全局队列长度长期非 0 → 供大于求,或者有 P 长期被占
[0 0 1 ...]每个 P 的本地队列长度长期严重不均 → 窃取没跟上,检查是不是有 G 被 LockOSThread 钉住

threads 这个字段是最重要的一个。 一个健康的纯 Go 网络服务:goroutine 几万,threads 应该在几十量级。如果 threads 跟着 goroutine 一起涨,那一定是有东西在占 M(阻塞 syscall、cgo、文件 I/O)。

成本很低,可以在生产环境灰度开启来看趋势。

武器二:pprof(剖面,按需开)

三个和调度直接相关的 profile:

1. goroutine profile —— 找泄漏和风暴

curl http://host/debug/pprof/goroutine?debug=2   # 全量,带完整栈

debug=2 的输出会把每个 goroutine 的完整调用栈和状态([chan receive]、[IO wait]、[semacquire]、[select]、[runnable]…)都打出来。按栈分组数一数,你立刻能看出”几万个 goroutine 卡在同一行” —— 那就是问题所在。

2. block profile —— 找”卡在哪”

runtime.SetBlockProfileRate(1)  // 需要先开启,默认关闭(有开销)

它记录的是:goroutine 因为同步原语(channel、锁、select、cond)而阻塞了多久、阻塞在哪一行。 这是找出”锁竞争""channel 设计不合理""慢消费者”的利器。注意它不记录网络 I/O 阻塞(那是 netpoll 的事)。

3. mutex profile —— 找锁竞争

runtime.SetMutexProfileFraction(100)  // 采样比例

告诉你哪些 sync.Mutex 上积累了最多的等待时间。

武器三:runtime/trace(精确时序,最重也最准)

pprof 是”聚合统计”,看的是”哪里多”;trace 是”逐事件时间线”,看的是**“什么时候发生了什么”**。

f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// 或者生产环境用 HTTP 抓: curl 'http://host/debug/pprof/trace?seconds=10'

然后 go tool trace trace.out 打开,重点看两个视图:

Proc 视图:每一行是一个 P,横向是时间。每个色块是”这个 P 这段时间在跑哪个 G”,块之间的空白就是 P 在空转(找不到活或者被抢占)。

Goroutine 视图:每一行是一个 G 的完整生命线,分段显示它处于什么状态。其中最关键的一段是:

“Runnable” 灰色段 = 这个 G 已经可以被执行了,但一直没轮到 CPU。这段的长度,就是调度延迟。

这是本章前面所有机制(本地队列、窃取、抢占、sysmon、netpoll 唤醒)最终要优化的东西。所有设计的好与坏,最后都折现成这段灰条的长度。

四种典型病症 → 病因对照表

病症一:goroutine 泄漏

  • 症状:goroutine 数单调增长,GC 后不回落;goroutine profile 里大量 G 卡在同一行
  • 典型病因:channel 只 send 没人 receive(或反过来)、context 没取消导致下游 goroutine 永远等下去、time.Ticker 没 Stop、HTTP 响应体没 Close 导致连接和它的 goroutine 一起挂着
  • 抓法:debug=2 的 goroutine profile,按栈分组

病症二:goroutine 风暴

  • 症状:goroutine 数瞬间暴涨、内存涨、调度延迟一起涨、然后可能 OOM
  • 典型病因:无上限地 go func()。来一个请求开一个 goroutine,下游一慢,goroutine 就堆积
  • 药方:goroutine 是便宜,但不是免费。永远给并发设上限 —— worker pool、带权重的 semaphore、或者至少用 errgroup.SetLimit。这是 Go 并发编程最常被忽略的一条纪律

病症三:goroutine 饿死

  • 症状:trace 里大量 G 有很长的 Runnable 灰段;schedtrace 里 runqueue 长期非 0
  • 病因分类:
    • 算力真的不够(GOMAXPROCS 太小 / 核不够)→ 加资源
    • 有 G 长时间不让出(死循环、超长 GC 辅助标记)→ 看是不是 cgo
    • 有 P 被 runtime.LockOSThread() 钉住或者陷在 cgo 里 → P 数实际变少了
    • cgroup throttle(子主题十二)→ 看容器 CPU 指标里的 throttling 计数

病症四:线程数暴涨

  • 症状:schedtrace 的 threads 持续上涨;内存也在涨(每个 M 有自己的栈)
  • 病因:阻塞式 syscall / cgo / 文件 I/O 太多。或者在用 getaddrinfo 做 DNS 解析(走 cgo)而没用纯 Go 解析器
  • 药方:池化这些调用、换纯 Go 实现、或者接受它并把 GOMAXPROCS 留出冗余

一条实操建议

给调度延迟设一条可观测的基线。 正常的服务,调度延迟(Runnable → Running)应该非常短;一旦它开始抬升,往往是”算力不够”或”有人在占着不让出”的最早信号 —— 比 CPU 使用率和响应时间都更早。把它做成仪表盘上的一条线,你就能在用户投诉之前发现问题。


子主题十四:业界对照补强——Erlang、Java Loom、C++ 协程的三条岔路

有了前面十三个子主题的厚度,我们再回头看别人的方案,能看到更深的东西:三种方案的差异,根源不在”谁的设计更好”,而在”谁来负责让出 CPU”。

Erlang/BEAM:归约计数 —— VM 天然拥有的上帝视角

Erlang 的抢占是按归约计数(reduction counting):每个进程有一个”归约预算”,每执行若干次函数调用(或等价的归约单位,默认量级是几千次)就强制让出。

为什么 Erlang 能做到这么细的粒度? 因为 BEAM 是一个虚拟机 —— 它逐条解释/执行自己的指令,而”每执行一条指令就减一次计数”这件事,在 VM 的指令循环里是几乎免费的。VM 天然站在上帝视角,它知道每个进程执行了多少。

Go 为什么做不到? 因为 Go 编译成原生机器码,直接跑在 CPU 上,没有那个指令循环,没有那个上帝视角。你想数”执行了多少条指令”,只能:

  • 在函数边界插检查(协作式抢占,复用栈检查,几乎免费,但粒度粗)
  • 或者借操作系统的时钟和信号从外部打断(异步抢占,能覆盖死循环,但成本高)

这就是语言实现方式对调度设计的硬约束。 Go 选了原生编译(快、无 VM 开销),就必须在抢占粒度上付出代价;Erlang 选了 VM(慢一些,但完全可控),就得到了极细的抢占粒度和极强的实时性可预测性。

再看一个衍生差异:Erlang 每个进程独立堆、无共享,所以 GC 是 per-process 的,没有全局 STW;Go 是共享堆,必须靠”极短的 STW + 并发标记 + 写屏障”来补(第五、六章讲过)。“无共享”让 Erlang 的调度和 GC 都简单得多,代价是进程间通信只能靠拷贝消息。 又是一次取舍。

Java 虚拟线程(Loom):mount/unmount 与”钉住”问题

Loom 的思路和 goroutine 高度一致:

  • 虚拟线程(Virtual Thread)挂在**载体线程(carrier thread)**上
  • 阻塞时 unmount:把虚拟线程的 continuation(一段可延迟拷贝的栈 chunk)从载体线程上摘下来,载体线程去跑别的虚拟线程
  • 就绪时 mount:找个载体线程把 continuation 装上继续跑
  • 默认调度器是 ForkJoinPool —— 又是 work-stealing,载体线程数默认等于 CPU 核数

和 Go 的差别在哪?三个,都很关键:

1. Pinning(钉住)—— 和 Go 的 cgo 是同一个病

虚拟线程在 synchronized 块内、或在某些 native/FFM 调用中发生阻塞时,无法 unmount,载体线程被钉死。这和 Go 里 “cgo 调用占住 M” 完全同构。

连解法都一样:换用可让出的原语。Java 侧是把 synchronized 换成 ReentrantLock;Go 侧是少用 cgo、用纯 Go 实现。

为什么会有这种同构? 因为两者的本质矛盾是同一个:“一段不受运行时控制的原生代码(C 代码 / JVM 的 monitor 实现)在执行时,运行时没法把它从载体上摘下来。” 这不是设计缺陷,是”要和既有生态共存”必须付的税。

2. Java 虚拟线程没有时间片抢占

这是个大差异。虚拟线程的让出只发生在”可阻塞操作”上,运行时不会因为”你跑够久了”而强制把你换下来。也就是说:一个纯 CPU 密集的虚拟线程会一直占着载体线程,直到它自己遇到一个阻塞点。

Go 有异步抢占(子主题十一),所以一个死循环不会独占 P。这是 Go 在”公平性上界”上的一处真实优势。

3. 历史包袱

ThreadLocal、synchronized 的语义、海量的既有同步代码 —— Java 是后发者,却被自己的过去拉着。Go 是白纸起家,goroutine 从第一天起就是唯一的一等公民。

C++20 协程:无栈 vs 有栈,一场关于”控制”的交易

这是两条完全不同的技术路线:

Go(有栈 goroutine):

  • 切换 = 换栈指针 + 保存/恢复寄存器。心智模型接近线程
  • 优点:任意函数、任意调用深度都能挂起,调用方完全无感知
  • 代价:运行时必须管理可增长的栈(搬栈、栈边界检查)、GC 要能精确扫描这些栈、要写平台相关汇编

C++20(无栈协程):

  • 编译器把协程函数改写成状态机,挂起点是显式的 co_await
  • 协程帧的大小编译期往往算不出来,通常要堆分配一帧(编译器有 HALO 优化可以消除一部分)
  • 优点:零运行时魔法、能与既有内存模型和所有权体系精细配合、生命周期完全由程序员掌控
  • 代价:函数染色(coloring problem)

函数染色是什么? 在无栈协程里,一个函数能 co_await 的前提是——它自己必须是协程。于是协程会沿着调用链向上传染:你的 fetchUser() 用了 co_await,那调用它的 handleRequest() 也必须是协程,调用 handleRequest() 的也得是……一条链全被染成同一个颜色。而且同步函数和异步函数之间不能直接互调。

Go 没有这个问题(因为任意深度都能挂起,函数不需要改签名),但换来的是另一种代价:

你从 Go 代码上看不出哪里会让出。

mu.Lock()
m[key] = value      // 看着是连续的
mu.Unlock()

这三行中间会让出吗?会 —— Lock() 可能阻塞,甚至 map 赋值可能触发 GC 辅助标记。但代码上你看不出来。所以 Go 里”跨越可能阻塞点的不变量”必须格外小心,必须显式用锁或 channel 保护。 这恰恰解释了为什么 Go 反复强调”不要靠直觉判断共享数据的安全性”。

取舍一句话:

  • Go:用”运行时复杂度 + 一点可预测性”,换”心智简单 + 任意深度挂起 + 无函数染色”
  • C++:用”心智复杂度 + 函数染色”,换”零抽象成本 + 完全控制内存与生命周期”

没有谁更好,只有你的场景更在乎哪一头。在乎开发效率和大规模并发 → Go;在乎极致性能、内存布局和与既有 C++ 代码共存 → C++20 协程。

收束:四种”谁来让出”的哲学

方案谁来负责让出粒度代价
Erlang/BEAMVM 自动(归约计数)极细(几千次调用)需要 VM,单进程执行慢一些
Go运行时强制(协作式 + 异步信号)粗(时间片量级)需要平台相关汇编和信号处理
Java Loom阻塞点协作(无时间片抢占)只在阻塞点CPU 密集任务会钉住载体线程
C++20程序员显式声明(co_await)完全可控函数染色,心智负担重

看清这张表,你就不会再问”哪个语言的并发最好”这种问题了 —— 你会问的是:这个方案把”让出”的责任放在了谁身上,以及我为这个选择付了什么代价。 这才是精通。


业界对照

Erlang: 每个进程独立栈、独立堆,调度器也是”多核调度器 + 可抢占”,且 Erlang 的抢占是”按归约计数(reduction)“——每个进程跑满 N 次函数调用就强制换,抢占粒度比 Go 更细、更公平。Erlang 语言本身(无共享、不可变)让它的调度天然更简单。

Java(虚拟线程/Loom): 虚拟线程挂在”载体线程(carrier thread)“上,阻塞时(mount/unmount)让出载体线程,思路和 goroutine 高度一致。Java 的调度靠 JVM 的延续(continuation),抢占点主要在”可阻塞操作”上。Java 是”后发”,但背负着庞大的历史包袱(大量同步代码、ThreadLocal、锁的语义)。

C#(async/await + Task): 用编译器状态机 + 线程池实现”轻量并发”,但没有”可移动栈”这种底层魔法;async 方法会在 await 处被拆成状态机,阻塞语义和 goroutine 的”看起来像同步、实际会挂起”不同,需要程序员显式标记 async/await。

C++(协程 C++20): 无栈协程(stackless coroutine),编译器状态机,配合调度器。和 Go 的”有栈 goroutine”是两条路线:有栈协程切换就是换栈指针(更接近线程、心智简单),无栈协程切换是状态机跳转(更省内存、但心智复杂)。Go 选了有栈,是因为它和”百万 goroutine + 可移动连续栈”的目标更契合。


版本演进小结

版本关键变化
早期GM 模型,全局 runqueue + 全局锁
1.1引入 P,改 GMP 模型,本地队列 + 工作窃取
1.14基于信号的异步抢占,解决死循环霸占 CPU
持续netpoll 打磨、调度延迟优化、P 的细节调整

主线就一条:Go 调度器的一切演进,都在回答”如何让海量轻量任务的调度,既快(无锁本地)、又公平(工作窃取 + 抢占)、还不浪费 OS 资源(netpoll 挂起阻塞 G)“。


本章思考题

【思考题】

  1. P 到底是什么?为什么说”P 是 Go 调度器最独特的设计”?它解决了 GM 模型的什么问题?

  2. 为什么工作窃取时,“自己从队列头部取、别人从队列尾部偷”?这个细节为什么重要?

  3. Go 1.14 之前的抢占为什么会导致死循环霸占 CPU?异步抢占是怎么解决的?

  4. “一个连接一个 goroutine”为什么不会变成”百万连接百万线程”?netpoll 在中间扮演什么角色?

  5. _Gwaiting 和 _Grunnable 的本质区别是什么?为什么说”调度器的开销只和可运行的 G 数量有关”?

  6. P 的本地队列满了会发生什么?为什么 Go 选择”一次搬一半到全局队列”,而不是”只把新来的那个 G 塞进全局队列”?

  7. 为什么调度器查全局队列是”每 N 次调度才查一次”,而且查到了也只取 长度/GOMAXPROCS + 1 个?

  8. 一个 goroutine 进入阻塞式系统调用时,M 和 P 各自发生了什么?为什么”被释放的是 P 而不是 M”?

  9. 为什么 cgo 调用和文件 I/O 是”goroutine 很便宜”这个说法的例外?

  10. netpoll 挂起一个 G 时,为什么必须”先把 G 置为 _Gwaiting,再把 G 指针登记到 pollDesc 上”?反过来会出什么事?

  11. Go 的异步抢占号称”异步”,但 G 仍然要在安全点才真正让出。这两句话是否矛盾?异步抢占到底异步在哪一步?

  12. 一个 Go 服务跑在 64 核机器上、Kubernetes 容器 CPU limit 设为 2。请问默认 GOMAXPROCS 是多少?会出现什么现象?根因是什么?怎么修?

  13. 你的 Go 服务延迟突然变大,但 CPU 使用率只有 30%。你怀疑是调度问题,请说出你会依次用哪些工具、看哪些指标、以及每种指标异常分别指向什么病因。

  14. 对比 Erlang 的归约计数抢占、Java 虚拟线程的阻塞点协作、C++20 无栈协程的显式挂起:Go 的抢占方案处在这张图的哪个位置?它为什么不做成 Erlang 那样?

【参考答案】

  1. P 是”逻辑处理器/执行资源”:它维护本地 runqueue,数量由 GOMAXPROCS 决定。GM 模型的问题在于全局 runqueue 要加全局锁,M 越多锁竞争越激烈,调度器自己成了瓶颈。P 把”共享队列”拆成”每 P 一个本地队列”,本地队列 P 独占、无锁;同时把内存分配缓存(mcache)也挂在 P 上,最大化局部性。P 的本质是”把会竞争的共享资源,按稳定执行单元拆开,让绝大多数操作无锁本地完成”。

  2. 因为”两端操作”能最小化冲突:窃取者(从尾部)和被窃取者(从头部)操作队列的不同端,几乎不打架;同时能保护局部性——被窃取者最近在跑的、缓存还热的 G 在头部,不容易被偷走,被偷的往往是尾部较冷、还没来得及跑的 G。这是无锁 work-stealing dequeue 的经典设计。

  3. 1.14 之前,抢占只在”函数调用边界”(函数序言的检查点)发生。一个不调用任何函数的死循环(如 for {})永远不会到达检查点,于是永远霸占 M,别的 goroutine 饿死,甚至阻塞 GC。1.14 的异步抢占用操作系统信号(SIGURG)定期打断正在运行的 goroutine,信号处理里只设标志,等 goroutine 到安全点再真正让出。这样死循环也能被中断,调度延迟和 GC 停顿都大幅下降。

  4. 因为阻塞的 goroutine 会被 netpoll 从 M 上”摘下来挂起”,不占用 OS 线程。底层 socket 全注册到 epoll/kqueue/IOCP,goroutine 在 Read() 上阻塞时,实际是”挂起 + 注册到 netpoll”,M 转去跑别的 G;epoll 报告有数据时,netpoll 再唤醒它放回队列。所以一个连接的代价只是一个挂在 epoll 上的 goroutine,不是一个线程。这是”百万连接、百万 goroutine、几十个线程”的完整机制。

  5. _Grunnable 的 G 在调度器的队列里(本地 runq、全局 runq 或 runnext),调度器每时每刻都在为它付费:它占队列长度、参与工作窃取的遍历判断、影响负载均衡。_Gwaiting 的 G 根本不在任何队列里,它只是某个等待结构(channel 的 sendq/recvq、timer 堆、Mutex 等待队列、pollDesc 的 rg/wg)里挂着一个指针,调度器完全”看不见”它。所以调度器的开销只和”可运行的 G”数量正相关,和”活着的 G 总数”无关——100 万个 goroutine 里 99.9 万在 channel 上等着,调度器眼里就只有 1000 个。这是”百万 goroutine”能成立的另一半原因(另一半是栈小)。

  6. 本地队列(定长环形数组)加 runnext 都满时,Go 走 runqputslow:把本地队列中一半的 G 加上新 G 一起搬到全局队列。只搬一个新 G 的话,每次满都要加一次 sched.lock、做一次链表操作,锁成本完全没摊还;一次搬一半,加锁次数降两个数量级,每个 G 均摊的锁成本几乎可忽略。同时给本地留下一半余粮,保住局部性——要是全搬空,这个 P 下一轮就得去全局队列讨饭,白瞎了缓存热度。核心手法是”批量化对锁竞争的降维打击”。

  7. 因为查全局队列要加 sched.lock,而查本地队列是无锁的;每次调度都查全局,本地队列的无锁优势就被全局锁吃回去了,所以设一个周期把它压到可忽略。取的时候按 runqsize/GOMAXPROCS + 1 分摊,是为了防止”第一个饿的 P 把全局任务全私有化,其他 P 仍然空着”——那只是把”全局饥饿”换成了”全局被一人独占”。分摊取让每个饿的 P 只拿自己那份,天然均衡。

  8. entersyscall:保存 G 的 PC/SP,G 置 _Gsyscall,P 置 _Psyscall 但仍记在 m.oldp 上(M 不主动放手,因为绝大多数 syscall 很快,释放/重夺 P 的成本会超过 syscall 本身)。若真的卡住,sysmon 周期性 retake 时发现该 P 处于 _Psyscall 超时,就 handoffp 把 P 摘下来交给空闲 M,或者 startm 新建一个 M 接手。exitsyscall 回来时:原 P 没被抢走就直接 acquirep 拿回(零成本);被抢走就从空闲 P 列表拿一个;都没有就把 G 丢进全局队列、M 自己 stopm 睡下。被释放的只能是 P 不能是 M——M 已经陷在内核里了,内核眼里只有那个线程,运行时对它毫无办法,没法让它”顺便跑点别的”。这正说明 P 的本质是”一份可以脱离 M 存活的执行配额”。

  9. 因为 sysmon 只能救走 P,救不走 M。一个卡住的 syscall 会有一个 M 全程陪绑,M 数随阻塞调用数增长,而每个 M 有自己的 g0 栈、内核侧开销和管理成本,涨到上限还会直接 panic(thread limit exceeded)。cgo 更特殊:G 进入 C 代码后不算 _Gsyscall,运行时管不着、sysmon 也救不了,M 被彻底钉住。文件 I/O 同理——普通文件 fd 在 epoll 里语义不完整,不走 netpoll,读写走阻塞 syscall 路径。这也是为什么推荐用纯 Go 的 DNS 解析器(走 netpoll)而不是 cgo 的 getaddrinfo(占 M)。

  10. 反过来会”丢失唤醒”。如果先登记 pd.rg = g 再检查事件再睡,那么在”登记完”和”真正睡下”之间的窗口里事件到达,netpoll 那边 CAS 把 pd.rg 改成 pdReady 并试图唤醒,可这个 G 还没睡,唤醒信号就丢了;等它真睡下,再没人叫它。Go 把顺序锁死:先原子地把状态改成 _Gwaiting(宣告”我要睡了”),再调用 unlockf(netpollblockcommit)把 G 指针写进 pd.rg(登记”睡在这儿了”)。这样 netpoll 唤醒路径只能看见已登记好的等待者,要么 CAS 成功把 G 拿走(它取消睡眠直接返回),要么它已睡下就正常唤醒,两条路都不丢。这是”先声明意图、再登记”消除竞态的通用手法。

  11. 不矛盾。“异步”指的是信号的投递是异步的——内核可以在任意两条机器指令之间打断这个线程;但”让出”仍然必须发生在安全点。原因是抢占可能发生在任意指令处,此时 G 的现场是寄存器里的中间值,而 Go 是精确 GC,必须能解析栈和寄存器才能安全扫描。所以 asyncPreempt 那段汇编的唯一任务是把所有寄存器规规矩矩保存到栈上固定位置,并留下 unwind 元数据,然后走到安全点才 mcall(preemptPark) 真正让出。类比:交警把你拦下来是异步的,但你还是得把车开到路边停好,不能停在马路中间。

  12. 历史版本里默认 GOMAXPROCS = 64(运行时只读机器的逻辑 CPU 数,不看 cgroup 的 CPU quota)。现象:64 个 P、几十个线程,在一个 100ms 的 cgroup 周期里几毫秒就把 200ms 的 CPU 配额烧光,然后被整进程 throttle,剩下的时间全部冻结;表现为 CPU 使用率不高(比如 30%)但延迟爆炸、P99 锯齿抖动、吞吐上不去;GC 是并行标记,一开就瞬时拉起 64 路,更容易触发 throttle,形成”分配多→GC 多→throttle 狠→延迟差”的正反馈。根因是”并行度上限”和”吞吐量上限”两个模型错位:GOMAXPROCS 限制同时跑几个,CPU limit 限制周期内用多少 CPU 时间。修法:显式设 GOMAXPROCS 匹配 limit;或用 automaxprocs 这类库在启动时读 cgroup 配额自动设置;或干脆不设 CPU limit 只设 request。注意较新的 Go(1.25 起)已内置容器感知:读 cgroup 带宽限额取较小值并会周期更新,但它只认 CPU limit 不认 request,且一旦手动设置 GOMAXPROCS(环境变量或 runtime.GOMAXPROCS)就全部失效。

  13. 三档依次来:① GODEBUG=schedtrace=1000 看趋势——runqueue 长期非 0 且 idleprocs=0 是算力不足;threads 持续几百上千指向阻塞 syscall/cgo;idleprocs 高而 runqueue 也高说明有 P 被 LockOSThread 或 cgo 钉住;各 P 的 runqsize 长期严重不均说明窃取没跟上。② pprof:/debug/pprof/goroutine?debug=2 查 goroutine 数量和卡在哪一行(数量单调涨=泄漏,瞬间暴涨=风暴,几万个卡同一行=该处设计有问题);开 SetBlockProfileRate 查同步原语阻塞时长(锁竞争、channel 慢消费者);SetMutexProfileFraction 查 mutex 竞争。③ runtime/trace + go tool trace 看精确时序:Goroutine 视图里 G 的 “Runnable” 灰段长度就是调度延迟;Proc 视图里 P 行的大片空白是空转。四种典型病症:泄漏(忘了 close/cancel)、风暴(无上限 go func,缺并发上限)、饿死(算力不足 / 死循环 / P 被钉住 / cgroup throttle)、线程暴涨(阻塞 syscall 或 cgo 太多)。

  14. Go 处在”运行时强制”这一格:以协作式抢占(函数边界的栈检查,几乎零成本)为主力,以异步信号抢占(SIGURG)为兜底保险丝,抢占粒度是时间片量级。Erlang 是”VM 自动”:靠归约计数,粒度极细(几千次调用),因为 BEAM 逐条执行自己的指令,计数几乎免费。Java 虚拟线程是”阻塞点协作”:只在可阻塞操作处让出,没有时间片抢占,CPU 密集任务会钉住载体线程。C++20 是”程序员显式声明”:完全可控但带来函数染色。Go 不做成 Erlang 那样,是因为 Go 编译成原生机器码直接跑,没有 VM 的指令循环,就没有那个”上帝视角”,数不了执行了多少指令;想数只能在函数边界插检查或借 OS 信号从外部打断。这是”选择原生编译”必然付的代价——换来的是无 VM 开销的执行速度,代价是抢占粒度较粗。