并发模型对比 - Go GMP 调度器:打造百万并发的用户态调度体系

Go GMP 调度器:打造百万并发的用户态调度体系
o 语言的并发模型在诸多现代编程语言中独树一帜。它并没有采用传统的操作系统线程作为并发基本单元,而是在运行时层实现了自己的调度器——这就是大名鼎鼎的 GMP 调度模型。
通过精心设计的 GMP 调度器,Go 能够轻松支撑起数十万甚至上百万个 goroutine 的并发运行,为开发者带来近乎极致的资源利用效率。
一、核心三要素:G、M、P 的前世今生
在正式分析 GMP 模型之前,先定义三个核心角色:
G(Goroutine)——轻量级的执行单元,可以理解为 Go 的“协程”。每个 G 包含了自己的栈空间、程序计数器、状态等信息,由 Go 运行时管理。一个 Goroutine 的初始栈内存消耗仅为 2KB,因此可以大规模创建。
M(Machine)——操作系统线程,代表真正的计算资源。M 与内核线程一一对应,是实际执行 G 的“工人”。
P(Processor)——逻辑处理器,连接 G 和 M 的桥梁。P 维护着一个本地可运行 G 队列,并控制着程序的并行度。P 的数量由
GOMAXPROCS决定,通常等于 CPU 核心数。
三者核心关系图如下:
text
┌─────────────────────────────────────────────────────────────┐ │ GMP 模型 │ │ │ │ 全局队列: [G8] [G9] [G10] │ │ │ │ │ ▼ │ │ ┌──────────┐ ┌──────────┐ │ │ │ P1 │ │ P2 │ │ │ │ 本地队列 │ │ 本地队列 │ │ │ │[G1][G2] │ │[G3][G4] │ │ │ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────┐ ┌──────────┐ │ │ │ M1 │ │ M2 │ │ │ │ (线程) │ │ (线程) │ │ │ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ ▼ ▼ │ │ CPU 0 CPU 1 │ └─────────────────────────────────────────────────────────────┘
二、为什么要有 P?一次历史演进
要理解 P 为什么存在,需要回顾 GMP 模型出现之前的 GM 模型。
在 Go 1.1 及更早的版本中,Go 调度器只有 G 和 M 两个角色。所有可运行的 G 都存放在一个 全局队列 中,M 每次需要执行 G 时,都要从这个全局队列中获取。
问题很快暴露出来:全局队列需要一把大锁(Sched.Lock)来保护并发访问。当并发量增大、M 数量增多时,这把全局锁成了严重的性能瓶颈——所有与 goroutine 相关的操作(创建、结束、重排等)都需要竞争这把锁,导致锁竞争异常激烈。
GM 模型存在的四大问题:
-
单一的全局锁和集中状态管理,高并发下锁竞争严重
-
M 之间频繁切换 G,增加额外开销和延迟
-
每个 M 都关联大量内存缓存,造成资源浪费
-
系统调用场景下工作线程频繁阻塞和唤醒,开销过高
解决方案:基于“加一个中间层”的思路引入 P
Go 在 G 和 M 之间加入了 P 作为中间层。P 负责持有本地运行队列,M 需要先获取 P 才能运行 G。这个看似简单的改变,带来了三大关键收益:
-
本地队列减少锁竞争:每个 P 有自己专属的本地队列,M 优先从本地队列获取 G,绝大部分情况下无需加锁,极大降低了锁竞争。
-
Work Stealing 实现负载均衡:当某个 P 的本地队列为空时,它会主动从其他 P 的队列或全局队列中“偷取”任务来执行,确保所有 P 都不闲置。
-
并行度可控:P 的数量
GOMAXPROCS决定了程序的最大并行度,可根据 CPU 核心数精确配置。
为什么 P 的逻辑不直接加在 M 上? M 是真正的操作系统线程,操作系统只知道线程,不知道用户空间的调度逻辑。Go 运行时在用户态维护一个抽象层(P),正是为了避免去修改内核线程的内部逻辑。操作系统内核无需要知道 Go 的存在,一切调度逻辑交由应用层自己完成——这正是用户态调度器的设计精髓。
三、调度循环:工作窃取的精密协作
3.1 调度核心流程
当一个 M 要执行 G 时,遵循以下调度路径:
-
M 获取 P:M 先从全局 P 数组中获取一个可用的 P,绑定后才能开始执行任务。
-
从本地队列获取 G:M 优先从所绑定 P 的本地队列中获取一个可运行的 G。
-
执行 G:M 执行 G 中的任务代码。
-
重复调度:G 执行完成后,M 继续从本地队列获取下一个 G,形成一个调度循环。
整个过程由 schedule() 函数和 findrunnable() 函数协同完成,永无止境。
3.2 核心调度策略:Work Stealing(工作窃取)
Work Stealing 是 GMP 调度器最具代表性的负载均衡机制。当一个 P 的本地队列为空时,该 P 会依次尝试以下路径获取可运行的 G:
-
步骤 1:从当前 P 的本地队列获取
-
步骤 2:从全局队列获取(需加锁)
-
步骤 3:从 netpoll 获取网络轮询器中的可运行 G
-
步骤 4:Work Stealing——从其他 P 的本地队列末尾偷取约一半的 G
偷取细节:当一个 P 从另一个 P 的本地队列偷取 G 时,偷取的是队列末尾的约一半数量的 G。这种设计有利于保持缓存局部性——被偷走的 G 原本在队列中排序靠后,在目标 P 上执行时的缓存命中率能得到一定程度的保障。
这个机制确保了所有 P 都能保持忙碌状态,最大化 CPU 利用率。整个过程不需要中央调度器介入,完全分布式的负载均衡。
3.3 全局队列:最后的保底机制
在新 G 创建时,优先放入当前 P 的本地队列。如果本地队列已满(容量上限为 256 个 G),则会将本地队列中约一半的 G 移动到全局队列,为新创建的 G 腾出空间。全局队列的存在为调度器提供了一个缓冲层,确保在极端情况下调度器仍能保持稳定运行。
四、协程让出:协作式调度与抢占式调度
Go 的调度策略经历了从纯粹协作式到加入抢占式的重要演变。
4.1 协作式调度
Go 早期采用的是协作式调度,依赖 goroutine 主动让出执行权。提供两种主动让权方式:
-
runtime.Gosched():主动调用,当前 goroutine 放弃执行权,让其他 goroutine 有机会执行 -
IO/通道阻塞时的隐式让权:当 goroutine 因通道操作、系统调用等原因阻塞时,调度器会自动让出 CPU
协作式调度的局限:如果一个 goroutine 执行纯计算逻辑且从不主动让权,它可能长时间霸占 CPU,导致其他 goroutine 无法运行,出现所谓的“饿死”现象。
4.2 抢占式调度
为了解决协作式调度的不足,Go 1.14 正式引入了 基于信号的异步抢占式调度(非协作式抢占),通过在调度器中内置一个特殊的后台监控线程 sysmon,检查长时间运行的 G 并强制让出 CPU。
抢占机制的核心工作流程:
-
sysmon后台监控线程每隔一段时间检查所有 P 的运行情况 -
当发现某个 G 运行时间过长(默认超过 10ms),向对应的 M 发送 SIGURG 信号
-
M 收到信号后,暂停当前 G 的执行,保存上下文,将 G 放回运行队列
-
调度器从运行队列中获取下一个 G 继续执行
为什么选择 SIGURG 信号? SIGURG 是通常在紧急 socket 带外数据到达时使用的信号。在 Go 调度器中选择用这个信号,是因为它本身是异步的、不会影响正常的程序执行路径,而且在所有主流 Unix-like 系统中都有良好支持,能够可靠地实现线程间的异步抢占通知。
这种抢占式调度使得长时间运行的 CPU 密集型计算也能被及时中断,保证所有 goroutine 都能获得公平的执行机会。
4.3 GC 与 STW 场景中的特殊抢占
在垃圾回收(GC)的 Stop The World(STW)阶段,runtime 会调用 preemptall() 强制抢占所有正在运行的 G,确保 GC 能够安全地扫描栈和进行内存整理。实现方式也是通过信号机制:GC 循环中调用 suspendG → preemptM → signalM,向正在执行 goroutine 所在的线程发送抢占信号。
五、Handoff 机制:阻塞场景下的 P 转移
当一个 goroutine 因为系统调用而阻塞时,GMP 模型并不会让整个线程空转浪费 CPU 资源。此时,会发生 P 的 Handoff(移交):
-
当前 M 判断 G 即将进入系统调用,会主动解绑 P
-
这个 P 被立即移交给另一个空闲的 M(或新建的 M)
-
新 M 拿到 P 后,从该 P 的本地队列中取出 G 继续执行
-
原先的 M 在系统调用返回后,若发现没有 P 可用,会将 G 放回全局队列,然后自身进入休眠
这个机制确保了即使在发生系统调用阻塞时,P 和它所代表的 CPU 资源也不会被浪费,而是快速流转到其他可执行的任务上。
六、调度器启动与生命周期的源码视角
Go 程序的启动分为四个关键阶段:
-
osinit:获取系统 CPU 核心数 -
schedinit:初始化调度器,创建全局调度状态schedt -
newproc:创建主 goroutine(执行runtime.main) -
mstart:启动调度循环
在 schedinit 中,P 的数量根据 GOMAXPROCS 配置确定,通常等于 CPU 核心数,最多不超过 256。M 的最大数量被设置为 10000,但实际活跃的 M 数量通常很少。
七、Go 调度器设计的思想精髓
经过对 GMP 模型的全面拆解,可以提炼出 Go 调度器设计的几个关键原则:
-
用户态调度:完全在用户态完成 goroutine 的创建、切换和销毁,避免陷入内核,使调度开销降至纳秒级。
-
分布式负载均衡:通过本地队列 + Work Stealing,将调度决策分散到各个 P,避免了单一全局锁瓶颈。
-
资源精确复用:P 的数量就是并行度上限,M 按需创建和销毁,系统调用的 Handoff 机制确保 CPU 资源不被浪费。
-
公平性与可抢占性:从协作式演进到信号式抢占,确保在任意场景下所有 goroutine 都能获得公平的执行机会。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)