GPM模型
更高详细的GO的并发细节参照GO程序员笔试面试宝典
并非引流,只是因为写这篇文章时对细节还不了解,也没看源码。感觉脑瓜子还是一团乱麻。等后面有机会再梳理一下。
G:Goroutine
GO协程。拥有自己的栈空间,指令指针和其他用于调度的上下文信息。
P:Proccessor
逻辑处理器。用于执行Goroutine。它维护了一个 goroutine 队列,即
runqueue。每个P必须绑定一个M才能执行G。如果M阻塞(如系统调用),与该M绑定的P会解绑,并由其他空闲的M(或者新创建的M)接管这个P,继续执行LRQ中的G。M:Machine
M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的;M 是一个很大的结构,和某个P绑定,从P的
runqueue中不断取出G,切换堆栈并执行,M本身不具备执行状态,在需要任务切换时,M将堆栈状态写回G,任何其它M都能据此恢复执行。
通过设定 GOMAXPROCS 来控制 P 的数量,Go 程序既能确保充分利用多核 CPU 的计算能力,又避免了因过多线程竞争 CPU 资源而导致的性能下降。通常,P 的数量与 CPU 核心数相等,这意味着在理想情况下,每个核心都有一个 P 在积极地调度和执行 G。
GPM调度机制
了解调度策略之前,先了解基本的概念和数据结构
全局运行队列:当P的本地运行队列没有空间,或者某些G被唤醒(例如从网络调用返回的G、被抢占的G)需要重新调度时,这些G可能被放入全局运行队列。
P的本地运行队列(LRQ):每个P都有一个自己的LRQ,用于存放等待在该P上执行的G。M会优先从其关联的P的队列上获取G。LRQ的存在减少了对GRQ的竞争。
g0:每个M都有一个特殊的goroutine,称为g0。g0拥有自己的栈空间(独立于用户G的栈,通常较大),主要用于执行调度相关的代码,垃圾回收的辅助工作以及其他运行时任务。当M需要切换到某个用户G执行时,会从g0切换到用户G的栈,反之依然。m.curg:指向当前在M上运行的用户G。每个M都有该变量。G的状态:
Goroutine在生命周期中会经历多种状态,例如:_Gidle(闲置,刚被分配还未使用)_Grunnable(可运行,在运行队列中等待调度)_Grunning(运行中,正在 M 上执行)_Gsyscall(进行系统调用,M 已与 P 分离)_Gwaiting(等待中,如等待 channel 操作、锁、或定时器)_Gdead(已结束,资源可回收)_Gcopystack(栈复制中,通常在栈增长时发生)_Gpreempted(被抢占,等待重新调度)
P的状态:P也存在不同的状态,例如:
_Pidle(闲置,没有 M 与之关联或没有可运行的 G)、_Prunning(运行中,有 M 与之关联并正在执行 G 或调度代码)、_Psyscall(其关联的 M 正在进行一个阻塞的系统调用,P 本身可能被其他 M 使用)、_Pgcstop(因垃圾回收而停止)、_Pdead(不再使用,例如GOMAXPROCS被减小时)
M的自旋状态:
自旋(Spinning) 是指 M 在暂时没有可执行的 G(Goroutine)时,不立即进入休眠,而是空转循环(忙等待),主动寻找可运行的 G。 自旋的 M 会占用 CPU 资源,但避免了线程休眠和重新唤醒的代价(系统调用、上下文切换等)。
M的自旋需要满足以下条件:
- 当前 M 绑定的 P 的本地运行队列(
LRQ)为空,且无法立即从全局队列(GRQ)或其他 P 偷取到 G。 - 仍有其他 P 正在运行 G(即系统整体有任务可执行,只是当前 P 暂时无任务)。
- 自旋的 M 数量未超过阈值(默认最多
GOMAXPROCS/2个自旋 M,防止过度占用 CPU)。
自旋的 M 会持续执行以下操作:
- 检查全局队列(
GRQ):- 定期扫描
GRQ,看是否有新加入的 G 可执行。
- 定期扫描
- 尝试从其他 P 偷取 G(Work Stealing):
- 随机选择其他 P,从其
LRQ中偷取一半的 G(最少偷 1 个)。
- 随机选择其他 P,从其
- 检查网络轮询器(
Netpoller): 查看是否有就绪的网络 I/O 相关的 G 可恢复执行。 - 自旋超时后休眠: 如果自旋一段时间(约
10ms)仍找不到 G,M 会退出自旋状态并休眠。
- 当前 M 绑定的 P 的本地运行队列(
调度决策在很大程度上是每个 M 各自独立在其 g0 栈上执行的。当一个 M 空闲下来(例如,其当前 G 执行完毕或被阻塞),它会运行调度代码来寻找下一个可运行的 G。
没有“总控”M :Go 的调度器设计上是去中心化的,没有一个特定的 M 作为“总控制器”来指挥所有其他 M。这种设计避免了单点瓶颈,提高了并发度。
协调机制:尽管调度是分布式的,但 M 之间通过一些共享结构和机制进行协调:
全局运行队列 (GRQ) :为所有 P 提供了一个共享的 G 来源。
工作窃取 (Work Stealing) :当一个 P 的
LRQ为空时,其关联的 M 会尝试从其他 P 的LRQ中“窃取”一半的 G 到自己的LRQ,或者从GRQ中获取 G。这有助于在 P 之间均匀分配工作负载,防止某些 P 空闲而另一些 P 过载。抢占:
在 Go 的早期版本中(1.14 之前),抢占主要是协作式的。也就是说,一个 goroutine 主动放弃 CPU 的执行权通常发生在函数调用时(编译器会在函数入口处插入检查点,判断是否需要进行栈增长以及是否需要被抢占)、channel 操作、
select语句、以及一些同步原语的调用点。这意味着如果一个 goroutine 执行一个没有任何函数调用的密集计算循环 (for {}),它可能会长时间占据 M,导致同一个 P 上的其他 goroutine 饿死。从 Go 1.14 版本开始,引入了 基于信号的异步抢占机制 (asynchronous preemption) ,以解决上述问题:
sysmon后台监控线程 :这是一个特殊的 M(不与 P 绑定),它负责一些全局性的协调任务,比如垃圾回收的触发和辅助、网络轮询器(Netpoller)事件的处理(间接影响调度,通过将等待 I/O 的 G 变为可运行状态)、以及检测并抢占长时间运行(超过 10 ms)的 G,并将其调度到global runqueue。sysmon更像是一个维护者和协调者,而非一个命令下发者。- 发送信号 :如果
sysmon发现某个 G 在一个 M 上运行时间过长,它会向该 M 发送一个抢占信号(例如,在 Unix 系统上是SIGURG)。 - 信号处理 :M 接收到信号后,会中断当前正在执行的 G。G 的上下文(主要是寄存器)会被保存,其状态会被标记为
_Gpreempted或类似状态,然后被放回到运行队列(通常是GRQ,以给其他 P 机会执行它,避免立即在同一个 P 上再次调度)。 - 重新调度 :M 随后会进入调度循环(在其
g0栈上),选择下一个可运行的 G 来执行。
P 的管理 :Go runtime 负责管理 P 的池。当 M 因系统调用阻塞时释放 P,或当有空闲 P 和可运行 G 时,runtime 会尝试唤醒或创建 M 来绑定这些 P。
G的调度:
前面说过,如果一个G任务运行10ms,sysmon就会认为它的运行时间太久而发出抢占式调度的请求。一旦G的抢占标志位被设为true,那么等到这个G下一次调用函数或方法时,运行时就可以将G抢占并移出运行状态,放入队列中,等待下一次被调度。
除此之外,还有两种特殊情况下的G的调度:
channel阻塞或网络I/O情况下的调度:
如果G被阻塞在某个channel操作或网络I/O操作上时,G会被放置到某个等待(wait)队列中,而M会尝试运行P的下一个可运行的G。如果这个时候P没有可运行的G供M运行,那么M将解绑P,并进入挂起状态。当I/O操作完成或channel操作完成,在等待队列中的G会被唤醒,标记为可运行(runnable),并被放入到某P的队列中,绑定一个M后继续执行。
系统调用阻塞情况下的调度(hand off 机制):
如果G被阻塞在某个系统调用(system call)上,那么不光G会阻塞,执行这个G的M也会解绑P,与G一起进入挂起状态。如果此时有空闲的M,那么P就会和它绑定,并继续执行其他G;如果没有空闲的M,但仍然有其他G要去执行,那么Go运行时就会创建一个新M(线程)。
当系统调用返回后,阻塞在这个系统调用上的G会尝试获取一个可用的P,如果没有可用的P,那么G会被标记为runnable,【之前的那个挂起的M将再次进入挂起状态???(从别的博客搬运的。。。没太明白,从挂起到挂起???)】。