Go - GPM
CPU
- 一个 Go 程序对于操作系统来说只是一个
用户层程序,操作系统眼中只有线程 - goroutine 的
调度由Go Runtime来完成:『公平』竞争『CPU』- Go Runtime 如何将众多的 goroutine 按照一定的算法调度到『CPU』上运行
goroutine要竞争的『CPU』是操作系统线程- 将
goroutine按照一定算法放到不同的操作系统线程上执行
- 将
演进
G-M->G-P-M- 不支持抢占 -> 支持
协作式抢占 -> 支持基于信号的异步抢占
G-M
2012.03.28 Go 1.0
- 抽象
- 每个
goroutine对应于运行时的一个抽象结构G - 被视作『CPU』的
操作系统线程,则对应另一个抽象结构M(machine)
- 每个
- 工作
- 将 G
调度到 M 上去运行
- 将 G
GOMAXPROCS- 调度器可见的最大 M 数
缺陷:限制 Go 并发程序的
伸缩性,尤其对于有高吞吐或并行计算的服务程序
- 由于
单一全局互斥锁和集中状态存储的存在,导致所有 goroutine 相关操作,都需要上锁 - M 之间经常
传递可运行的 goroutine,导致调用延迟增大,增加额外的性能开销 - 每个 M 都做
内存缓存,导致内存占用过高,数据局部性较差 - 由于
系统调用而形成的频繁的工作线程阻塞和解除阻塞,导致额外的性能损耗
G-P-M
Go
1.2- 计算机领域的任何问题都可以增加一个间接的中间层来解决
在 G-M 模型中增加一个 P,让调度器具有很好的
伸缩性
P是一个逻辑 Processor,每个 G 要真正运行,需要首先被分配一个 P,即进入 P 的本地队列- 对 G 来说,P 就是运行 G 的『CPU』,即
G 只能看见 P - 对 Go 调度器来说,真正的『CPU』是
M,只有将 P 和 M绑定,才能让 P 的本地队列中 G 真正运行起来
但此时,调度器依然不支持
抢占式调度
- 一旦某个 G 出现
死循环,那么 G 将永久占用分配给它的 P 和 M - 而位于
同一个P 中的其它 G 将得不到调度,出现饿死的情况 - 如果只有一个 P(
GOMAXPROCS=1)时,整个 Go 程序中的其它 G 都将被饿死
基于协作的抢占式调度
Go
1.2
- 编译器在每个
函数或者方法的入口,加上一段额外的代码- 让
运行时有机会在这段代码中检查是否需要抢占式调度
- 让
- 只能
局部解决饿死问题,因为只在函数调用的地方才能插入抢占代码埋点- 对于没有函数调用而是
纯算法循环计算的 G,调度器依然无法抢占
- 对于没有函数调用而是
- 后果
- GC 在等待所有 goroutine 停止时的等待时间过长(类似于 JVM 的
安全点) - 从而导致
GC 延迟,内存占用瞬间冲高
- GC 在等待所有 goroutine 停止时的等待时间过长(类似于 JVM 的
基于系统信号的抢占式调度
Go
1.14
- 通过向线程
发送信号的方式来抢占正在运行的 goroutine
小优化
通过
IO poller减少M的阻塞等待
运行时已经实现了netpoller- 即便 G 发起
网络 IO 操作,也不会导致 M 被阻塞(只阻塞 G),也不会导致大量线程 M 被创建出来
- 即便 G 发起
- 对于
文件 IO 操作,一旦阻塞,那么线程 M 将进入挂起状态,等待 IO 返回后唤醒- 此时 P 将与
挂起的 M解绑,再选择一个处于空闲状态的 M - 如果此时没有空闲的 M,就会
新创建一个 M(线程) - 在这种情况下,
大量 IO 操作仍然会导致大量线程被创建
- 此时 P 将与
- Go
1.9,在 G 操作支持监听的FD时,仅会阻塞 G,而不会阻塞 M- 但依然对
常规文件无效,因为常规文件是不支持监听的
- 但依然对
G-P-M
G-M模型已经废弃,NUMA模型尚未实现
语义
G- 代表goroutine- 存储 goroutine 的
执行栈信息、状态、任务函数等 - G 对象是
可重用的
- 存储 goroutine 的
P- 代表逻辑 Processor- P 的数量决定了系统内
最大可并行的G的数量 - P 拥有各种
G 对象队列、链表、一些缓存和状态
- P 的数量决定了系统内
M- 代表真正的执行计算资源- 在 M
绑定有效的 P 后,进入调度循环- 从 P 的
本地队列以及全局队列中获取G,并切换到 G 的执行栈上并执行 G 的函数 - 调用
goexit做清理工作并回到 M,如此反复
- 从 P 的
M 并不保留 G 状态,因此 G 可以跨 M 调度
- 在 M
调度
调度器的目标:公平合理地将
G调度到P上运行
抢占调度
场景:G 没有进行
系统调用、没有进行IO 操作、没有阻塞在一个channel操作上
- Go 程序
启动时,Go Runtime会去启动一个名为sysmon的M(监控线程) - 该 M
不需要绑定P 就可以运行(以g0的形式)
1 | // The main goroutine. |
sysmon每20us ~ 10ms启动一次
- 释放闲置超过 5 分钟的
span内存 - 如果超过
2 分钟没有执行GC,强制执行 - 将长时间未处理的 netpoll 结果添加到任务队列中
- 向
长时间运行的 G 任务发起抢占式调度- retake - 回收因
系统调用而长时间阻塞的P
1 | // forcePreemptNS is the time slice given to a G before it is |
- 如果一个 G 任务运行
10 ms,sysmon 会认为其运行时间太久而发起抢占式调度的请求 - 一旦 G 的
抢占标志位被设为true,等到 G 的下一次调用函数或者方法时(Go Runtime 注入埋点)Go Runtime可以将 G 抢占并移除运行状态,然后放入队列中,等待下一次调度
channel / 网络 IO
不阻塞 M,避免大量创建 M 导致的开销
- 如果 G 被
阻塞在某个 channel 操作或者网络 IO 操作时- G 会被放置在某个
等待队列中,M 会尝试运行 P 的下一个可运行 G
- G 会被放置在某个
- 如果此时 P 没有可运行的 G 供 M 运行,那么 M 将
解绑P,并进入挂起状态 - 当 IO 操作完成或者 channel 操作完成
- 在
等待队列中的 G 会被唤醒,标记为可运行(Runnable),并放入到某 P 的队列中
- 在
系统调用
阻塞 M,但在阻塞前会与 P解绑,P 会尝试与其它 M 绑定继续运行其它 G
如果没有现成的 M,Go Runtime 会新建 M,因此
系统调用可能会导致系统线程数增加
如果 G 被阻塞在某个系统调用上,那么不光 G 会阻塞,执行这个 G 的 M 也会
解绑P,并与 G 一起进入挂起状态如果此时有空闲的 M,那么 P 会和它绑定,并继续执行其它 G
如果此时没有空闲的 M,但仍然有其他 G 要执行,那么 Go Runtime 会
创建一个新 M 线程
当系统调用返回后,阻塞在这个系统调用上的 G 会尝试获取一个
可用的 P- 如果没有可用的 P,那么 G 会被标记为
Runnable,之前的那个挂起的 M 将再次进入挂起状态 - 因为 G 和 M 需要通过一个 P 来
桥接
- 如果没有可用的 P,那么 G 会被标记为
All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.












