golang 协程调度原理
Go语言 最大的特点是提供简单易用的并发编程,这个并发的执行单元就是goroutine, 这个goroutine 是运行在用户态,并由GO自身来调度。调度器来决定谁来使用CPU资源,谁该让出CPU资源。 本文就来深入探讨一下Go的调度原理。
GMP调度模型
Go采用的是GMP调度模型。
核心概念
- G :即Goroutine ,使用关键字 go 即可创建一个协程来处理用户程序,如下所示:
1 go func() //创建协程来执行函数
- M :Machine 系统抽象的线程,代表真正的机器资源,目前最多10000,超过这个数量会panic.
- P :Process,虚拟处理器,代表goroutine的上下文,用于关联G和M;P的数量可以通过GOMAXPROCS设置,默认为CPU核数;
- 本地队列(local queue): 每个P关联有一个协程队列,该队列就是P的本地队列,新生成的协程放在该队列中,当该队列达到最大数量时,会将该队列的一般协程存入到全局队列中;
- 全局队列(global queue): 当本地队列达到最大数量时,多余的协程就会存在全局队列中;
调度原理
1 +-------------------- sysmon ---------------//------+
2 | |
3 | |
4 +---+ +---+-------+ +--------+ +---+---+
5 go func() ---> | G | ---> | P | local | <=== balance ===> | global | <--//--- | P | M |
6 +---+ +---+-------+ +--------+ +---+---+
7 | | |
8 | +---+ | |
9 +----> | M | <--- findrunnable ---+--- steal <--//--+
10 +---+
11 |
12 mstart
13 |
14 +--- execute <----- schedule
15 | |
16 | |
17 +--> G.fn --> goexit --+
从上图(来自雨痕GO源码阅读)我们可以看到在新建G时
- 当使用go 关键字执行函数时,会创建(首先查看P的freelist是否可以复用G,如果不能则新建)一个G(goroutine);
- 新创建的G,并不会添加到本地队列,而是添加到P关联的runnext中(runnext是一个指针变量,用来存放G的地址),runnext原来的G被放到本地队列中;
- 2.1 如果本地队列未满(最大256),则放置到队尾;
- 2.2 如果本地队列已满,则将本地队列的一半数量的G和runnext中原来的G存放到全局队列中;
- 唤醒或新建M来执行任务。
- 进入调度循环
- 尽力获取可执行的G,并执行
- 清理现场并且重新进入调度循环
运行时调度
- 为公平起见,有1/61的机会首先从全局队列获取到G,如果获取到则执行G;
- 如果没有机会从全局队列获取或者没有获取到G,则从P关联的runnext或者本地队列获取:
2.1 如果P的runnext有G,则执行该G; 2.2 如果P的runnext没有G,则从本地队列中获取G; - 如果第二步没有获取到,则执行以下步骤获取:
3.1 从关联P中获取,步骤同2,若获取到返回;
3.2 从全局队列中获取,若获取到返回;
3.3 调用 netpoll()取异步调用结束的G,该调用为非阻塞调用,若获取到则返回一个G,剩余的G放入到全局队列中;
3.4 从其他P中steal一半的G到本地队列,若获取到则返回;
3.5 如果处于垃圾回收标记阶段,则执行垃圾回收操作;
3.6 再次从全局队列中获取,若获取到返回;
3.7 调用 netpoll()取异步调用结束的G,该调用为阻塞调用,若获取到则返回一个G,剩余的G放入到全局队列中;
协程的状态
在go1.12.5/src/runtime/runtime2.go:15 定义有如下几个状态
_Gidle: 值(0) 刚刚被创建,还没有初始化;
_Grunnable: 值(1) 已经在运行队列中,只是此时没有执行用户代码,未分配栈;
_Grunning:值(2)在执行用户代码,已经不在运行队列中,分配了M和P;
_Gsyscall: 值(3)当前goroutine正在执行系统调用,已经不再运行队列中,分配了M;
_Gwaiting: 值(4) 在运行时被阻塞,并没有执行用户代码,此刻的goroutine会被记录到某处(例如channel等待队列)
_Gmoribund_unused: 值(5) 当前并未使用,但是已经在gdb中进行了硬编码;
_Gdead: 值(6) 当前goroutine没有被使用,可能刚刚退出或者刚刚被初始化,并没有执行用户代码;
_Genqueue_unused: 值(7) 当前并未使用;
_Gcopystack:值(8)正在复制堆栈,并未执行用户代码,也没有在运行队列中;
状态转换图(引自 goroutine调度)如下
1 +------------+
2 ready | |
3 +------------------ | _Gwaiting |
4 | | |
5 | +------------+
6 | ^ park_m
7 V |
8 +------------+ +------------+ execute +------------+ +------------+
9 | | newproc | | ---------> | | goexit | |
10 | _Gidle | ---------> | _Grunnable | yield | _Grunning | ---------> | _Gdead |
11 | | | | <--------- | | | |
12 +------------+ +-----^------+ +------------+ +------------+
13 | entersyscall | ^
14 | V | existsyscall
15 | +------------+
16 | existsyscall | |
17 +------------------ | _Gsyscall |
18 | |
19 +------------+
P的状态
_Pidle: 空闲状态,未与M绑定
_Prunning: 正在运行,已经与M绑定,M 正在执行P中G;
_Psyscall: 正在执行的G处于系统调用中;
_Pgcstop: runtime正在gc;
_Pdead: 当前P已经不再使用;
状态转换图(引自 goroutine调度)如下
1 acquirep(p)
2 不需要使用的P P和M绑定的时候 进入系统调用 procresize()
3new(p) -----+ +---------------+ +-----------+ +------------+ +----------+
4 | | | | | | | | |
5 | +------------+ +---v--------+ +---v--------+ +----v-------+ +--v---------+
6 +-->| _Pgcstop | | _Pidle | | _Prunning | | _Psyscall | | _Pdead |
7 +------^-----+ +--------^---+ +--------^---+ +------------+ +------------+
8 | | | | | |
9 +------------+ +------------+ +------------+
10 GC结束 releasep() 退出系统调用
11 P和M解绑
抢占
在golang程序启动时,会创建一个M(并没有关联P)来执行监控函数即sysmon,该函数就是用来完成抢占的;
- 该函数每次执行之间都会休眠一定的时间,休眠时间计算规则与每次是否抢占成功有关系:
1.1 如果连续未抢占成功的次数小于等于50,则每次休眠20us;
1.2 如果连续未抢占成功的次数大于50,则每次休眠次数翻倍;
1.3 最大休眠时间不得超过10ms; - 遍历所有的P,查看P的状态:
2.1 如果状态为_Psyscall(处于系统调用中)且执行时间已经超过了一个sysmon时间(最少20us),则进行抢占;
2.2 如果状态为_Prunning且执行时间已经超过了forcePreemptNS(10ms),则进行抢占;
阻塞/唤醒
channel阻塞...
系统阻塞...
参考
https://studygolang.com/articles/20991
https://studygolang.com/articles/11627
https://mp.weixin.qq.com/s/Oos-aW1_khTO084v0jPlIA
https://blog.csdn.net/u010853261/article/details/84790392
go夜读 golang 调度