调度策略
goroutine的调度的核心策略位于schedule函数中
// runtime/proc.go
func schedule() {
....
}
Go语言调度器将运行队列分为局部运行队列与全局运行队列。局部运行队列是每个P特有的长度为256的数组,该数组模拟了一个循环队列,其中runqhead标识了循环队列的开头,runqtail标识了循环队列的末尾。每次将G放入本地队列时,都从循环队列的末尾插入,而获取时从循环队列的头部获取。 除此之外,在每个P内部还有一个特殊的runnext字段标识下一个要执行的协程。如果runnext不为空,则会直接执行当前runnext指向的协程,而不会去runq数组中寻找。
type p struct {
// 使用数组实现的循环队列
runq [256]guintptr
runnext guintptr
}
被所有P共享的全局运行队列存储在schedt.runq中。
type schedt struct {
runq gQueue
}
一般的思路是先查找每个P局部的运行队列,当获取不到局部运行队列时,再从全局队列中获取。但是这种方法可能存在一个问题,如果只是循环往复地执行局部运行队列中的G,那么全局队列中的G可能完全不会执行。为了避免这种情况,Go语言调度器使用了一种策略:P中每执行61次调度,就需要优先从全局队列中获取一个G到当前P中,并执行下一个要执行的G。
调度协程的优先级与顺序如下图所示。排除从全局队列中获取这种情况,每个P在执行调度时,都会先尝试从runnext中获取下一个执行的G,如果runnext为空,则继续从当前P中的局部运行队列runq中获取需要执行的G;如果局部运行队列为空,则尝试从全局运行队列中获取需要执行的G;如果全局队列也没有找到要执行的G,则会尝试从其他的P中窃取可用的协程。到这一步,正常的程序基本都能获取到要运行的G,如果窃取不到任务,那么当前的P会解除与M的绑定,P会被放入空闲P队列中,而与P绑定的M没有任务可做,进入休眠状态。
调度时机
可以根据调度方式的不同,将调度时机分为主动、被动和抢占调度
主动调度
协程可以选择主动让渡自己的执行权利,这主要是通过用户在代码中执行runtime.Gosched函数实现的。主动调度的原理比较简单,需要先从当前协程切换到协程g0,取消G与M之间的绑定关系,将G放入全局运行队列,并调用schedule函数开始新一轮的循环。
被动调度
被动调度指协程在休眠、channel通道堵塞、网络I/O堵塞、执行垃圾回收而暂停时,被动让渡自己执行权利的过程。被动调度具有重要的意义,可以保证最大化利用CPU的资源。根据被动调度的原因不同,调度器可能执行一些特殊的操作。由于被动调度仍然是协程发起的操作,因此其调度的时机相对明确。和主动调度类似的是,被动调度需要先从当前协程切换到协程g0,更新协程的状态并解绑与M的关系,重新调度。和主动调度不同的是,被动调度不会将G放入全局运行队列,因为当前G的状态不是_Grunnable而是_Gwaiting,所以,被动调度需要一个额外的唤醒机制。如果当前协程需要被唤醒,那么会先将协程的状态从_Gwaiting转换为_Grunnable,并添加到当前P的局部运行队列中。
抢占调度
- 基于协作的抢占式调度 juejin.cn/post/714289…
- 基于信号的抢占式调度 juejin.cn/post/714289…
参考资料
- 《Go语言底层原理剖析》
- 《Go 语言设计与实现》