gmp模型

发布时间 2023-05-08 09:07:47作者: Steam残酷

gmp模型

为什么引入协程?

1.线程进程模型的弊端

  • 为了解决多线程多进程频繁切换,导致的CPU浪费
  • 多线程随着同步竞争(锁、竞争资源冲突),导致性能下降
  • 占用内存:进程4GB、线程4MB

2.协程的优点

协程是用户态实现的,不需要经过内核态和用户态之间的切换,更加轻量

一个goroutine:几KB

灵活调度,切换成本低

早期的Go调度器

img

全局go协程队列,存放着M个协程g

有N个线程去全局go协程队列获取G执行,每次获取都需要加全局锁(锁竞争)

GMP模型简介

① M每次都先去获取P ② P再去获取G

    一个线程M想执行协程G:M就要先去「空闲P队列」获取P,然后P和M绑定,之后P再依次去「本地协程队列、全局协程队列」获取G,将G交给线程M去执行

G:协程

M:thread线程(内核线程)

有一个M阻塞,会先从空闲M队列获取新的M,若没有,再去创建一个新的M

如果有M空闲,那么就会回收or放回空闲M队列

P:processor处理器(每个P具有自己的协程本地队列,P管理了协程队列中的G)

P的本地队列存放等待运行的G

优先将新创建的G存放在P的本地队列,本地队列满了,才会放到全局队列

除了P的本地队列,还有一个全局队列

img

数量

M的数量:GO语言本身限定M的最大量是1w,一般设置为核心数(runtime/debug包中的SetMaxThreads函数来设置)

P的数量问题

环境变量$GOMAXPROC,一般设置为 = 内核线程数/2

在程序中通过runtime.GOMAXPROCS()来设置

G的数量问题

调度器的设计策略

复用线程、利用并行、抢占、全局G队列

work-stealing机制

概述

  • 场景:当本线程⽆可运⾏的G时,尝试「从其他线程绑定的P偷取G」

  • 获取的流程:

    • 从本地队列获取任务

    • 从全局队列获取任务

    • 从其它M的本地队列窃取任务

      case1:从全局队列中steal协程G

此时,M2内核线程绑定的P,没有协程G了,M1的P也没有协程G,但是全局队列中有空闲的G

img

    M2去全局队列中steal协程G3,存放在自己的P中

img

case2:从其他P中steal协程G

  1. M1和P绑定,G1正在运行,M2线程是空闲的

    img

  2. 此时M2想执行,那么将会从M1绑定的P的本地队列中steal协程

    img

4.2. hand-off机制(切换机制)
概述

  • 场景:当本线程因为G进⾏系统调⽤阻塞时,线程M释放绑定的P,把P转移给其他空闲的线程执⾏

  • 流程:当G阻塞时,与该G绑定的M也会陷入阻塞,在阻塞之前,会先把M绑定的P转移给其他M',然后将CPU切换到M'去执行

  • 假设,此时M1绑定的P队列中正在执行的协程G1,执行了一个阻塞操作,比如read

    img

  • hand-off执行过程

    2.1. 首先,创建一个线程or唤醒一个睡眠状态的线程,如M3
    

    img

    2.2. 将M1绑定的P,迁移到M3上

img

    2.3. 将G1与M1进行绑定,此时
   
            ① M1阻塞等待read事件的返回
   
            ② 内核线程切换到M3,通过P去获取本地队列中的G2,继续执行
   
    这样就完成了hand-off机制

img

Go指令的调度流程

1~2步骤:执行go func(),先创建一个G,优先放入P的本地队列,如果满了,放入全局队列(此时P已经存放到P的本地队列)

img

3步骤:此时M获取G:优先从M的本地队列P中获取G,如果为空,依次去全局队列、其他M的本地队列P去偷取G。(当获取P成功后,将P与M进行绑定)

img

4~6步骤:

    之后,M1调度协程G,执行G的func()函数(备注,每个G的运行时间不超过10ms,防止其他G被饿死)

img

此时,G执行,执行分为以下情况

    case1:G的执行时间片超时,即执行时间大于10ms,G会重新放到M1绑定的本地队列P中   

img

    case2:func函数执行了systemcall\阻塞(如read、write),则会获取新的M(从休眠M空闲队列or创建一个M)

img

            若此时,M1的P队列还有很多G等待执行,因为M在执行G1时调用了systemcall\阻塞操作,所以,M1的P队列将交给新的M接管(hand-off机制)

            执行完后的效果:①M1和G1捆绑 ②M3接管了M1的P

img

    之后,与M1绑定的G1,因为处于阻塞状态,所以下一步会解除绑定关系,此时①M1销毁或者存放回休眠队列M中 ②G1放回全局队列中

调度器的生命周期

M0

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

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

  3. 负责执行初始化和启动第一个G

  4. 执行第一个G之后,M0就和其他的M一样了

G0

    1. 每次启动一个M,都会第一个创建的G

    2. G0仅用于负责调度G

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

    4. 每一个M都会有一个自己的G0

    5. 在调度或系统调用时就会使用M会切换到G0,来调度

    6. M0的G0会放在全局空间

img

img

场景分析

7.1.(场景1)G1创建G3
此时,存在M1、M2,每个M绑定了P,P上分别有一个G

img

此时,G1创建了G3:满足局部性,即G1创建的G3,应该存放在M1和G1所在的P上(如下图所示)

img

7.2.(场景2)G1执行完毕
当M1绑定的G1执行goexit(),G1执行完毕:M1继续获取G,优先从本地的P获取G

img

7.3.(场景3-4-5)
场景3:G2开辟过多的G

img

场景4:G2本地满,再创建G7

    1. 将本地队列P拆分成2段

    2. 将 前一段和G7 打散,再存放在本地队列中

