调度器 GMP 模型

Administrator
发布于 2024-05-11 / 1 阅读
0

调度器 GMP 模型

调度器 GMP 模型

https://github.com/fengyuan-liang/notes/blob/main/GoLang/golang%E5%A4%A7%E6%9D%80%E5%99%A8GMP%E6%A8%A1%E5%9E%8B.md

在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上。

  • 全局队列(Global Queue):存放等待运行的 G。

  • P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。

  • P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。

  • M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

调度器的设计策略

golang调度器的设计策略思想主要有以下几点:

  • 复用线程

  • 利用并行

  • 抢占

  • 全局G队列

2.2.1 复用线程

golang在复用线程上主要体现在work stealing机制hand off机制(偷别人的去执行,和自己扔掉执行)

首先我们看work stealing,我们在学习java的时候学过fork/join,其中也是通过工作窃取方式来提升效率,充分利用线程进行并行计算,并减少了线程间的竞争

干完活的线程与其等着,不如去帮其他线程干活于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行

hand off机制

当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行,此时M1如果长时间阻塞,可能会执行睡眠或销毁

2.2.2 利用并行

我们可以使用GOMAXPROCS设置P的数量,这样的话最多有GOMAXPROCS个线程分布在多个CPU上同时运行。GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行


2.2.3 抢占策略

  • 1对1模型的调度器,需要等待一个co-routine主动释放后才能轮到下一个进行使用

  • golang中,如果一个goroutine使用10ms还没执行完,CPU资源就会被其他goroutine所抢占

2.2.4 全局G队列

  • 全局G队列其实是复用线程的补充,当工作窃取时,优先从全局队列去取,取不到才从别的p本地队列取(1.17版本)

  • 在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G

开启一个 Goroutine 的调度流程


go func 创建和执行的流程

从上图我们可以分析出几个结论:

​ 1、我们通过 go func () 来创建一个 goroutine;

​ 2、有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了就会保存在全局的队列中;

​ 3、G 只能运行在 M 中,一个 M 必须持有一个 P,M 与 P 是 1:1 的关系。M 会从 P 的本地队列弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会想其他的 MP 组合偷取一个可执行的 G 来执行;

​ 4、一个 M 调度 G 执行的过程是一个循环机制;

​ 5、当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),然后再创建一个新的操作系统的线程 (如果有空闲的线程可用就复用空闲线程) 来服务于这个 P;

​ 6、当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。

2.4 调度器的生命周期

在了解调度器生命周期之前,我们需要了解两个新的角色M0G0

M0(跟进程数量绑定,一比一):

  • 启动程序后编号为0的主线程

  • 在全局变量runtime.m0中,不需要在heap上分配

  • 负责执行初始化操作和启动第一个G

  • 启动第一个G之后,M0就和其他的M一样了

G0(每个M都会有一个G0):

  • 每次启动一个M,都会第一个创建的gourtine,就是G0

  • G0仅用于负责调度G

  • G0不指向任何可执行的函数

  • 每个M都会有一个自己的G0

  • 在调度或系统调用时会使用M切换到G0,再通过G0进行调度

M0和G0都是放在全局空间的

我们来分析一段代码:

package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}
  1. runtime创建最初的线程m0和goroutine g0,并把2者关联。

  2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCSP构成的P列表

  3. 示例代码中的main函数是main.mainruntime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列。

  4. 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine。

  5. G拥有栈,M根据G中的栈信息和调度信息设置运行环境

  6. M运行G

  7. G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。

调度器的生命周期几乎占满了一个Go程序的一生,runtime.main的goroutine执行之前都是为调度器做准备工作,runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束


Go 协程调度器各场景调度过程解析

场景一:G1创建G2

P 拥有 G1,M1 获取 P 后开始运行 G1,G1 使用 go func() 创建了 G2,为了局部性 G2 优先加入到 P1 的本地队列。

场景二:G1执行完毕

G1 运行完成后 (函数:goexit),M 上运行的 goroutine 切换为 G0,G0 负责调度时协程的切换(函数:schedule)。从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute)。实现了线程 M1 的复用。


场景三:G2创建过多的 Goroutine

假设每个 P 的本地队列只能存 3 个 G。G2 要创建了 6 个 G,前 3 个 G(G3, G4, G5)已经加入 p1 的本地队列,p1 本地队列满了

场景四:G2本地队列满再创建 Goroutine

G2 在创建 G7 的时候,发现 P1 的本地队列已满,需要执行负载均衡 (把 P1 中本地队列中前一半的 G,还有新创建 G 转移到全局队列)

(实现中并不一定是新的 G,如果 G 是 G2 之后就执行的,会被保存在本地队列,利用某个老的 G 替换新 G 加入全局队列)


3.4 唤醒正在休眠的M

规定:在创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行

假定G2唤醒了M2,M2绑定了P2,并运行G0,但P2本地队列没有G,M2此时为自旋线程(没有G但为运行状态的线程,不断寻找G)

  • 先从全局队列中获取,没有再从其他线程中偷取(先全后偷