GMP模型详解

​ 前几天上青训营的课时老师在将Go的垃圾回收时提到了GMP模型,但并没有作深入讲解,后面的课似乎也不会在提及了,故参考网上博文以及自己的一些理解来总结一下。在此之前,Goroutine想必学过Go的人都是必知的,它是类似线程一样的可调度单位,也是使得Go享誉天生支持高并发这一名号的缘由。通过创建多个Goroutine来处理任务,将这些Goroutine分配,负载,调度到处理器采用的就是GMP模型。

0 了解Goroutine

​ Goroutine是golang中实现的协程,是用户级线程。具有以下特点

  • 相比线程,启动代价更小,只需要很小的栈空间(2KB左右)
  • 栈的大小可以动态伸缩
  • 工作在用户态,切换成本小
  • 与线程关系是n:m,即可以在n个系统线程上多工调度m个Goroutine

​ 与线程,进程的区别

​ 在引入线程的操作系统中,线程是独立调度的基本单位,并且线程切换与通信的开销要比进程小,同一进程的线程切换不会引起进程切换;进程是独立调度的基本单位,进程切换和通信开销比较大,不同进程的线程切换回引发进程切换。

思考:进程,线程的通信方式?切换流程?可以扩展到协程

1 线程模型

​ 线程创建、管理、调度等采用的方式称为线程模型。线程模型一般分为以下三种:

  • 内核级线程(Kernel Level Thread)模型
  • 用户级线程(User Level Thread)模型
  • 两级线程模型,也称混合型线程模型

​ 他们之间的最大差异在于用户级线程与内核调度实体KSE之间的对应关系。KSE可以理解为内核级线程。用户级线程即协程,由应用程序创建,必须要和内核线程绑定之后才可以执行。

​ 线程由CPU调度是抢占式的,协程由用户调度是协作式的。一个协程让出CPU后,才执行下一个协程

特性 用户级线程 内核级线程
创建者 应用程序 内核
操作系统是否感知存在
开销成本 创建成本低,上下文切换成本低,上下文切换不需要硬件支持 创建成本高,上下文切换成本高,上下文切换需要硬件支持
如果线程阻塞 整个进程将被阻塞。即不能利用多处理来发挥并发优势 其他线程可以继续执行,进程不会阻塞
案例 Java thread, POSIX threads Window Solaris

1.1 内核级线程模型

image-20220517192622137.png

​ 该模型用户线程(协程)和内核线程(线程)是一对一关系(1:1)。线程的创建,销毁,切换工作都是由内核完成。应用程序只能调用编程接口。

优点:

  • 多处理器系统中,内核能够并行处理同一进程内的多个线程
  • 一个线程被阻塞,不会影响进程运行,可以切换到其他线程继续运行

缺点:

  • 线程的创建和删除都需要CPU参与,成本大

1.2 用户级线程模型

image-20220517191252465.png

协程和线程是多对一的关系

线程的创建、销毁以及线程之间的协调、同步等工作都是在用户态完成。也就是应用程序的线程库来完成。内核对这些都是无感知的,此时的调度都是基于进程的。

优点:

  • 创建,销毁,切换的开销要比内核线程少得多,因为保存线程状态的过程和调用程序都只是本地过程
  • 线程能够利用的表空间和堆栈空间比内核级线程多

缺点:

  • 线程发生I/O或页面故障引起的阻塞时,如果调用阻塞系统调用则内核由于不知道有多线程的存在,而会阻塞整个进程从而阻塞所有线程, 因此同一进程中只能同时有一个线程在运行
  • 资源调度按照进程进行,多个处理机下,同一个进程中的线程只能在同一个处理机下分时复用

1.3 两级线程模型

image-20220517192120208.png

​ 协程和线程的关系是多对多的关系。充分吸收了上述两个模型的优点,尽量规避缺点。

1.4 Golang线程模型

Golang在底层实现了混合型线程模型。M即系统线程,由系统调用产生,一个M关联一个KSE,即两级线程模型中的系统线程。G为Groutine,即两级线程模型的的应用及线程。M与G的关系是N:M。

image-20220517192730612.png

2 GMP模型概览

image-20220517193300649.png

G-M-P分别代表:

  • G - Goroutine,Go协程,是参与调度与执行的最小单位
  • M - Machine,指的是系统级线程
  • P - Processor,指的是逻辑处理器,P关联了的本地可运行G的队列(也称为LRQ),最多可存放256个G。

GMP调度流程大致如下:

  1. 线程M想运行任务就需要获取P,即与P关联
  2. 然后从P的本地队列(LRQ)获取G
  3. 若LRQ中没有可运行的G,M 会尝试从全局队列(GRQ)拿一批G放到P的本地队列
  4. 若全局队列也未找到可运行的G时候,M会随机从其他 P 的本地队列偷一半放到自己 P 的本地队列。
  5. 拿到可运行的G之后,M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

