Go运行时调度器

了解Go运行时对goroutine的调度,对于深入分析和理解并发程序还是很有帮助的。

当操作系统的线程切换到另一个线程时,CPU会执行一个操作,叫作上下文切换(context switch),操作系统会在中断、系统调用时执行线程上下文切换。线程上下文切换是一种昂贵的操作,因为操作系统需要将用户态转移到内核态,保存要切换线程的执行状态,也就是将一些重要寄存器的值和进程状态保存在线程控制块数据结构中。当恢复线程的运行时,需要将这些状态加载到集群中,从内核态转移到用户态。想想就比较复杂、耗时,如果又涉及进程上下文切换,就更加耗时了。goroutine的调度是由Go运行时控制的,每个编译的Go程序都会附加一个很小的Go运行时,负责内存分配、goroutine 调度和垃圾回收。goroutine会和某个线程绑定,它是用户态的,并且初始的栈也比较小,所以它的上下文切换开销比较小,大致上,你可以认为goroutine的上下文切换开销是线程上下文切换开销的十分之一。当然,这个数值会随着一些应用场景和CPU架构的不同而不同,我们可以大致粗略估计成这个数量级。

最早的Go运行时(1.0版以下)采用GM模型,随着Go运行时的优化,改成了GPM模型。如图: gpm

  • G:表示 goroutine,存储了与goroutine相关的信息,比如栈、状态、要执行的函数等。
  • P:表示逻辑processor,有人把它和CPU处理器的概念混在一起,这是不对的,它和CPU的处理器没有半点关系。P负责把M和G捏合起来,让一系列的goroutine 在某个M上顺序执行。在默认情况下,P的数量等于CPU逻辑核的数量,当然,你也可以使用runtime.GOMAXPROCS改变它,尤其是在I/O敏感的场景下。有些数据结构会利用P的特性,实现per-P的方式以避免使用锁来提升性能,因为属于同一个P的gorouline没有数据竞争的风险。如上图所示,每个P都有一个自己的本地goroutine队列。
  • M:表示执行计算资源单元,Go会把它和操作系统的线程--对应。只有在P和M绑定之后,才能让P的本地队列中的goroutine 运行起来。

为什么早期golang要从GM模型转为GPM模型?

因为GM 模型在多核时代“卡死”在调度和系统调用上,扩展性太差。

最早的 Go 调度模型是:

G:goroutine

M:machine,本质是 OS 线程

👉 G 直接绑定到 M 上运行

看起来很直观:

一个 goroutine 找一个线程跑就完了。

但问题也正是出在这里。

1️⃣ 全局锁:调度一多直接炸

GM 模型里:

  • 有一个 全局 run queue
  • 所有 goroutine 的调度都要抢 同一把锁

在单核、低并发时代还能忍 多核一来:

  • 线程越多
  • 抢锁越凶
  • CPU 空转、吞吐暴跌

👉 多核性能直接被锁打回单核水平

2️⃣ 系统调用会“拖死”调度器

在 GM 模型中:

  • 一个 G 在 M 上
  • 如果这个 G 发起 阻塞系统调用(IO、sleep、DNS)

那么:

G 阻塞 M 也阻塞 这个线程不能干别的 G

结果就是:

  • 明明还有大量 goroutine
  • 却没有线程能跑
  • runtime 只能疯狂新建线程(很贵) 3️⃣ goroutine 迁移成本极高

在 GM 模型里:

  • G 和 M 强绑定
  • G 想换 M → 成本高、实现复杂

导致:

  • 负载不均
  • 有的线程忙死
  • 有的线程饿死

GPM 模型是怎么救场的?

Go 1.1 左右引入了现在的 GPM 模型:

  • G:goroutine
  • P:processor(调度上下文,关键角色)
  • M:OS 线程

一句话核心设计:

G 不再直接绑定 M,而是先绑定 P,由 P 再交给 M 执行

P 是“神来之笔”

1️⃣ 每个 P 都有自己的本地队列

  • 不再是一个全局 run queue
  • 每个 P 一个 local queue
  • 大幅减少锁竞争

👉 这是 Go 在多核下能线性扩展的根本原因

2️⃣ 系统调用不再拖死调度

GPM 中:

  • G 在 M 上运行
  • 一旦进入阻塞 syscall:
    • M 解绑 P
    • P 马上找别的 M 继续跑 G

结果:

  • 一个 goroutine 阻塞
  • 不会影响其他 goroutine
  • 线程复用率暴涨

3️⃣ Work Stealing(工作窃取)

当某个 P 空闲时:

  • 可以从别的 P 的队列里偷 G

👉 自动负载均衡

👉 不需要人工干预

Go 从 GM 演进到 GPM,是为了解决多核下的锁竞争、阻塞系统调用和调度扩展性问题。 P 的引入解耦了 goroutine 与 OS 线程,使 Go 调度器既高效又可伸缩。