Skip to content

案例研究:Go 如何组合三种模式来调度 Goroutine

这是什么。 大多数模式文档孤立地讲解一个模式。本案例研究反其道而行:它剖析单个真实系统——Go 运行时调度器(GMP 模型)——如何组合 三种 模式,使数百万 goroutine 运行在少数几个 OS 线程上,且快路径无锁。每一处针对单个模式的论断都链接到固定 commit 上的源码;组合关系的论证则由 Go 自己的设计文档支撑。

Go 解决的问题

goroutine 本应是"自由"的:go func() 应当几乎零开销,一个程序应能轻松创建数十万个。但 OS 线程很昂贵(兆字节级的栈、内核调度开销),所以 Go 无法把一个 goroutine 映射到一个线程。它必须把许多 goroutine 多路复用到少量线程上——而且不能用一把每个 go 语句都要争抢的全局锁。

Go 的答案是 GMP 模型Goroutine 运行在 M(OS 线程)上,每个 M 由一个 P(逻辑处理器)驱动,而每个 P 拥有一个本地运行队列。要让它快——创建廉价、负载均衡、争用极小——需要三种模式协同工作。它们单独看都不新颖;真正有启发性的是它们如何组合

问题模式Go 如何回答
一个线程如何挑选接下来运行什么?协作式调度schedule() 循环,找到一个可运行的 G,而 G 在安全点让出
如何在没有全局锁的前提下让所有线程都忙起来?工作窃取空闲的 P 从另一个 P 的队列里窃取 goroutine
如何避免每次复用都重新分配?对象池per-P 的 sync.Pool 分片无锁地交还缓存对象

模式 1 —— 协作式调度:每线程的循环

每个 M(线程)运行 schedule(),它是运行时的心脏。它找到下一个可运行的 goroutine 并切换过去。常见情况下,goroutine 在安全点(函数序言、channel 操作、系统调用)协作式地让出;自 Go 1.14 起,运行时支持基于信号的异步抢占,所以即便一个 goroutine 卡在没有安全点的紧循环里,也仍能被停下。下面的调度循环是其中协作的那一半——你读它,是为了理解一个线程如何挑选它的下一个 goroutine。

go
func schedule() {
  mp := getg().m
  // ...guards: not holding locks, not in cgo, handle locked g...
  // find a runnable goroutine (local queue, global queue, or steal),
  // then execute it on this M.
}

这个循环的职责是"在这个线程上接下来运行什么"。它先检查 P 自己的本地运行队列(廉价、常见的情况),再看全局队列,只有当两者都空时才求助于下一个模式。(两个供好奇者了解的细节:每第 61 次调度 tick 它会查全局队列,于是一个永不清空本地队列的 P 也不会饿死全局工作;findRunnable 还会轮询网络 poller、并可能运行一个 GC worker。"本地→全局→窃取"这个梗概是主干,而非全貌。)

心智模型

把每个 P 想成一个有自己待办清单(本地运行队列)的工人。schedule() 就是这个工人反复从自己清单上取下一个任务。因为清单是 P-本地的,取任务无需全局锁——这正是给每个 P 一个独立队列的全部意义。

→ 单独了解该模式,见 Cooperative Scheduling

模式 2 —— 工作窃取:无需中央队列的负载均衡

如果每个 P 永远只排空自己的队列,倒霉的 P 可能干坐着空闲,而另一个 P 却超载。一个共享队列能解决均衡,但会重新引入全局锁。Go 的解法是 工作窃取:当一个 P 的本地队列为空时,它尝试从另一个 P 窃取 goroutine。

go
func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool) {
  pp := getg().m.p.ptr()
  const stealTries = 4
  for i := 0; i < stealTries; i++ {
    // walk other Ps in a randomized order; try to grab half of a victim's queue
    for enum := stealOrder.start(cheaprand()); !enum.done(); enum.next() {
      // ...runqsteal from allp[enum.position()]...
    }
  }
}

几个关键设计选择:受害者的遍历顺序是随机化的(这样窃取者不会全都涌向 P0),它窃取受害者队列的一半(这样工作量、以及未来的窃取都被摊薄),且它只在慢路径上发生——当一个 P 自己无事可做时。因此争用是被有意设计成罕见情况的。

心智模型

工作窃取是"空闲的工人主动去帮忙碌的工人"。没有管理者分派工作;一个空手的 P 走到一个随机同事那里,拿走他一半的活儿。快路径(你自己的队列)保持无锁;锁只在罕见的窃取时出现。

→ 单独了解该模式,见 Work Stealing

模式 3 —— 对象池:避免分配的 per-P 缓存

调度数百万 goroutine 意味着不断产生短命的临时对象(fmtencoding/json 等里的缓冲区)。逐个分配再让 GC 回收会主导开销。sync.Pool 用与调度器相同的 per-P 分片思想解决它:每个 P 有自己的池分片,所以 Get/Put 在快路径上无锁。

go
type Pool struct {
  noCopy noCopy
  local     unsafe.Pointer // per-P fixed-size pool, actual type is [P]poolLocal
  localSize uintptr        // size of the local array
  victim     unsafe.Pointer // local from previous GC cycle
  victimSize uintptr
  New func() any           // optional factory when the pool is empty
}