2.1 调度的生命周期

image-20220517195017029.png

​ 说明:

  1. runtime 创建最初的线程 m0 和 goroutine g0,并把两者进行关联(g0.m = m0)
  2. 调度器初始化:设置M最大数量,P个数,栈和内存出事,以及创建 GOMAXPROCS个P
  3. 示例代码中的 main 函数是 main.main,runtime 中也有 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 退出程序。

2.2 调度的流程状态

image-20220517201016356.png

可以看出:

  • 每个P有个局部队列,局部队列保存待执行的goroutine(流程2),当M绑定的P的的局部队列已经满了之后就会把goroutine放到全局队列(流程2-1)
  • 每个P和一个M绑定,M是真正的执行P中goroutine的实体(流程3),M从绑定的P中的局部队列获取G来执行
  • 当M绑定的P的局部队列为空时,M会从全局队列获取到本地队列来执行G(流程3.1),当从全局队列中没有获取到可执行的G时候,M会从其他P的局部队列中偷取G来执行(流程3.2),这种从其他P偷的方式称为work stealing
  • 当G因系统调用(syscall)阻塞时会阻塞M,此时P会和M解绑即hand off,并寻找新的idle的M,若没有idle的M就会新建一个M(流程5.1)。
  • 当G因channel或者network I/O阻塞时,不会阻塞M,M会寻找其他runnable的G;当阻塞的G恢复后会重新进入runnable进入P队列等待执行(流程5.3)

2.3 GMP调度过程中阻塞

GMP模型的阻塞可能发生在下面几种情况:

  • I/O,select
  • block on syscall
  • channel
  • 等待锁
  • runtime.Gosched()

用户态阻塞

​ 当goroutine因为channel操作或者network I/O而阻塞时(实际上golang已经用netpoller实现了goroutine网络I/O阻塞不会导致M被阻塞,仅阻塞G),对应的G会被放置到某个wait队列(如channel的waitq),该G的状态由_Gruning变为_Gwaitting,而M会跳过该G尝试获取并执行下一个G,如果此时没有runnable的G供M运行,那么M将解绑P,并进入sleep状态;当阻塞的G被另一端的G2唤醒时(比如channel的可读/写通知),G被标记为runnable,尝试加入G2所在P的runnext,然后再是P的Local队列和Global队列。

image-20220517210310201.png

系统调用阻塞

​ 当G被阻塞在某个系统调用上时,此时G会阻塞在_Gsyscall状态,M也处于 block on syscall状态,此时的M可被抢占调度:执行该G的M会与P解绑,而P则尝试与其它idle的M绑定,继续执行其它G。如果没有其它idle的M,但P的Local队列中仍然有G需要执行,则创建一个新的M;当系统调用完成后,G会重新尝试获取一个idle的P进入它的Local队列恢复执行,如果没有idle的P,G会被标记为runnable加入到Global队列。

image-20220517211328598.png

3 总结

  1. Golang的线程模型采用混合型线程模型,线程与协程对应关系是N:M
  2. 每一个M都需要与一个P绑定,P拥有本地可运行G队列,M是执行G的单元,M获取可运行G流程是先从P的本地队列获取,若未获取到,则从其他P偷取过来(即work steal),若其他的P也没有则从全局G队列获取,若都未获取到,则M将处于自旋状态,并不会销毁。
  3. 当执行G时候,发生通道阻塞等用户级别阻塞时候,此时M不会阻塞,M会继续寻找其他可运行的G,当阻塞的G恢复之后,重新进入P的队列等待执行,若G进行系统调用时候,会阻塞M,此时P会和M解绑(即hand off),并寻找新的空闲的M。若没有空闲的就会创建一个新的M。
  4. Work Steal和Hand Off保证了线程的高效利用。

G-M-P高效的保证策略有:

  • M是可以复用的,不需要反复创建与销毁,当没有可执行的Goroutine时候就处于自旋状态,等待唤醒
  • Work Stealing和Hand Off策略保证了M的高效利用
  • 内存分配状态(mcache)位于P,G可以跨M调度,不再存在跨M调度局部性差的问题
  • M从关联的P中获取G,不需要使用锁,是lock free的

参考

GMP模型 — 深入Go语言之旅 (cyub.vip)

[典藏版] Golang 调度器 GMP 原理与调度全分析 | Go 技术论坛 (learnku.com)

线程的3种实现方式–内核级线程, 用户级线程和混合型线程_CHENG Jian的博客-CSDN博客_用户线程和内核线程


GMP模型详解
https://2w1nd.github.io/2022/05/14/Go/GMP模型详解/
作者
w1nd
发布于
2022年5月14日
许可协议