二、协程调度

2.1 线程和协程

2.1.1 线程调度开销

在操作系统中,线程是cpu调度的基本单位,然而,线程是由操作系统负责调度。线程切换时:

  1. 线程需要从用户态陷入内核态,栈指针指向内核态栈地址,同时保存上下文,如各种用户空间寄存器的数据(通用寄存器,段寄存器,标识寄存器等等)
  2. 操作系统调度算法找到需要调度的线程之后,又要从内核态切换回用户态,恢复上下文,栈指针指向新线程的用户态栈地址。
  3. 整个切换过程中,为了安全,需要引入更强的内核检查。

这些操作导致线程切换存在不小的开销

2.1.2 协程调度

既然线程切换要从用户态切换到内核态才能让操作系统进行调度,那么我们可以将其抽象的看作“用户态”线程和“内核态”线程,用户态线程1:1绑定在内核态线程上面完成CPU调度,为了降低陷入内核态的开销,我们可以创建多个用户态线程绑定到一个内核态线程上面,我们自己调度用户态线程,这样,用户态线程切换的时候就无需陷入内核了
于是,协程便应运而生,协程即是用户级线程,协程的切换无需操作系统参与,无需陷入内核态,只需用户在用户态实现协程的切换即可(切换栈指针,程序计数器,保存少数几个寄存器),切换开销较之线程大大减少

2.2 GMP调度模型

为了高效地调度协程,golang协程调度器经历了从GM模型到如今的GMP模型的转变。

2.2.1 GM模型

G: Goroutine的缩写,协程,类比于用户态线程,可以通过关键字go来创建
M: Machine的缩写,代表了内核态线程,CPU调度的基本单元
在GM模型早期,n个G协程绑定在一个内核态线程中,这样用户态的G可以通过Go的调度器高效切换,而无需底层内核态线程切换。此时n个G绑定在同一个M线程上(N:1)。
这也有明显的缺点,便是无法利用cpu的多核,为此便提出了采用中心化的调度器来将n个G调度到m个M上面(N:M),如图所示:

2.2.2 GMP模型

GM模型由于采用单一的调度器来将全局的G队列调度到m个M上面,会产生如下的问题:

  1. 锁竞争: 全局的协程队列,协程的各种操作都需要加锁
  2. 破坏数据局部性: CPU各个核心都有多级缓存,当M1上面的协程G1创建了协程G2,G1和G2地数据是相关的,如果继续再M1上面运行,能很好的利用现有的缓存。当M频繁的交接G时,会导致很差地数据局部性
  3. 系统调用开销: 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销
    为解决GM模型带来的上述问题,golang当前通过引入P处理器,形成了GMP模型
    P:  Processer的缩写,代表一个虚拟的处理器,每个P维护一个独立的Go协程队列,每个M需要获取到P才能运行P中的协程,程序中可以通过环境变量或者代码中设置GOMAXPROCS的值来修改P的数量,从而控制并行量

GMP模型调度过程如下:

  1. 每个P维护了一个本地的协程队列,以及P绑定了一个内核线程M,用于运行P协程队列中的G。
  2. 当P中队列满了,可以将部分协程放到全局协程队列。
  3. 如果P中没有可运行的协程,也可以从全局协程队列拉取可运行的协程。
  4. 如果与P绑定的M处于阻塞的系统调用状态,M会释放绑定的P,从而P可以绑定到另一个空闲的M并运行下一个G,而不必等待
  5. P维护了协程所需的内存资源,g申请内存时,先从P维护的内存块中分配,无需从全局内存块中加锁分配

GMP核心调度策略如下:
局部性原理: 由g1创建的子协程g2优先放在g2所在的P的本地队列上面,从而最大概率运行在同一个内核线程M上面,最大概率利用现有的缓存
线程复用: 避免频繁的创建、消耗内核线程M。M会尽可能地复用,空闲地M会进入休眠阶段,并保存在空闲M列表中
多核并行: 通过控制P地数量,可以调节最多有多少个协程并行执行,从而将多核的性能利用起来
work stealing 任务窃取: 当本线程M对应的P本地队列已经没有可运行的G时,同时全局队列也是空的,那么M会去其他P中窃取它一半的G过来,放到对应P的队列里面并执行
handle off 线程交接: 当M进行系统调用时,M和绑定的P会立即解绑,P则会唤醒一个休眠的M或创建一个新的M与其绑定
抢占式调度: 为了防止部分协程始终占用线程,导致另外一些协程无法被调度,造成饥饿问题,Go进程中有一个sysmon的系统线程,通过循环检查所有P的调度时间,如果某个P与上次调度时间大于10ms,表明某个G持续占用了这个P大于10ms了,会触发抢占式调度。