GO基础语法
LDK Lv4

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。

GMP模型图解

GPM调度机制

了解调度策略之前,先了解基本的概念和数据结构

  • 全局运行队列:当P的本地运行队列没有空间,或者某些G被唤醒(例如从网络调用返回的G、被抢占的G)需要重新调度时,这些G可能被放入全局运行队列。

  • P的本地运行队列(LRQ):每个P都有一个自己的LRQ,用于存放等待在该P上执行的G。M会优先从其关联的P的队列上获取G。LRQ的存在减少了对GRQ的竞争

  • g0:每个M都有一个特殊的goroutine,称为g0g0拥有自己的栈空间(独立于用户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 个)。
    • 检查网络轮询器(Netpoller): 查看是否有就绪的网络 I/O 相关的 G 可恢复执行。
    • 自旋超时后休眠: 如果自旋一段时间(约 10ms)仍找不到 G,M 会退出自旋状态并休眠。

调度决策在很大程度上是每个 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 runqueuesysmon 更像是一个维护者和协调者,而非一个命令下发者。
      • 发送信号 :如果 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将再次进入挂起状态???(从别的博客搬运的。。。没太明白,从挂起到挂起???)】。

由 Hexo 驱动 & 主题 Keep
本站由 提供部署服务
总字数 74.8k 访客数 访问量