另外,每一个 M 结构中都存储了一个特殊的协程 g0,协程 g0 运行在操作系统的线程栈上,它的主要作用是执行协程调度的一系列运行时代码,一般的协程则负责无差别地执行用户代码。
很显然,执行用户代码的任何协程都不适合进行全局调度。当用户协程退出或者被抢占时,意味着需要重新执行协程调度,这时,我们需要从用户协程 g 切换到协程 g0,这样才能完成协程的调度。
协程经历从 g→g0→g 的过程之后,就完成了一次调度循环。和线程类似,协程切换的过程叫作协程的上下文切换。
当某一个协程 g 执行上下文切换时,需要保存当前协程的执行现场,才能够在后续切换回 g 协程时正常执行。协程的执行现场存储在 g.gobuf 结构体中,g.gobuf 结构体主要保存 CPU 中几个重要的寄存器值,分别是 rsp、rip、rbp。
type gobuf struct {
// 保存CPU 的rsp 寄存器的值
sp uintptr
// 保存CPU 的rip 寄存器的值
pc uintptr
// 记录当前这个gobuf 对象属于哪个Goroutine
g guintptr
// 保存系统调用的返回值
ret sys.Uintreg
// 保存CPU 的rbp 寄存器的值
bp uintptr
...
}
调度循环
从协程 g0 调度到协程 g:
- schedule 函数: 处理的是具体的调度策略,也就是选择下一个要执行的协程
- execute 函数: 执行的是一些具体的状态转移、协程 g 与结构体 m 之间的绑定等操作
- gogo 函数;与操作系统有关的函数,用于完成栈的切换以及恢复 CPU 寄存器。
执行完这一步之后,协程就会切换到协程 g 去执行,当协程 g 主动让渡、被抢占或退出后,又会切换到协程 g0 开始下一轮调度。
在从协程 g 切换回协程 g0 时,mcall 函数会保存当前协程的执行现场,mcall 函数是和平台有关的汇编指令。
协程切换到 g0 后,根据切换原因的不同,会执行不同的函数。
- 如果是用户调用 Gosched 函数主动让渡执行权,就会执行 gosched_m 函数;
- 如果协程已经退出,则执行 goexit 函数,将协程 g 放入 p 的 freeg 队列,方便下次重用。
执行完毕后,运行时再次调用 schedule 函数开始新一轮的调度循环,从而形成一个完整的闭环,循环往复。
调度算法
调度的核心策略位于 schedule 函数中。
// runtime/proc.gofunc
schedule() {
...
}
由于程序中不可能同时执行成千上万个协程,因此,那些等待被调度的协程就存储在了运行队列中。
Go 语言调度器将运行队列分为局部运行队列与全局运行队列。
- 局部运行队列是每个 P 特有的长度为 256 的数组。这个数组模拟了一个循环队列
type p struct {
// 使用数组实现的循环队列
runq [256]guintptr
runnext guintptr
}
+ runqhead 标识了循环队列的开头
+ runqtail 标识了循环队列的末尾。
+ 每次将 G 放入本地队列时,都是从循环队列的末尾插入,而获取 G 时则是从循环队列的头部获取。
+ 除此之外,在每个 P 内部还有一个特殊的 runn