GMP 是怎么来的

GMP 是怎么来的

系列阅读:GMP由来 -> GMP机制 -> GMP源码1(上)-> GMP源码2(下)

术语口径:G=任务、M=线程、P=运行资源与本地队列、schedt=全局调度中心

这篇写给谁

  • 刚接触 Go 并发,知道 goroutine 很轻,但不清楚它为什么轻。
  • 想先建立调度器演化的直觉,再去看结构和源码。

先说结论

Go 的调度器不是一开始就叫 GMP。它是一步步"被问题逼出来"的:

  1. 传统线程模型太重。
  2. 先做了用户态协程调度(GM),减轻了切换成本。
  3. 再上多核并行后,GM 又出现全局锁和缓存失效等瓶颈。
  4. 于是引入 P,形成 GMP + 工作窃取。
  5. 最后补上抢占机制,避免某个 goroutine 长时间霸占 CPU。

1. 传统多线程:为什么"重"

在操作系统里,线程是内核调度单位。它并发能力强,但代价不低,主要有三类成本:

  1. 内存成本高:一个线程栈通常是 MB 级(常见 1~8MB)。
  2. 切换成本高:线程切换要保存/恢复寄存器、PC、栈指针等上下文。
  3. 内核成本高:创建、销毁、调度都要进内核,系统调用频繁。

这套模型在高并发场景下会越来越吃力:线程多了,内存和调度开销都上来。

2. 单线程用户态调度(GM):先把切换变轻

Go 先做的一步,是把"执行任务"拆成两层:

  • M(Machine):内核线程,负责真正跑代码。
  • G(Goroutine):用户态任务,数量可以很多。

直觉上可以理解为:M 是工人,G 是待办任务。一个 M 在用户态切换不同 G,无需进入内核态,内存开销极小。

为了防止在添加或提取 G 时发生混乱,Go 引入了一把全局锁schedlock)。只有拿到锁,M 才能去全局队列里挑选下一个要执行的 G。

在「单枚 M、用户态来回切 G」的阶段,一把全局锁往往还撑得住;为了吃满多核再挂上多枚 M 并行取 G 时,同一把锁和单条全局队列才会迅速变成热点------这就是下一节的出发点。

3. 多线程调度器:并行上来了,瓶颈也上来了

为了实现真正的并行,利用多核 CPU,Go 从"单线程调度"走向"多线程并行"(多个 M 同时工作),新的问题出现了:

  1. 全局锁争抢 :M 多了,大家都抢同一把 schedlock,锁竞争成瓶颈。
  2. 任务交接成本:新 goroutine 往全局队列放,跨线程传递频繁。
  3. 缓存局部性变差:G 在 M1 跑过有热缓存,阻塞回来可能改由 M2 接手,缓存变冷。
  4. syscall 拖累:G 做阻塞系统调用时,M 也可能被拖住,连带其他 G 受影响。

一句话:并行能力提高了,但"交通组织"不够细,导致拥堵。

4. GMP + 工作窃取:P 出场

为了解决上面的拥堵,Go 在 GM 之间加了一个关键角色 P(Processor)

P 的定位可以理解成"资源管家":

  1. 每个 P 有自己的本地队列,M 先从本地拿活。
  2. 本地空了,再去全局队列拿,减少全局锁压力。
  3. 新建 G 优先放当前 P 的本地队列,减少跨线程来回搬运。
  4. 内存缓存(如 mcache)绑定到 P,提升局部性。
  5. 如果某个 M 因 syscall 卡住,会把 P 让出来给其他 M 接手。

并行度上还可记一句:「同时跑满几核」主要由 P 的数量(GOMAXPROCS)兜住 ;M(线程)可以多于 P(syscall、阻塞等场景),但 跑用户 G 时仍是 M 必须先绑上 P

再加上 Work Stealing(工作窃取)

  • 当某个 P 很闲、另一个 P 很忙时,闲的会去忙的那里"偷一半任务"(至少 1 个)。

