Last updated
Last updated
调度需要解决的两个核心问题:
调度时机:什么时候会发生调度?
调度策略:使用什么策略来挑选下一个进入运行的 goroutine
新建 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()
,这个很少见,一般调试才用
该函数会调用 mcall
保存现场到 G.sched
字段,然后修改 G
状态为 _Gwaiting
,并解绑 G
和 M
,使之暂停运行。然后调用 schedule()
调度别的 G
起来运行
当通过 gopark()
休眠的协程满足条件、可以唤醒后(比如从 channel
中收到了数据),运行时会调用 goready()
唤醒协程
goready
会直接将 G
放回 P.runnext
,使之以最高优先级恢复运行
注册函数处理抢占信号 或 我们使用 runtime.Gosched()
主动暂停当前协程时都会调用 goschedImpl
函数。该函数逻辑和 gopark
差不多,只是比它多了一步 globrunqput
,会将当前协程放入全局队列
在 exitsyscall()
里会触发 schedule()
函数进行调度
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
调度方面的性能
系统调用也会触发运行时调度器的调度,为了处理特殊的系统调用,我们甚至在 Goroutine 中加入了 _Gsyscall
状态,Go 语言通过 和 等使用汇编语言编写的方法封装操作系统提供的所有系统调用,其中 的实现如下: