go 中 goroutine 的调度机制
目前 go 语言里面的调度机制采用是一种 M:N 的 GMP 的调度模型,其中:
G (Goroutine)是用户级的轻量级线程——协程,占用内存更小(几kb)调度更灵活(runtime调度);
M(Machine) 是对系统内核级线程的封装,数量对应真实的 CPU 数(真正干活的对象);
P (Processor)是 G 和 M 的调度对象——协程调度器,用来调度 G 和 M 之间的关联关系,其数量可以通过 GOMAXPROCS()设置,默认为核心数。
GM 调度模型
在 go1.1 版本之前,采用的调度模型是 GM 模型,那时候没有 P,只有一个全局 G 队列。GM 调度模型通过系统线程直接对 goroutine 进行调度,调度的执行流程如下:
首先获取一个全局锁;
获得全局锁成功后就将当前 goroutine 状态从 running 改为 runnable;
然后再调用一个 gput 方法将当前 goroutine 的运行状态保存起来,便于后续的使用;
调用 nextgandunlock 方法来寻找下一个可运行的 goroutine,并且释放全局锁给其他调度使用;
获取到下一个待运行的 goroutine 后,就将它的运行状态修改为 running;
最后调用 runtime.gogo 方法来运行刚获取到的 goroutine。
GM 模型的问题
由于 GM 调度模型只有一个单一的全局锁,这就导致锁的竞争很严重
同时多个系统线程之间也会经常交接可运行的 goroutine,不可避免的造成了许多额外的开销和延迟
所以就有了我们现在的 GMP 模型。
GMP 调度模型
GMP 模型是在 GM 模型的基础上又增加了一层协程调度层 P,P 的数量默认是系统CPU 的核数
每个协程调度器都有自己的本地队列,大幅减轻了对全局队列的依赖,锁的竞争大大减少,同时为了达到每个 P 上本地队列的一个平衡,GMP 模型中还实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从其他队列中窃取可运行的 G 来运行,减少空转
每个协程调度器都会跟系统线程进行绑定,然后将 M 个 goroutine 调度到 N 个系统线程上进行执行,这样就实现了 M:N 的调度策略
如果某个 goroutine 由于等待 I/O 操作或者等待锁等原因阻塞了或者主动让出执行权时,那么该调度器就会与当前线程解绑,然后与其他系统线程进行绑定,进而来执行调度器本地队列中其他处于就绪状态的 goroutine
M 和 P 的数量关系
一般情况下 M 与 P 是一对一绑定的,但是 M 与 P 的整体数量没有绝对关系,如果一个 M 阻塞,P 就会去创建或者切换另一个 M ,所以,即使 P 的默认数量是1,也有可能会创建很多个 M 出来。通过 P 可以将 M 个 goroutine 映射到 N 个 Machine 上进行执行,这就是 M:N 的调度策略
协程死循环和阻塞时如何调度
协程陷入死循环
在 go1.14 版本之前,如果一个协程进入死循环,那么它就不会主动让出 CPU,一直占用 CPU,无法执行下一个协程;但是在 go1.14 版本及以后,由于实现了基于信号的抢占式调度,一个 goroutine 最多占用 CPU 10ms,时间一到就会被迫让出 CPU 执行权,防止其他 goroutine 被饿死。
协程被阻塞
当本线程因为 G 进行系统调用阻塞时,P 就会与当前线程解绑,进而去寻找其他空闲的线程或者重新创建线程来执行 G 。