这样系统整体更均衡,不容易出现"有人忙死、有人闲着"的情况。

还能降低全局热点与锁竞争,热点从「所有人抢一把锁」变成「多数时间在本地无锁/低开销路径上取 G」。

5. 抢占机制:防止一个 G 独占 CPU

即使有 GMP,如果某个 G 是长循环、长计算,不主动让出 CPU,其他 G 还是会饿。

Go 的抢占演进有两步:

5.1 协作式抢占(Go 1.14 前)

  1. 编译器会在函数的开头(或结尾)偷偷插入几行汇编代码,叫 stack check
  2. 调度器发现 G1 跑了太久(比如 10ms),就给这个 G1 打个标记:stackguard0 = stackPreempt
  3. G1 继续跑,当它运行到下一个函数调用时,会执行那段秘密的 stack check 代码
  4. G1 一看,内存检测发现我不该继续跑了,于是它会主动调用 runtime.goschedImpl,自己把自己搬下台,把 M 让出来

问题是:如果代码是 for {} 这种没有函数调用的死循环,它就不容易被及时抢占。

5.2 信号抢占(Go 1.14 起)

  1. Go给 M 注册了一个 sighandler
  2. **监控者(sysmon)**发现 M 上的 G 运行超过 10ms 了,且这哥们一直没下台,向 M 发送一个信号:SIGURG。
  3. 只要信号一到,操作系统内核会立刻暂停 M1 的当前工作。
  4. M1 会被迫跳转去执行 sighandler。在这个函数里,会直接操作 M1 的寄存器(PC 等),在当前的执行位置强行塞进一个叫 asyncPreempt 的函数调用。
  5. 当内核恢复 M1 的执行时,M1 以为自己还在接着刚才的代码跑,结果跑的第一行代码就是被塞进去的 asyncPreempt
  6. asyncPreempt 会通过mcall切换到 g0 栈(这是 Go 调度器的专属后台通道)。在 g0 栈里,它运行 gopreempt_m,正式把 G1 踢走。M1 找 G2 干活去了。

这一步让"公平性"真正可控,不再完全依赖 G 自觉。

6. 为什么 goroutine 轻量

综合上面的演进,goroutine 轻量主要来自:

  1. 初始栈小(KB 级),并且可动态增长/收缩。
  2. 主要在用户态调度,减少内核切换。
  3. GMP 让任务分发更高效(本地优先 + 全局兜底 + 窃取均衡)。
  4. 抢占机制避免长任务长期霸占 CPU。
  5. 数量上可以远高于线程,适合高并发模型。

这篇你应该记住的 3 件事

  1. GMP 不是"凭空设计",是被现实瓶颈一步步逼出来的。
  2. P 的价值是把调度和资源管理做了分层,让并发更可扩展。
  3. 抢占机制补齐了公平性,让 goroutine 不会被"长任务"长期压制。
相关推荐
悟空码字2 小时前
【保姆级】实现APP分享至微信,看完就能落地
java·后端·微信
毕设源码-郭学长2 小时前
【开题答辩全过程】以 基于Spring Boot“活力青春”健身房管理系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
陈随易2 小时前
我也曾离猝死很近
前端·后端·程序员
毕设源码-钟学长2 小时前
【开题答辩全过程】以 基于SpringBoot的校园快递APP系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
喵个咪2 小时前
GoWind Content Hub|风行,开箱即用的企业级前后端一体内容中台
前端·后端·cms
小码哥_常2 小时前
警惕!别让@Async成为服务器的“资源杀手”
后端
qq_256247052 小时前
解剖大型语言模型:剥开人工智能的“黑盒”
后端
代码探秘者3 小时前
【算法篇】3.位运算
java·数据结构·后端·python·算法·spring
Anastasiozzzz3 小时前
告别 Class:深入理解 Go 语言的面向对象编程
开发语言·后端·golang