暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

浅谈 Go 调度器

拾沙馆 2019-09-06
321

陆陆续续看过许多关于 Go 调度的文章了,有了一些模糊的认识,为了巩固梳理相关知识,写出来是一个很好的方式,遂有此文,将我对于 Go 调度目前的认识用自己的方式表述出来,算是一篇归纳型的笔记。

基本模型

G:Groutine

G 代表 groutine, 就是我们在写代码时使用 go func()
创建出来的并发功能模块,它在运行时中对应着 g
这样一个结构。g
被创建出来之后等待被调度,最终绑定到一个 m
上执行。

我们挑选 g
部分结构看下:

  1. type g struct {

  2. stack stack

  3. stackguard0

  4. stackguard1

  5. m *m

  6. sched gobuf

  7. atomicstatus uint32

  8. preempt bool

  9. ...

  10. }


  11. type stack struct {

  12. lo uintptr

  13. hi uintptr

  14. }


  15. type gobuf struct {

  16. sp uintptr

  17. pc uintptr

  18. g guintptr

  19. ctxt unsafe.Pointer

  20. ret sys.Uintreg

  21. lr uintptr

  22. bp uintptr // for GOEXPERIMENT=framepointer

  23. }

  • stack:stack记录了这个 g
     栈空间的起止地址

  • m:记录了执行当前 g
     的 m

  • sched:sched 记录了这个 g
     执行中断时的寄存器状态,用于恢复现场

  • atomicstatus: g
     的状态,比如 _Gidle
    、 _Grunning
     、 _Gsyscall
    、 _Gwaiting
    、 _Gdead
     等

  • preempt:抢占标志(因运行太长,被申请执行抢占)

M:Machine

M 是具体执行 G 功能的执行体,它对应系统线程,持有计算资源,每个 M 对应一个操作系统线程。它在运行时中对应结构 m

  1. type m struct {

  2. g0 *g

  3. curg *g

  4. p puintptr

  5. oldp puintptr

  6. spinning bool

  7. mcache *mcache

  8. ...

  9. }

  • g0:g0是一个比较特殊的 g
    ,它跟我们用 go func()
     创建的不太一样,它是随着新 M 创建的时候,单独创建的一个 g
    ,用于 M 在执行调度等运行时动作时使用其栈空间, g0
     的栈空间也比一般的 G 的初始空间要大很多。当执行用户 G 的时候,就切到对应 g
     的栈空间上,当执行调度相关的逻辑时就切换到 g0
     这个栈空间上

  • curg:绑定在 M 上正在执行的 G

  • p:M 绑定的 P,M 必须绑定 P 之后才能够运行 G

  • oldp:M 最近被剥夺的一个 P,主要用于系统调用阻塞发生的抢占调度中

  • mcache:本地内存对象,执行 G 所需要的内存优先先从这里分配(实际上指向锁绑定的 P 的 mcache)

P:Processor

