【Golang】GMP模型

  • ~3.12K 字
  1. 1. 调度器由来
    1. 1.1. 改进
    2. 1.2. Golang对协程的处理
  2. 2. GMP模型
    1. 2.1. 简介
    2. 2.2. 调度器的设计策略
      1. 2.2.1. 复用线程
      2. 2.2.2. 利用并行
      3. 2.2.3. 抢占
      4. 2.2.4. 全局G队列
    3. 2.3. Go指令的调度过程
    4. 2.4. 调度器的生命周期
      1. 2.4.1. 特殊的调度器
    5. 2.5. 可视化GMP编程
      1. 2.5.1. 网页可视化
      2. 2.5.2. GO DEBUG
  3. 3. 使用场景
    1. 3.1. 场景1:G1创建G3
    2. 3.2. 场景2:G1执行完毕
    3. 3.3. 场景3:G2开辟过多的G
    4. 3.4. 场景4:唤醒正在休眠的M
    5. 3.5. 场景5:被唤醒的M从全局队列中获取批量G
    6. 3.6. 场景6:M2从M1中偷取G
    7. 3.7. 场景7:自旋线程的最大限制
    8. 3.8. 场景8:G发生系统调用或阻塞
    9. 3.9. 场景9:G发生系统调用/非阻塞

参考视频:Golang深入理解GPM模型

调度器由来

早期通过CPU调度器调度进程/线程的切换,将一个时间切为若干时间片,每个时间片跑不同的进程,实现并发问题,解决阻塞问题

但是CPU切换进程/线程的成本开销较大,需要保存当前进程数据,再从内存中把另一个进程的数据载入CPU等操作,进程/线程的数量越多,切换成本越大,越浪费

而且多线程还会出现资源竞争之类的问题,设计越来越复杂

改进

将一个线程分为内核空间与用户空间,将用户部分称为协程(co-routine),CPU只会看到内核空间,因此还认为是同一个线程,而用户空间可以通过协程调度器来让线程绑定多个协程,轮询调度(1:n关系),这样就避免了CPU切换线程的高消耗操作

但若有一个协程阻塞,因为轮询关系,会影响其他协程运行

继续改进可以让多个内核线程通过调度器来绑定多个协程(n:m)关系,可以分给多个CPU核来控制多个线程,这样只要协程调度器开发的好,CPU利用率高,效率就会高

Golang对协程的处理

Golang有自己的协程Goroutine

早期Goroutine存在一个全局协程队列中,当一个线程M想要执行Gorotine,就会拿队头的G执行,剩余的往队头移动

注意,为了并发安全,创建、销毁和调度队列时会需要每个M获取锁,形成激烈的锁竞争,而且M转移G也会造成延迟或额外的开销

GMP模型

简介

  • G:Goroutine协程
  • M:thread线程
  • P:processor处理器

P中包含了每个goroutine的资源,所以M要先获取P,才能运行G

  • 全局队列中存放等待运行的G
  • P的本地队列存放当前P等待运行的G,有数量限制,不超过256个G
  • 优先将新的G放入P的本地队列,若本地队列满则放在全局队列
  • 一个程序中有GOMAXPROCS个P,称为P列表,程序启动时创建,可以在环境变量中设置
  • 当前操作系统分配给当前go程序的内核线程,组成了M列表,M的数量是动态的,由Golang限定了最大量10000个,可以通过runtime/debug包中的SetMaxThreads函数设置

调度器的设计策略

复用线程

  • work stealing机制

若一个P的本地队列为空,M会向其他本地队列偷取一半G放入自己的本地队列

  • hand off机制

当当前线程因为正在运行的G因为系统调用而阻塞时,CPU会创建或唤醒一个线程,并接管和之前的M绑定的P,当前G继续在原M阻塞,原M此时不工作,为睡眠状态,若G阻塞完毕还需要执行,则会加入其他本地P队列,若不执行,M会直接销毁

利用并行

使用GOMAXPROCS限定P的个数,可限定为CPU核数/2

有几个P表示最多有几个G同时运行

抢占

一个G最多占用CPU10ms,若超时新的G可以抢占旧的G,保证了并发,防止其他G被饿死

全局G队列

全局队列是加锁的,因此访问全局队列效率要慢一些

在work stealing中,若M无法从其他本地队列偷取G,它会向全局队列获取G运行

Go指令的调度过程