local 字段是一个按 P 索引的数组。运行在 Pi 上的 goroutine 只接触 local[i],所以来自不同 P 的并发 Get/Put 永不争用。victim 字段是一个跨一个 GC 周期的宽限缓冲,用来在 GC 之间平滑复用。

心智模型

sync.Pool 是把调度器的 per-P 哲学应用到内存而非工作上:按 P 分片,让常见情况无锁,只在争用或 GC 时回退到共享/慢路径。在两个子系统里认出同一个分片思想,是深读运行时的标志。

→ 单独了解该模式,见 Object Pool

三者如何组合

启动 go func(),三个模式围绕 P 依次交接:

  1. 协作式调度schedule())排空这个 P 的本地运行队列——处理绝大多数切换的无锁快路径。
  2. 工作窃取stealWork())只在本地队列为空时触发,通过抓取一个随机受害者 P 队列的一半来再平衡负载。
  3. 对象池sync.Pool)按同一个 P 分片,于是调度和用户代码不断产生的临时对象被复用,无需分配、无需全局锁。
text
go func()  ──► enqueue G on current P's local run queue


        schedule() drains P-local queue   ◄── fast path, lock-free
                         │  (empty?)

        stealWork(): grab half of a random P's queue  ◄── slow path, rare

        (running goroutines reuse temporaries via)

        sync.Pool local[P]  ◄── per-P shard, lock-free Get/Put

统一这一切的思想是 per-P 所有权:给每个逻辑处理器它自己的运行队列和它自己的池分片,于是常见情况只接触 P-本地状态、无需锁。全局结构(或锁)只出现在罕见的慢路径上——P 跑空时的窃取,或 GC 下的共享池。去掉其中任意一个模式它都会崩塌:没有协作式的 per-P 循环就没有无锁快路径;没有窃取,负载不均会饿死某些 P;没有 per-P 池,分配与 GC 会主导调度器本想让其廉价的那些工作负载。

架构推断

把这些模式描述为一个有意组合的设计——以 per-P 所有权为统一原则——依据的是 Go 自己的调度器设计文档(见延伸阅读),而非任何单个源码文件。针对单个模式的代码链接是直接的源码证据;而"被有意组合在一起"这一论断,由那些设计层级的材料支撑。

生产验证

所有源码链接均固定到 Go commit f5cdf4745455415c7a43cfc7d925214d4511489b。针对单个模式的论断属于 source-code(L1);组合关系则由设计层级的证据支撑——运行时自己的源码内设计注释(source-code)与官方文档(official-doc)。

模式 / 论断来源证据类型在 GMP 调度中的角色
协作式调度proc.go#L4143-L4200source-codeschedule()——找到并运行下一个 goroutine 的 per-M 循环
工作窃取proc.go#L3836-L3903source-codestealWork()——空闲 P 窃取一个随机受害者 P 队列的一半
对象池sync/pool.go#L52-L97source-codePool 结构体——per-P 的 local 分片带来无锁的 Get/Put
组合(有意为之)proc.go#L25-L36 (scheduler design comment)source-code运行时自己的头部注释,定义了这三种模式所服务的 G/M/P 模型
Goroutine 与并发Effective Go — Concurrencyofficial-doc官方对 goroutine 多路复用到 OS 线程的解释

要点

  • 模式很少单独出现。 一个运行时调度器同时需要一个控制流模式(协作式调度)、一个均衡模式(工作窃取)和一个内存模式(对象池)——而且它们围绕 P 交接。
  • 一个思想能统一一个子系统。 per-P 所有权是本地运行队列 sync.Pool 分片背后的同一个原则。在两处认出同一个思想,正是深读源码的收获。
  • 把快路径设计成无锁;让争用成为罕见情况。 Go 调度器之所以快,不是因为窃取快,而是因为窃取几乎从不发生——P-本地的快路径主导了一切。
  • 这与 React Fiber 呼应。 两者都协作式地调度工作(在安全点让出而非硬抢占)。跨语言对比这两个协作式调度器,能磨利对该模式的理解。

延伸阅读

一条从"我读过了"走向"我能在任何地方认出这些模式"的路径:

  1. 先从设计注释开始 —— 运行时自己的 proc.go 中的 G/M/P 头部注释 定义了该模型,并解释了为什么调度器状态要按 P 分布。先读这个;其余源码随后都在印证它。
  2. 掌握并发模型 —— Effective Go:Concurrency 把 goroutine 框定为廉价的、多路复用到线程上的执行体。
  3. 然后按这个顺序读源码 —— per-P 循环(schedule)→ 空闲 P 如何再平衡(stealWork)→ 把同一个 per-P 思想应用到内存(sync.Pool)。
  4. 跨语言对比 —— 阅读 React Fiber 案例研究,把它的协作式调度器与 Go 的对比。同样的模式,不同的约束(浏览器帧预算 vs. OS 线程)。
  5. 练习这种识别力 —— 打开下面三个模式页,在你熟悉的另一个系统里寻找"按 P 分片、无锁快路径、罕见慢路径"。
  6. 读运行时自己的笔记 —— runtime/HACKING.md 文档用维护者自己的话解释了调度器的约定(P、M、work、parking)。

延伸学习这些模式

基于 MIT 许可证发布。