P 代表处理器,但是这里是指虚拟的逻辑处理器,它并不负责执行程序。P 维护有一个本地的待处理 G 队列、本地的内存对象 mcache
等相关的执行资源,当与 M 绑定后,M 就可以执行 G 了。

  1. type p struct {

  2. id int32

  3. status uint32 // one of pidle/prunning/...

  4. link puintptr

  5. schedtick uint32 // incremented on every scheduler call

  6. syscalltick uint32 // incremented on every system call

  7. sysmontick sysmontick // last tick observed by sysmon

  8. m muintptr // back-link to associated m (nil if idle)

  9. mcache *mcache


  10. runqhead uint32

  11. runqtail uint32

  12. runq [256]guintptr


  13. gFree struct {

  14. gList

  15. n int32

  16. }

  • m:与 P 绑定的 M,两者相互链接

  • runq:P 维护的本地 G 队列,这是一个环形队列,有 runqhead
     和 runqtail
     记录队列首尾位置。之所以有本地 G 队列,是因为在 P 内的 G 是线程安全的,不用加锁,对它的操作会快很多,提高调度效率。与之对应还有一个全局的 G 队列,后文会介绍

  • gFree:这里记录是一些已经运行完毕的 G,这是为了维护一个 g
     对象的池,便于重用,减少内存分配和 GC 压力。需要新 g
     的时候会从这儿取出复用

M、P、G 模型是构成 Go 调度的基本单元,执行体 M 对应操作系统线程,它的数量被 Go 限制为最多 10000 个。G 是被执行体,代表轻量级的用户线程,它的数量无限制。而 M 和 G 之间的 P,是一个逻辑处理器,作用是实现对 M 和 G 的解耦,这样就能够实现任意数量的 G 在任意数量的 M 中的调度。

sched

除了 G、M、P 这几个基本模型外,还有一个记录调用器状态并维护一些全局资源的结构 sched

  1. type schedt struct {

  2. midle muintptr // idle m's waiting for work

  3. nmidle int32 // number of idle m's waiting for work

  4. pidle puintptr // idle p's

  5. npidle uint32

  6. nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go.

  7. runq gQueue

  8. runqsize int32

  9. gFree struct {

  10. lock mutex

  11. stack gList // Gs with stacks

  12. noStack gList // Gs without stacks

  13. n int32

  14. }

  15. }

sched
中记录了空闲M、空闲 P链表。以及一个全局的待运行 G 列表和可重用的 g
结构池,这和 P 的两个本地队列相对应。

另外还有一些全局的变量:

  1. var (

  2. allglen uintptr

  3. allm *m

  4. allp []*p // len(allp) == gomaxprocs; may change at safe points, otherwise immutable

  5. allpLock mutex // Protects P-less reads of allp and all writes

  6. gomaxprocs int32

  7. ncpu int32

  8. forcegc forcegcstate

  9. sched schedt

  10. newprocs int32

  11. ...

  12. }

调度流程

schedule

schedule 是一个不断轮回的调度循环,它的任务就是寻找一个可运行 G ,最后 execute
执行这个 G。它寻找 G 的策略和过程是 schedule 的一个核心:

  1. 首先尝试从 P 的本地 G 队列去取 G,但是为了保证全局 G 队列里的 G 有机会调度,每调度 61 次,调度器会优先从全局 G 队列拿 G;

  2. 如果没有,则开始更仔细的 findrunnable
     流程去获取 G;

  3. findrunnable
     会依次从 P 的本地 G 队列、全局 G 队列以及 netpoll
     中寻找 G。如果依然没找到,则开启饥饿的 runqsteal
     ,尝试从其他 P 偷一些 G 来运行;

  4. 随机顺序遍历其他 P,如果其他 P 的本地 G 队列中有 G,则尝试偷一半到自己的队列,并开始执行偷到的 G;

  5. 如果经过上面的步骤还是找不到可运行的 G,则尝试最后再去全局队列看一看,实在没有的话,则解除与 P 的绑定,并将 P 放入空闲列表;

  6. 虽然已经解除 P 了,但是还是不死心,再次去检查是否有正忙的 P,有的话立即要来一个空闲 P 绑上,并重新开始上述寻找 G 过程,尝试工作;

  7. 经过这么丧心病狂的寻找之后,实在无事可做,则 M 进入休眠。

为了工作,可以说是不择手段、丧心病狂。

sysmon

sysmon 是一个独立于调度循环体系外的一个监察员,sysmon 在系统初始化时被创建,单独绑定一个 M,并且不需要绑定 P 就可以运行。

sysmon 也是一个类似于 schedule 的不断循环的流程,初期每 20μs 运行一次,后面间隔时间逐渐翻倍,最终每 10ms 运行一次。

sysmon 一次执行大概会做这些事情:去长时间未轮询的 netpoller 中 poll 一次,执行 retake 相关的抢占调度,需要强制 GC 时执行 GC。

其中抢占调度有两种场景。

第一种:长时间阻塞在 syscall 。如果 sysmon 在理性检查过程中,发现某个 P 有段时间了还在 syscall 未返回,那么为了不浪费 P 资源,应该考虑是否抢占 P 去做别的事情。

判断条件有三条:

  • P 的本地队列里是否还有待运行的 G;

  • 其他 P 都很忙

  • 系统调用超过了 10ms

一旦任何一个条件满足,则 handoffp(_p_)
,将 P 与当前 M 解绑。这样 M 继续系统调用,P 则去绑定其他的 M 完成其他工作,提高系统效率。

其实在执行系统调用之初,M 已经与 P 做了预解绑,但是 P 没有被重新分配(并且被记录到了 M 的 oldp
字段,一旦系统调用返回,M 会优先尝试从 oldp
中拿回之前绑定的 P)。所以这里只是对 P 重新分配,绑定其他 M。

  1. if s == _Psyscall {

  2. // Retake P from syscall if it's there for more than 1 sysmon tick (at least 20us).

  3. t := int64(_p_.syscalltick)

  4. if !sysretake && int64(pd.syscalltick) != t {

  5. pd.syscalltick = uint32(t)

  6. pd.syscallwhen = now

  7. continue

  8. }

  9. if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {

  10. continue

  11. }

  12. ...

  13. handoffp(_p_)

  14. }

第二种:长时间运行的 G:如果一个 G 运行了太长时间(10ms),那么 sysmon 就要发起抢占调度,但并不是现在就停止 G 运行,而是发起一个抢占申请 -- 将 G 的抢占调度标志 preempt
设为 true
,同时修改 stackguard0
为一个极大值 stackPreempt
。这样当 G 再次执行函数时,检测到这个变化,就会处理抢占申请,退出执行,发起新的调度。

  1. if s == _Prunning || s == _Psyscall {

  2. // Preempt G if it's running for too long.

  3. t := int64(_p_.schedtick)

  4. if int64(pd.schedtick) != t {

  5. pd.schedtick = uint32(t)

  6. pd.schedwhen = now

  7. } else if pd.schedwhen+forcePreemptNS <= now {

  8. preemptone(_p_)

  9. // In case of syscall, preemptone() doesn't

  10. // work, because there is no M wired to P.

  11. sysretake = true

  12. }

  13. }

参考资料

  1. Head First of Golang Scheduler

  2. 三足鼎立 —— GPM 到底是什么?(一)

  3. goroutine调度器概述(11)

  4. 用 GODEBUG 看调度跟踪

  5. Scheduling In Go : Part II - Go Scheduler

文章转载自拾沙馆,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论