执行go func()后,会尝试创建一个G,会优先尝试放入执行当前函数的线程的P的本地队列中,若本地已满,则放入全局队列

接下来M通过P尝试获取G,若成功则放入M运行:

  • 若P本地队列为空,则从其他P队列偷取G
  • 若其他P队列为空,则从全局队列获取G

获取G后,执行调度并运行定义的逻辑代码,当时间片超时会返回放入本地队列

若执行时阻塞,为了提高CPU利用率,从休眠M队列中获取一个M或创建一个M,接管当前正在阻塞G的P,原M和G捆绑,当G阻塞结束后,M会被销毁或加入休眠队列,G会进入某个P本地队列或全局队列

调度器的生命周期

特殊的调度器

  • M0:启动程序后编号为0的主线程
    • 存在于全局变量runtime.m0中,不需要在堆上分配
    • 负责初始化操作和启动第一个G
    • 启动第一个G后,M0和其他M一样
  • G0:每次启动一个M后,第一个创建的Goroutine
    • G0仅用于负责调度G
    • G0不指向任何可执行的函数

可视化GMP编程

网页可视化

写一个简单的trace编程代码

go build并运行后,生成一个trace文件,通过go tool trace 文件名开启trace文件,可以在浏览器上查看GMP的分配

GO DEBUG

使用GODEBUG=schedtrace=1000 ./可执行文件来跟踪trace,这里只需要编译后生成的二进制文件即可,数字代表每多少ms打印一次

  • SCHED:调试的信息
  • 显示的时间:从程序启动到输出经历的时间
  • gomaxprocs:P的数量,默认和CPU核心数一致
  • idleprocs:处于空闲状态的P的数量
  • threads:全部的线程数,包括M0和GODEBUG调试线程
  • spinningthreads:处于自旋状态的线程数量
  • idlethread:处于空闲状态的线程数量
  • runqueue:全局G队列中G的数量
  • [0,0]:每个P的local queue本地队列中,目前存在G的数量

使用场景

场景1:G1创建G3

G1因为某些需求需要创建一个G3,由于需要保证局部性,最好放在同一个M下执行,此时G3优先加入G1所在的本地队列

场景2:G1执行完毕

当G1执行完毕,调用goexit()时,M会调度G0,加载后通过G0优先把本地队列中的G调过来,这里G0负责协程的切换

场景3:G2开辟过多的G

假设P1的本地队列最多存4个G,此时存满了,但G2开辟了多于4个G,为了满足局部性,多余的G会尝试取当前P队列的二分之一,将队头的二分之一打乱,放入全局队列,同时将新创建的G放入全局队列,将后二分之一前移

之后若未满情况下创建新G,会放入本地队列

场景4:唤醒正在休眠的M

当创建一个G时,它会尝试唤醒休眠M队列中的M(若有)

唤醒会将M从队列拿出来,M会寻找一个P将其绑定,绑定后调用G0,若该M正在运行G0,且队列中没有G,称该线程为自旋线程,它会试图抢夺其他队列中的线程

场景5:被唤醒的M从全局队列中获取批量G

一个自旋线程会首先尝试从全局队列中获取G,获取的数量满足如下公式:,至少保证取一个G

从全局队列取G到P的过程叫从全局队列到P本地队列的负载均衡

场景6:M2从M1中偷取G

当M把本地以及全局G都消耗完了,且另一个M还在处理本地队列,此时该M继续变为自旋线程,全局队列空的情况下,该M会从其他本地队列偷取

偷取时,会将另一个本地队列中的G分为二分之一,偷取后二分之一段的G

场景7:自旋线程的最大限制

自旋线程是有限制的:自旋线程+执行线程<=GOMAXPROCS

场景8:G发生系统调用或阻塞

若此时某个M上的G发生系统调用或阻塞,为了不耽误其他G,该G会和对应的M绑定,这个M上的P首先寻找是否有空闲M,若没有会进入空闲P队列,等待空闲M,若有则会唤醒一个新M,该P和新M绑定

注意:P不会和自旋线程绑定,因为自旋线程是抢占G的,它们拥有P

场景9:G发生系统调用/非阻塞

若此时上面说的G系统调用结束或非阻塞,M需要执行G,此时M会记录之前绑定的P,并试图获取原来的P,若抢占失败,会去看空闲P队列有无,若没有,M会放弃绑定P,将G放入全局P队列,M放入休眠线程队列

赞助喵
非常感谢您的喜欢!
赞助喵