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时

  1. 当使用go 关键字执行函数时,会创建(首先查看P的freelist是否可以复用G,如果不能则新建)一个G(goroutine);
  2. 新创建的G,并不会添加到本地队列,而是添加到P关联的runnext中(runnext是一个指针变量,用来存放G的地址),runnext原来的G被放到本地队列中;
  • 2.1 如果本地队列未满(最大256),则放置到队尾;
  • 2.2 如果本地队列已满,则将本地队列的一半数量的G和runnext中原来的G存放到全局队列中;
  1. 唤醒或新建M来执行任务。
  2. 进入调度循环
  3. 尽力获取可执行的G,并执行
  4. 清理现场并且重新进入调度循环
    调度原理

运行时调度

  1. 为公平起见,有1/61的机会首先从全局队列获取到G,如果获取到则执行G;
  2. 如果没有机会从全局队列获取或者没有获取到G,则从P关联的runnext或者本地队列获取:
    2.1 如果P的runnext有G,则执行该G; 2.2 如果P的runnext没有G,则从本地队列中获取G;
  3. 如果第二步没有获取到,则执行以下步骤获取:
    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.1 如果连续未抢占成功的次数小于等于50,则每次休眠20us;
    1.2 如果连续未抢占成功的次数大于50,则每次休眠次数翻倍;
    1.3 最大休眠时间不得超过10ms;
  2. 遍历所有的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 调度

Posts in this Series