img

场景5:G2本地未满,创建G8:直接将G8放到本地队列中

img

7.4.(场景6)唤醒正在休眠的M
M1与P1绑定,P1获取了G,此时,G2创建了G8

img

G2创建一个协程G8的时候

  1. 首先尝试去休眠线程队列中,唤醒一个休眠的线程
  2. 唤醒之后,将M从休眠线程队列中取出来

img

  1. 此时,被唤醒的M2,将尝试与新的P绑定

     一旦M2绑定了空闲的P,此时会调用G0
    
     自旋线程:M2的本地队列P2中没有G && M2正在运行G0去寻找G
    

    img

7.5.(场景7)被唤醒的M2从全局队列中获取批量G
获取G的个数 N = min{ len(GQ)/GOMAXPROCS+1, len(GQ/2) } , GQ:全局队列的总长度

img

7.6.(场景8)M2从M1中批量偷取G
假设此时全局队列中没有G,M2就需要从其他M的P中获取G(批量个数N=后半段)

img

7.7.(场景9)自旋线程的最大限制
自旋线程 + 执行线程 <= GOMAXPROCS

img

    此时,假设新创建了M5,因为GOMAXPROCS=4,不能在创建自旋线程了,所以,M5会被放入休眠线程队列1

img

7.8.(场景10)G发生系统调用/阻塞

  1. M2的P2执行G8,此时G8执行了systemcall 阻塞(此时M2绑定了G8)

img

  1. 因为此时M2的P2中存在G9,因为M2已经全权为G8负责了,为了不能阻塞G9的运行,所以P2会重新寻找有没有其他的M能继续为它执行(根据休眠线程队列中是否有空闲线程,分为两种情况)

    2.1. 有M
    
            P2将从空闲线程队列中取出M5,将P2挂到M5上(M5和P2组成新的MP)
    
​             ![img](https://img-blog.csdnimg.cn/d4ccedecffe74e289b2c58c3e60bb8b2.png) 

    2.2. 无M:将P放入空闲队列
​            ![img](https://img-blog.csdnimg.cn/818464617f614faa80061d025db29482.png) 

7.9.(场景11)G发生系统阻塞,再变为非阻塞

    M2中的G8,此时变为非阻塞,执行过程见下

    1. M2中记录了上一次绑定的P,P是P2,即优先获取原配

img

     2. M2发现P2已经被绑定给了M5,因此,M2是抢不过M5的

img

    3. M2会先尝试从空闲P队列中寻找P

img

    4.空闲P队列没有P,此时M2放弃绑定P,将执行释放逻辑:① M2放到空闲线程队列 ②G8放到全局P队列

img

Golang系统调用与阻塞处理 ?

8.1. 阻塞
8.1.1. Go阻塞的4种场景
由于原子、互斥量、通道操作调用导致 Goroutine 阻塞,调度器将把当前阻塞的 Goroutine 切换出去,重新调度 本地P队列 上的其他 Goroutine
由于 网络请求、网络IO 操作导致 Goroutine 阻塞。Go 程序提供了网络轮询器(NetPoller)来处理网络请求和 IO 操作的问题,其后台通过 kqueue(MacOS),epoll(Linux)或 iocp 来实现 IO 多路复用。通过 使用 NetPoller 进行网络系统调用,调度器可以防止 Goroutine 在进行这些系统调用时阻塞 M。这可以让 M 执行 P 的 LRQ 中其他的 Goroutines,而不需要创建新的 M。执行网络系统调用不需要额外的 M,网络轮询器使用系统线程,它时刻处理一个有效的事件循环,有助于减少操作系统上的调度负载。用户层眼中看到的 Goroutine 中的“block socket”,实现了 goroutine-per-connection 简单的网络编程模式。实际上是通过 Go runtime 中的 netpoller 通过 Non-block socket + I/O 多路复用机制“模拟”出来的。
当调用一些系统方法的时候(如文件 I/O),如果系统方法调用的时候发生阻塞,这种情况下,网络轮询器(NetPoller)无法使用,而进行系统调用的 G1 将阻塞当前 M1。调度器引入 其它M 来服务 M1 的P。
如果在 Goroutine 去执行一个 sleep 操作,导致 M 被阻塞了。Go 程序后台有一个监控线程 sysmon,它监控那些长时间运行的 G 任务然后设置可以强占的标识符,别的 Goroutine 就可以抢先进来执行。
8.2. 系统调用与调度机制
8.2.1.异步系统调用
异步系统调用:网络IO

结论:当G1执行异步系统调用时,会发生阻塞,该阻塞动作,①不需要创建新的M,② G会和MP分离(G挂到netpoller),阻塞事件会由NetPoller接管

刚开始,G1在M上运行,此时G1想去执行「网络系统调用」

img

G1执行「网络系统调用」后,发生阻塞,此时,将G1挂在到NetPoller上&&监听G1网络系统调用的返回,M会从P队列中找到新的协程运行。(注:不需要创建新的M)

img

当G1的「网络系统调用」返回后,G1会被移回到P队列中

img

同步系统调用

同步系统调用:读写文件

结论:当G1执行同步系统调用时,G2会发生阻塞,同时会导致与G1绑定的M1也阻塞,之后,MG 会和P分离(P另寻M),当M从系统调用返回时,不会继续执行,而是将G放到run queue

刚开始,G1在M上运行,此时G1想去执行「同步系统调用」,G1会阻塞

img

同步调用,当G1阻塞后,会导致M1也阻塞,具体的执行动作是:G1和M1绑定在一起&&陷入阻塞,M1绑定的P会转移给新的M

img

阻塞的系统调用完成后:G1可以移回 LRQ 并再次由P执行。如果这种情况需要再次发生,M1将被放在旁边以备将来使用

img