5-4.调度-调度策略

调度需要解决的两个核心问题:

  1. 调度时机:什么时候会发生调度?

  2. 调度策略:使用什么策略来挑选下一个进入运行的 goroutine

1. 调度时机

  • 新建 M 后,启动 Mmstart() 函数里触发 schedule()

  • 新建 G 并执行后,协程执行完毕退出时的 goexit0() 函数里触发 schedule()

  • goparkchannel 这种结构在等待读写时,会使用 gopark() 休眠协程后会触发 schedule()

  • 阻塞性系统调用,比如文件 IO,网络IO

    • Golang 重写了所有系统调用,封装了 syscall ,在其前后增加了 entersyscallexitsyscall,进入系统调用前暂停当前协程、完成系统调用后在 exitsyscall() 里恢复其资源、进度,并触发 schedule()

  • GC 触发抢占:GC 需要 STW 暂停所有 G ,GC 时会发送抢占信号。注册函数收到信号后会执行 preemptPark() -> schecule()

  • 其他抢占:注册函数收到抢占信号后执行 gopreempt_m() -> goschedImpl() -> schedule()

  • 主动调用 runtime.Gosched() -> goschedImpl(),这个很少见,一般调试才用

gopark

该函数会调用 mcall 保存现场到 G.sched 字段,然后修改 G 状态为 _Gwaiting,并解绑 GM,使之暂停运行。然后调用 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-4

goready

当通过 gopark() 休眠的协程满足条件、可以唤醒后(比如从 channel 中收到了数据),运行时会调用 goready() 唤醒协程

goready 会直接将 G 放回 P.runnext,使之以最高优先级恢复运行

goschedImpl

注册函数处理抢占信号 或 我们使用 runtime.Gosched() 主动暂停当前协程时都会调用 goschedImpl 函数。该函数逻辑和 gopark 差不多,只是比它多了一步 globrunqput,会将当前协程放入全局队列

系统调用

系统调用也会触发运行时调度器的调度,为了处理特殊的系统调用,我们甚至在 Goroutine 中加入了 _Gsyscall 状态,Go 语言通过 syscall.Syscallsyscall.RawSyscall 等使用汇编语言编写的方法封装操作系统提供的所有系统调用,其中 syscall.Syscall 的实现如下:

exitsyscall() 里会触发 schedule() 函数进行调度

2. 调度策略

schedule() 函数是调度逻辑的核心部分,该函数会暂停当前 G,保存现场、取下个 G 执行。

总体思路是:依次从 P 的本地 G 队列取、全局 G 队列取、其他 P 里偷 G

  1. 调度器每调度 61 次,从全局队列里取一次 G(以避免全局队列里有的 G 始终不被取出、饿死)

  2. 调用 runqget 函数从 P 本地的运行队列中查找待执行的 G

  3. 调用 findrunnable 函数从其他地方取 G(依次从本地队列、全局队列、netpoll、从其他 P 里偷窃取 G

    • 先再次尝试从本地队列获取 G

    • 去全局队列获取(因为前面仅仅是1/61的概率)

    • 执行 netpoll,检查网络读写事件,判断是否有 IO 就绪的 G

    • 如果还是没有,那么随机选择一个 P,偷其 runqueue 里的一半到自己的队列里

      • 这里的随机用到了一种质数算法,保证既随机,每个 P 又都能被访问到

      • 偷窃前会将 M 的自旋状态设为 true,偷窃后再改回去

      • 如果多次尝试偷 P 都失败了,M 会把 P 放回 sched 的 空闲 P 数组,自身休眠(放回空闲 M 列表)

  4. wakep: 另一种情况是,M 太忙了,如果 P 池子里有空闲的 P,会唤醒其他 sleep 状态的 M 一起干活。如果没有 sleep 状态的 Mruntime 会新建一个 M

  5. execute,执行代码。

schedule

findrunnable

依次从 本地队列、全局队列、netpoll、其他 P 获取可运行的 G。偷窃优先级最低,因为会影响其他 P 执行(需要加锁)。

globrunqget

从全局队列里取可用的 G

除返回一个可用 G 外,还会批量转移一批 GP 的本地队列(按 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 调度方面的性能

参考

Go 程序员面试笔试宝典 - 主 goroutine 如何创建

张方波 - 第三章 Goroutine调度策略(16)

Last updated