5-4.调度-调度策略
调度需要解决的两个核心问题:
调度时机:什么时候会发生调度?
调度策略:使用什么策略来挑选下一个进入运行的
goroutine
1. 调度时机
新建
M后,启动M的mstart()函数里触发schedule()新建
G并执行后,协程执行完毕退出时的goexit0()函数里触发schedule()gopark:channel这种结构在等待读写时,会使用gopark()休眠协程后会触发schedule()阻塞性系统调用,比如文件 IO,网络IO
Golang重写了所有系统调用,封装了syscall,在其前后增加了entersyscall和exitsyscall,进入系统调用前暂停当前协程、完成系统调用后在exitsyscall()里恢复其资源、进度,并触发schedule()
GC 触发抢占:GC 需要 STW 暂停所有
G,GC 时会发送抢占信号。注册函数收到信号后会执行preemptPark() -> schecule()其他抢占:注册函数收到抢占信号后执行
gopreempt_m() -> goschedImpl() -> schedule()主动调用
runtime.Gosched() -> goschedImpl(),这个很少见,一般调试才用
gopark
该函数会调用 mcall 保存现场到 G.sched 字段,然后修改 G 状态为 _Gwaiting,并解绑 G 和 M,使之暂停运行。然后调用 schedule() 调度别的 G 起来运行
// runtime/proc.go
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
mcall(park_m)
}
func park_m(gp *g) {
_g_ := getg()
casgstatus(gp, _Grunning, _Gwaiting)
dropg() // 解绑 M 和 G
schedule() // 调度别的 G 起来运行
}
// runtime/asm_amd64.s
// 该函数会保存当前现场到 g.sched、然后切换到 g0 栈调用函数
TEXT runtime·mcall(SB), NOSPLIT, $0-4goready
当通过 gopark() 休眠的协程满足条件、可以唤醒后(比如从 channel 中收到了数据),运行时会调用 goready() 唤醒协程
goready 会直接将 G 放回 P.runnext,使之以最高优先级恢复运行
goschedImpl
注册函数处理抢占信号 或 我们使用 runtime.Gosched() 主动暂停当前协程时都会调用 goschedImpl 函数。该函数逻辑和 gopark 差不多,只是比它多了一步 globrunqput,会将当前协程放入全局队列
系统调用
系统调用也会触发运行时调度器的调度,为了处理特殊的系统调用,我们甚至在 Goroutine 中加入了 _Gsyscall 状态,Go 语言通过 syscall.Syscall 和 syscall.RawSyscall 等使用汇编语言编写的方法封装操作系统提供的所有系统调用,其中 syscall.Syscall 的实现如下:
在 exitsyscall() 里会触发 schedule() 函数进行调度
2. 调度策略
schedule() 函数是调度逻辑的核心部分,该函数会暂停当前 G,保存现场、取下个 G 执行。
总体思路是:依次从 P 的本地 G 队列取、全局 G 队列取、其他 P 里偷 G:
调度器每调度
61次,从全局队列里取一次G(以避免全局队列里有的G始终不被取出、饿死)调用
runqget函数从P本地的运行队列中查找待执行的G调用
findrunnable函数从其他地方取G(依次从本地队列、全局队列、netpoll、从其他P里偷窃取G)先再次尝试从本地队列获取
G去全局队列获取(因为前面仅仅是1/61的概率)
执行
netpoll,检查网络读写事件,判断是否有 IO 就绪的G如果还是没有,那么随机选择一个
P,偷其runqueue里的一半到自己的队列里这里的随机用到了一种质数算法,保证既随机,每个
P又都能被访问到偷窃前会将
M的自旋状态设为true,偷窃后再改回去如果多次尝试偷
P都失败了,M会把P放回sched的 空闲P数组,自身休眠(放回空闲M列表)
wakep: 另一种情况是,M太忙了,如果P池子里有空闲的P,会唤醒其他sleep状态的M一起干活。如果没有sleep状态的M,runtime会新建一个M。execute,执行代码。
schedule
findrunnable
依次从 本地队列、全局队列、netpoll、其他 P 获取可运行的 G。偷窃优先级最低,因为会影响其他 P 执行(需要加锁)。
globrunqget
从全局队列里取可用的 G
除返回一个可用 G 外,还会批量转移一批 G 到 P 的本地队列(按 P 的数量均分,最多取 P 本地队列的一半)。这样就省的频繁加锁来全局队列取。
stealWork
从其他 P 里偷一半的 G 过来
调度循环
schedule() 调度中会通过 excute() 执行函数,函数执行完毕后在退出函数 goexit0() 再次触发调度,这样就完成了一次循环:
schedule() -> execute() -> gogo() -> g2() -> goexit() -> goexit1() -> mcall() -> goexit0() -> schedule()
问题:上述调度可能会进行无数次循环,是否会耗尽栈空间?
schedule() 函数开始之后的一系列函数永远都不会返回,每次执行 mcall 会切换到 g0 栈运行,会切换到 g0.sched.sp 所指的固定位置,即重用这些函数上一轮调度时所使用过的栈内存,因此不会耗尽空间。
优点
调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换
不需要频繁的进行内存的分配与释放。在用户态维护着一块大的内存池, 不直接调用系统的
malloc函数(除非内存池需要改变)另一方面充分利用了多核的硬件资源,近似的把若干
goroutine均分在物理线程上再加上本身
goroutine的超轻量,以上种种保证了go调度方面的性能
参考
Last updated