Golang协程调度器原理与GMP设计思想

Golang 调度器由来

单进程时代的两个问题

  1. 单一执行流程、计算机只能一个任务一个任务处理
  2. 进程阻塞所带来的CPU浪费时间

多进程、多线程的问题

设计变得复杂
  1. 进程/线程的数量越多,切换成本越大,也就越浪费
  2. 多线程随着同步竞争(如锁/竞争资源冲突)
多进程、多线程的壁垒
  1. 高内存占用
  2. 高CPU调度消耗
协程(co-routine),引发的问题
  1. N:1 无法利用多个CPU,出现阻塞的瓶颈
  2. 1:1 跟多线程/多进程模型无异,切换协程成本代价昂贵
  3. M:N 能够利用多核,过于依赖协程调度器的优化和算法
调度器的优化
  1. Goroutine的优化,内存占用几KB,灵活调度切换成本低

Goroutine 调度器的GMP模型的设计思想

GMP模型简介

GMP
  1. G:goroutine 协程
  2. P:processor 处理器
  3. M:thread 内核线程
全局队列
  1. 存放等待运行的队列
P的本地队列
  1. 存放等待运行的G
  2. 数量限制(不超过256G)
  3. 优先将新建的G放在P的本地队列中,如果放满了会放在全局队列中
P列表
  1. 程序启动时创建
  2. 最多有GOMAXPROCS个(可配置)
M列表
  1. 当前操作系统分配到当前GO程序的内核线程数
P和M的数量
  1. P的数量问题
    1. 环境变量$GOMAXPROCS
    2. 在程序中通过runtime.GOMAXPROCS()来设置
  2. M的数量问题
    1. Go语言本身 是限定M的最大量是10000
    2. runtime/debug包中的SetMaxThreads函数来设置
    3. 有一个M阻塞,会创建一个新的M
    4. 如果有M空闲,那么就会回收或者睡眠

调度器的设计策略

复用线程(避免频繁的创建、销毁线程,而是对线程的复用)
  1. work stealing 机制 (当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程)
  2. hand off 机制(当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行)
利用并行
  1. GOMAXPROCS 设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行)
抢占
  1. 在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死
全局G队列
  1. 当M执行 work stealing从其他P偷不到G时,它可以从全局G队列获取G

go func() 经历了什么过程

image.png

image.png

  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中摘除,然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P
  6. 当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入全局队列中。

调度器的生命周期

  1. M0 是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0()中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G,在之后M0就和其他的M一样了
  2. G0 是每次启动一个M都会第一个创建的goroutine,G0仅用于负责调度的G,G0不指向任何可执行的函数,每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间,全局变量的G0是M0的G0

Go调度器GMP调度场景的全过程分析

场景一

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

image.png

场景二

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

image.png

场景三、四、五

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

假定G2唤醒了M2,M2绑定了P2,并运行G0,但P2本地队列没有G,M2此时为自旋线程

M2尝试从全局队列取一批G放到P2的本地队列。M2从全局队列取的G数量符合下面的公式:

n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))

image.png
image.png
全局队 已经没有G,那m就要执 work stealing(偷取):从其他有G的P哪 偷取 半G过来,放到的P本地队 。P2从P1的本地队 尾部取 半的G,本 中 半则只有1个G8,放到P2的本地队 并执行。

image.png
最多有GOMAXPROCS个 旋的线程(当前 中的GOMAXPROCS=4,所以 共4个P),多余的没事做线程会让他们休眠。

image.png
假定当前除 M3和M4为 旋线程,还有M5和M6为空闲的线程(没有得到P的绑定,注意我们这 最多就 只能够存在4个P,所以P的数 应该永远是M>=P, 部分都是M在抢占需要运 的P),G8创建 G9, G8进 阻塞的系统调 ,M2和P2 即解绑,P2会执 以下判断:如果P2本地队 有G、全局队 有G 或有空闲的M,P2都会 唤醒1个M和它绑定,否则P2则会加 到空闲P 表,等待M来获取可 的p。 本场景中,P2本地队 有G9,可以和其他空闲的线程M5绑定。

image.png
M2和P2会解绑,但M2会记住P2,然后G8和M2进 系统调 状态。当G8和M2退出系统调 时,会尝试获 取P2,如果 法获取,则获取空闲的P,如果依然没有,G8会被记为可运 状态,并加 到全局队 ,M2因为没有P的绑定 变成休眠状态( 时间休眠等待GC回收销毁)。