每一个线程 M 都有一个调度协程 g0, g0协程的主函数是 runtime.schedule,该函数实现了协程调度功能。那么,Go 语言是如何管理以及调度成千上万个协程呢?是否和操作系统一样,维护着可运行队列和阻塞队列?有没有所谓的按照时间片调度?或者是优先级调度?又或者是抢占式调度?
1. 调度器实现原理
函数 runtime.schedule 实现了协程调度功能,怎么调度协程呢?第一步当然是获取到一个可运行协程 G; 第二步就是切换到协程 G 的上下文(包括切换协程栈,指令跳转)。
思考一下,Go 语言调度器都有哪些途径去获取可运行协程 G 呢?首先每一个逻辑处理器 P 都有一个可运行协程队列,调度器一般情况下只需要从当前逻辑处理器 P 负载分配不均衡,还有一个全局可运行协程队列,一定条件下也会从全局可运行协程队列获取协程。当然,如果逻辑处理器 P 的本地可运行协程队列为空,全局可运行协程队列也为空的话,调度器还会尝试其他方法获取协程,比如从其他逻辑处理器 P 的本可运行协程队列去 “偷”。
在讲解调度器的实现原理之前,我们再思考一下:无论是逻辑处理 P 的本地可运行协程队列,还是全局可运行协程队列,存储的都是可运行状态的协程,那处于阻塞状态的协程呢?它们能被调度器调度吗?其实,可以换一个角度思考,阻塞状态的协程在什么条件下会解除阻塞呢?这就要看协程是因为什么原因阻塞的了。比如,因为抢锁阻塞的协程,只有其他协程释放了锁才有可能解除阻塞;因为管道的读/写阻塞的协程,只有其他协程读/写了该管道或关闭了该管道才有可能解除阻塞;因为网络 I/O 阻塞的协程,只有 Go 程序检测到网络可读可写了才能解除阻塞;休眠协程,只有当时间到达某一时刻才能解除阻塞,而这同样需要 Go 程序主动检测。可以看到,休眠的协程和因为网络 I/O 阻塞的协程,都需要 Go 程序主动检测,才有可能解除阻塞,而这些检测逻辑我们将会在 Go 语言调度程序中看到。
Go 语言调度器基本就是通过上述这些手段获取可运行协程 G 的,我们可以简单看一下函数 runtime.schedule 的实现逻辑,代码如下所示:
func schedule(){
//检测是否有定时任务到达触发时间
checkTimers(pp,0)
//schedtick 调度计数器,每执行一次调度程序加 1
//每执行 61 次调度程序,会优先从全局可运行协程队列获取协程
if gp == nil {
if _g_.m.p.prt().scheditick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(_g_.m.p.ptr(),1)
unlock(&sched.lock)
}
}
//从逻辑处理器 P 的本地可运行协程队列获取协程
if gp == nil {
gp,inheritTime = runqget(_g_.m.p.prt())
}
//继续尝试其他方式获取协程
if gp == nil {
gp,inheritTime = findrunnable()
}
//调度执行
execute(gp,inheritTime)
}
一般情况下,Go 语言调度器优先从当前逻辑处理器 P 的可运行协程队列获取协程。另外,每执行 61 次调度程序,Go 语言调度器就会优先从全局可运行协程队列获取协程。如果经过这两个步骤之后还没有获取到协程,则 Go 语言调度器会通过函数 runtime.findrunnable 继续尝试其他方式获取协程,该函数实现了获取协程的多种方法。此外,在获取协程之前,函数 runtime.schedule 会先检测是否有定时任务到达触发时间,如果有,则执行该定时任务。
函数 runtime.findrunnable 的逻辑实际上非常简单,但是代码量较多。在这里,摘抄了少量代码,如下所示:
func findrunnable()(gp *g,inheritTime bool) {
top:
.....
//从全局可运行协程队列获取
if sched.runqsize != 0 {
lock(&sched.lock)
gp := globrunqget(_p_,0)