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.