译自:Scalable Go Scheduler Design Doc
在GMP模型出现之前,golang的协程调度只有M(工作线程)和G(goroutine)。协程切换全部依赖于全局的runq,会产生一些问题:
- 单一的全局锁和集中的状态:全局锁控制全部的协程相关操作,如协程的创建,结束和重新调度等。这会导致高并发场景下加锁解锁的消耗过高。
- 协程的切换问题:M之间经常会切换可执行的G,这会导致额外的开销以及延迟的增加。
- 每个M的内存缓存:当前的内存缓存和其他缓存(堆栈等)和所有的M都相关联。实际上只有正在运行G的M才需要缓存(阻塞在系统调用中的M不需要缓存),实际上正在运行G的M和所有M的比例为1:100,这会导致过高的资源消耗(每个M最多可占用2M)和较差的局部性。
- 频繁的阻塞和唤醒:在系统调用的场景下,工作线程会经常被阻塞和唤醒,这会导致过高的开销。
解决方案
引入一个P,并在P上实现work-stealing调度(从其他的P处偷取要执行的G)
现在GMP中,M代表工作线程,P代表处理器(执行G所需要的资源),当M正在执行P的时候,M必定和一个P关联,当M处于空闲或系统调用的时候,M就需要一个可用的P了。
P的数量固定等于变量GOMAXPROCS 的值,所有的P都维护在一个数组中(这是实现work-stealing的前提)。GOMAXPROCS空值P数组的大小(这会导致stop/start the world)
当M需要执行Go代码时,必须从数组中取出一个P,在M执行结束的时候还需要把P存回数组。
调度
当一个新的G被创建出来或者一个已存在的G重新变为可执行状态时,当前P会将G放入P自己的可运行G队列(runq)。当P执行完一个G时,首先会尝试从自己runq中获取G,如果为空的话则尝试从其他P处偷取一半的G。
系统调用
当M创建一个G或者M陷入系统调用的时候,必须要保证有一个空闲的M可以执行这个G(如果没有的话说明所有的M都在工作中)
这里有两个选项,一是立刻阻塞当前M并唤醒另一个M,二是使用自自旋。阻塞的性能影响和自旋的CPU空转之间的冲突是无法避免的。这里选择的是自选方案。但是在GOMAXPROCS=1的时候不会有影响。
自旋分为两个阶段:
- 空闲的M和它关联的P自旋来寻找可运行的G。
- 没有关联P的M自旋来寻找可用的P。
自旋的M最多有GOMAXPROCS(包括1和2)。当存在第2种空闲M的时候,第1种空闲M不会阻塞。
这句话可以理解为先优先阻塞第2种M,没有第2种M的时候才会阻塞第一种M。
当新的G被创建,或者M陷入系统调用,或者M从空闲变为繁忙的时候,需要确保至少有一个自旋的M存在(不然就是全部的P都在工作中)。这样可以保证不会存在能够运行却没有运行的runnable g,也可以避免同时有过多的M阻塞/唤醒。自旋在大多数情况下都是被动的(让出给OS),但有一些时候也是主动的自旋。
终止/死锁 检测
终止/死锁 检测在分布式系统中问题更严重,通常的想法是只有所有的P都处于空闲状态时才进行检查(通过空闲P的全局原子计数器)。这允许进行更昂贵的检查,包括每个 P 状态的聚合。
LockOSThread
此功能不是性能关键
- 锁定的G变为不可运行状态,M立即将P返回给空闲队列,并唤醒另一个M,然后阻塞。
- 锁定的G变为可运行状态并列排到队列头部。当前M把自己的P和锁定的G交给该G关联的M并unblock。当前M变为空闲状态。
Idle G
此功能不是性能关键
存在一个全局的G的空闲队列,当一个M执行数次work-stealing失败后会从该全局队列中获取空闲G。