go语言协程调度器 GPM 模型
下面的文章将以几个问题展开,其中可能会有扩展处:
-
什么是调度器?为什么需要调度器?
-
多进程/多线程时cpu怎么工作?
-
进程/线程的数量多多少?太多行不行?为什么不行?那怎么解决?
-
什么是协程?协程和线程/进程的区别?协程加入的作用?为什么会有这样的作用?
-
GPM模型的结构?怎么设计的?各部分的作用?各部分间怎么协作?
调度器由来
单进程时代,所有程序几乎都是阻塞的。只能一个任务一个任务执行。那么在计算机处理流程中会有多个硬件的支持和处理,cpu、cache、主内存、磁盘、网络等。比如:但是当任务执行到磁盘时,需要加载磁盘数据,此时流程阻塞,导致cpu处于等待状态,那么这对于cpu来说就是资源浪费。理应让cpu在这时去处理其他任务。又因为单进程下多任务也会阻塞,由此出现了多进程/多线程。为了极大发挥cpu等资源。我们需要有一个监听通知机制或者说算法,监听cpu状态并告知cpu执行哪一个任务。
多进程/多线程时cpu工作方式
为了实现在宏观角度上多个进程/线程一起执行的目标,需要一个调度器,通过分时
的机制,在不同的时间轴上执行不同的进程/线程。
多进程/线程的烦恼
假设在linux系统下,linux对待进程和线程是一样的。
假设,一个程序提供了一个服务。当并发量很小时,我们创建了较多的进程和线程,我们发现:整体的处理响应速度提高、应对并发量的阈值提高。当并发量很大时,我们创建了大量的进程和线程,此时我们发现:这个整体的响应反而比之前的慢,那按道理应该是相同的处理响应时间。
这是为何呢?
这是因为进程和线程的大量创建,cpu的资源大量用到了进程/线程的创建、进程/线程间切换、进程/线程销毁等与业务无关的操作。使得真正用到业务的cpu资源减少。还有内存的高占用,导致整体性能下降。
那么怎么解决呢?
这时出现了协程。
协程
协程其实是一种"用户态"的线程。(之后我们把"线程"都看作"内核级线程"),协程必须绑定线程才可以正常运行。那怎么绑定?方式上?数量上?
绑定方式和数量
N : 1 关系
N个协程由一个协程调度器调度,和一个线程绑定
缺点:
一个协程阻塞,整个线程也就阻塞了
1 : 1 关系
协程和线程 1 : 1 绑定,协程的调度也由cpu完成
缺点:
cpu又负责协程的创建、切换、销毁,增加了cpu的负担
M : N 关系
克服以上的问题。用户态调度器负责协程的创建,协程阻塞会主动让出线程,使得有新的协程可以和线程绑定,执行其他任务。
综上,那么在 M : N 的关系中,怎么实现一个协程调度器是至关重要的,因为他会基于协作式的调度策略负责与线程的解绑定,影响执行效率
在介绍完整个的调度器、协程后,我们来认识 go 语言中的协程和调度器
Go协程
go协程基于协程的思想,是一种用户级线程。由 runtime 调度,初始占用极小,但是可以动态的扩容。
扩展:
runtime 是 Go 语言的核心运行时环境,负责管理内存分配、垃圾回收(GC)、协程调度、系统调用等底层操作。其中,协程调度器 是 runtime 的关键组件之一,负责 Goroutine 的创建、销毁和调度。
GPM 模型
首先,需要明白的是:gpm模型是go语言实现的一种用户空间的协程调度器,是 runtime包 的核心组件之一
GPM 模型的成员
G:goroutine协程,用户空间。协程实体,保存执行上下文(栈、PC 指针等),初始栈 2KB,动态扩缩容
P:processor处理器,用户空间。是 Go 运行时在用户空间抽象出的调度上下文,负责承载 Goroutine 队列和执行环境,数量由 GOMAXPROCS
控制
M:(machine)thread线程,内核空间。实际执行代码的内核线程,必须绑定 P 才能运行 G(实际上,这里就是将之前的 "协程→线程" 直接绑定关系,抽象为 "协程→P→线程" 的间接绑定)
扩展:相比于之前直接绑定关系,这样的间接绑定的好处是什么?
一、抽象层级的对比
模型 | 绑定关系 | 调度灵活性 | 资源利用率 |
---|---|---|---|
传统 N:1 | 协程 → 单线程 | 极低 | 单核利用 |
传统 1:1 | 协程 → 专用线程 | 高 | 高(但开销大) |
Go GPM(M:N) | 协程 → P → 动态绑定线程 | 极高 | 高且开销低 |
二、引入 P 的核心优势
- 解耦协程与线程的强绑定
-
传统模式问题 :
在 1:1 模式下,协程阻塞会导致对应线程阻塞,即使系统中存在其他可运行的协程。
-
GPM 解决方案
P 作为 "执行上下文",可在不同 M 间动态迁移。当 G 阻塞时,P 与当前 M 解绑,转移到其他空闲 M 继续执行队列中的 G。
go// 示例:当 G1 执行阻塞操作时 go func() { // G1 resp, _ := http.Get("https://example.com") // 阻塞调用 // ... }() go func() { // G2 // 即使 G1 阻塞,P 可调度 G2 在其他 M 上执行 }()
- 减少锁竞争,提升并发性能
-
全局队列瓶颈 :
早期 Go 版本(<=1.0)仅使用全局运行队列,所有 M 竞争同一个队列,锁冲突严重。
-
P 的本地队列
每个 P 维护自己的本地队列(LRQ),M 优先从本地队列获取 G,大幅减少锁争用。
- 工作窃取:当本地队列空时,M 从其他 P 的队列 "偷取" G,负载均衡更高效。
- 优化系统调用处理
-
非阻塞系统调用 :
通过
netpoller
(基于epoll
/kqueue
)实现 IO 多路复用,M 无需阻塞等待,可继续执行其他 G。 -
阻塞系统调用
当 G 执行阻塞调用时,M 释放 P,允许其他 M 接管 P 继续工作。调用完成后,G 重新加入某个 P 的队列。
go// 底层逻辑简化示意 func syscallRead(fd int) { g := getg() g.m.p.ptr().syscallentering(g) // P 准备进入系统调用 // 执行内核调用... g.m.p.ptr().syscallexiting(g) // P 退出系统调用,重新分配 }
- 控制并行度,避免过度并发
GOMAXPROCS
限制活跃 P 的数量,从而控制实际并行执行的协程数。
- 对于 CPU 密集型任务,设置
GOMAXPROCS=CPU核数
可充分利用硬件资源。 - 对于 IO 密集型任务,可设置更大的
GOMAXPROCS
,但需权衡线程切换开销。
三、对比实验:P 的性能影响
以下是不同 GOMAXPROCS
设置下的性能测试(数据为示意):
GOMAXPROCS | 吞吐量(req/s) | 平均延迟(ms) | 线程数 |
---|---|---|---|
1 | 10,000 | 5 | 2-3 |
4 | 35,000 | 4 | 4-6 |
16 | 38,000 | 6 | 16-20 |
- 结论
- 增加 P 数量(≤CPU 核数)可提升并行度,但超过核数后收益递减,甚至因线程切换开销导致性能下降。
四、总结:P 的设计哲学
P 的引入本质是在用户空间实现了一个轻量级的虚拟 CPU 管理系统:
- 将调度决策(如 G 的选择、负载均衡)从内核转移到用户空间,减少内核干预;
- 通过本地队列和工作窃取算法,最小化锁竞争(对全局队列来说);
- 动态绑定机制使资源利用更高效,尤其适合高并发 IO 场景。
这种设计让 Go 既能支持百万级协程,又能高效利用多核 CPU,成为构建云原生应用的理想语言。
GPM 模型结构介绍
- 全局队列:负责存放等待运行的协程。协程来源:各自处理器下协程创建满了就拿一半放到全局队列。协程去处:处理器本地队列的协程不够了就从全局队列拿取。特点:全局资源,任何读写操作都是要互斥的(上锁)。
- P:处理器。对上负责调度协程,向下负责绑定线程。维护一个本地的协程队列,有利于细锁化。特点:协程偷取机制、动态绑定线程。
- M:负责从P中获取协程执行任务。触发P的偷取机制、从全局队列取协程动作
GMP 模型的调度策略的介绍
- 线程复用:比如在GPM模型中,当线程出现空闲或阻塞状态时分别会触发
偷取机制
和移交机制
。使得充分利用线程,避免大量创建和销毁线程。 - 并行:P 的数量决定了并行量。cpu核数决定了 P 的数量。推荐最大的 P = 核数/2
- 混合协程工作策略:抢占式 + 协作式。协作式:当goroutine出现阻塞,协程主动让出,P 解绑定,然后和其他空闲线程绑定。抢占式:一个go程最大运行时长为10ms(go1.14后新增),调度器通过
SIGURG
信号强制中断其执行,go程主动释放 P - 全局G队列:本地队列为空,优先从全局队列取,如果没有则"偷取"。本地队列过多,向全局队列输送协程。
GPM 模型的调度器生命周期
在 Go 语言调度器的 GPM 模型中还有两个比较特殊的角色,它们分别是 M0 和 G0。
- M0
- 启动程序后的编号为 0 的主线程。
在全局命令 runtime.m0 中,不需要在 heap 堆上分配。
- 负责执行初始化操作和启动第 1 个 G。
- 启动第 1 个 G 后,M0 就和其他的 M 一样了。
- G0
- 每次启动一个 M,创建的第 1 个 Goroutine 就是 G0。
G0 仅用于负责调度 G。
- G0 不指向任何可执行的函数。
每个 M 都会有一个自己的 G0。
在调度或系统调度时,会使用 M 切换到 G0,再通过 G0 调度
。- M0 的 G0 会放在全局空间。
初始化阶段
- 创建最初的 M0 和 G0,并将二者关联。
- 初始化 M0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表
作用阶段
- runtime.main函数开始,(创建 Main Goroutine)调用 main.main 函数, 将 Main Goroutine 放到 P 的本地队列。
- 启动 M0 ,M0 从 P 中获取 Main Goroutine 。(由于 G 拥有栈,M 根据 G 的栈信息和调度信息设置运行环境)然后运行,最后(如果还有待执行的go程)继续从 P 队列获取go程执行,直到 Main Goroutine 结束。最后 runtime.main 执行 Defer 和 Panic 或者 runtime.exit 结束。
GPM 模型调度的场景举例
- go程1 创建 go程2,优先加入本地队列
- G0 和 G1的切换,当 M 上的 G1 执行完,自动切换回 G0
- 开辟过多 G ,拿出前一半的go程和新创建的go程放到全局队列
- 新创建的 go程 可以唤醒空闲的 mp 组合执行任务
- 当一个 mp 组合没有g执行时,p 就会调度 G0线程。此时 M、P、G0组合被称为 自旋线程
- 自旋线程寻找可执行的 G 优先从全局队列获取
- 自旋线程寻找可执行的 G 最后从其他队列偷取
- 阻塞线程和 P 解绑,然后 P 和其他 可运行的M绑定。
- 之前与 P 绑定的 M 非阻塞后,P 会尝试与 M 重新绑定。如果 P 正在和其他 M 绑定 或者 全局空闲 P 队列为空,那么 M 进入空闲线程队列,进入休眠(最后可能被 gc)