Goroutine(二) Goroutine调度器的版本演变及基于协作的抢占调度器介绍

Go语言的调度器经过几个大版本的迭代才有今天的优异性能,历史上几个不同版本的调度器引入了不同的改进。大致如下: 

  •  单线程调度器 0.x
    • 只包含40多行代码
    • 程序中只能存在一个活跃线程由G-M模型组成
  • 多线程调度器1.0
    • 允许运行多线程的程序
    • 全局锁导致竞争严重
  • 任务窃取调度器1.1
    • 引入了处理器P,构成了GMP模型
    • 在处理器P的基础上,实现了基于工作窃取的调度器
    • 在某些情况下,Goroutine不会让出线程,进而造成饥饿问题
    • 时间多长的垃圾回收(STW)会导致程序长时间无法工作
  • 抢占式调度器1.2-至今
    • 基于协作的抢占式调度器-1.2~1.13
      • 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
      • Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;
    • 基于信号的抢占式调度器-1.14~至今
      • 实现基于信号的真抢占式调度
      • 垃圾回收在扫描栈时会触发抢占调度
      • 抢占的时间点不够多,还不能覆盖全部的边缘情况
  • 非均匀存储访问调度器 · 提案
    • 对运行时的各种资源进行分区;
    • 实现非常复杂,到今天还没有提上日程;

此处先介绍一个基于协作的抢占式调度器。

基于协作的抢占式调度器

与操作系统按时间片调度线程不同,Go中并没有时间片的概念,如果某个G没有进行系统调用(syscall)、没有进行I/O操作、没有阻塞在一个channel操作上,那么M是如何让G停下来并调度下一个可运行的G的呢?答案是:G是被抢占调度的。除非极端的无限循环或死循环,否则只要G调用函数,Go运行时就有了抢占G的机会。在Go程序启动时,运行时会启动一个名为sysmon的M(一般称为监控线程),该M的特殊之处在于它无须绑定P即可运行(以g0这个G的形式),Go程序启动时不止创建普通的调度线程,还存在辅助线程,辅助线程的主函数是runtime.sysmon,每10ms轮询一次,检测是否有协程执行时间过长,如果有,则通知该协程让出CPU。

我们可以在 pkg/runtime/proc.c 文件中找到引入基于协作的抢占式调度后的调度器。Go 语言会在分段栈的机制上实现抢占调度,利用编译器在分段栈上插入的函数,所有 Goroutine 在函数调用时都有机会进入运行时检查是否需要执行抢占。

对于长时间占用P的几种情况,我们逐个分析(1.14版本之前)

  • 系统调用(syscall调用内核导致的阻塞,需要等待内核处理结果)
    • 如果G被阻塞在某个系统调用上,那么不仅G会阻塞,执行该G的M也会解绑P(实质是被sysmon抢走了),与G一起进入阻塞状态。如果此时有空闲的M,则P会与其绑定并继续执行其他G;如果没有空闲的M,但仍然有其他G要执行,那么就会创建一个新M(线程)。当系统调用返回后,阻塞在该系统调用上的G会尝试获取一个可用的P,如果有可用P,之前运行该G的M将绑定P继续运行G;如果没有可用的P,那么G与M之间的关联将解除,同时G会被标记为runnable,放入全局的运行队列中,等待调度器的再次调度。
  • 网络I/O操作或者channel操作
    • 如果G被阻塞在某个channel操作或网络I/O操作上,那么G会被放置到某个等待队列中,而M会尝试运行P的下一个可运行的G。如果此时P没有可运行的G供M运行,那么M将解绑P,并进入挂起状态。当I/O操作完成或channel操作完成,在等待队列中的G会被唤醒,标记为runnable(可运行),并被放入某个P的队列中,绑定一个M后继续执行。
  • 函数调用且需要扩充栈
    • 除非极端的无限循环或死循环,否则只要G调用函数,Go运行时就有了抢占G的机会。在Go程序启动时,运行时会启动一个名为sysmon的M(一般称为监控线程),该M的特殊之处在于它无须绑定P即可运行(以g0这个G的形式)
    • // $GOROOT/src/runtime/proc.go
      
      // forcePreemptNS是在一个G被抢占之前给它的时间片
      const forcePreemptNS = 10 * 1000 * 1000 // 10ms
      
      func retake(now int64) uint32 {
          ...
          // 抢占运行时间过长的G
          t := int64(_p_.schedtick)
          if int64(pd.schedtick) != t {
              pd.schedtick = uint32(t)
              pd.schedwhen = now
              continue
          }
          if pd.schedwhen+forcePreemptNS > now {
              continue
          }
          preemptone(_p_)
          ...
      }

      可以看出,如果一个G任务运行超过10ms,sysmon就会认为其运行时间太久而发出抢占式调度的请求。一旦G的抢占标志位被设为true,那么在这个G下一次调用函数或方法时,运行时便可以将G抢占并移出运行状态,放入P的本地运行队列中(如果P的本地运行队列已满,那么将放在全局运行队列中),等待下一次被调度。

  • 死循环等边缘情况
    • 死循环等边缘情况例子比较复杂,这里来个具体例子(注意版本1.14之前)
    • // chapter6/sources/go-scheduler-model-case1.go
      func deadloop() {
          for {
          }
      }
      
      func main() {
          go deadloop()
          for {
              time.Sleep(time.Second * 1)
              fmt.Println("I got scheduled!")
          }
      }

      在上面的实例中,我们启动了两个goroutine,一个是main goroutine,另一个是运行deadloop函数(顾名思义,一个死循环)的goroutine。main goroutine为了展示方便,也用了一个死循环,并每隔一秒钟打印一条信息。下面是笔者运行这个例子的结果(笔者的机器是四核八线程的,runtime的NumCPU函数返回8

    • $go run go-scheduler-model-case1.go
      I got scheduled!
      I got scheduled!
      I got scheduled!
      ...

      从运行结果输出的日志来看,尽管存在运行着死循环的deadloop goroutine,main goroutine仍然得到了调度。其根本原因在于机器是多核多线程的(这里指硬件线程,不是操作系统线程)。Go从1.5版本开始将P的默认数量由1改为CPU核的数量(实际上还乘了每个核上硬线程数量)。这样上述例子在启动时创建了不止一个P,我们用图来直观诠释一下。假设deadloop goroutine被调度到P1上,P1在M1(对应一个操作系统线程)上运行;而main goroutine被调度到P2上,P2在M2上运行,M2对应另一个操作系统线程,而线程在操作系统调度层面被调度到物理的CPU核上运行。我们有多个CPU核,因此即便deadloop占满一个核,我们还可以在另一个CPU核上运行P2上的main goroutine,这也是main goroutine得到调度的原因。下面略做修改

    • // chapter6/sources/go-scheduler-model-case2.go
      
      func deadloop() {
          for {
          }
      }
      
      func main() {
          runtime.GOMAXPROCS(1)
          go deadloop()
          for {
              time.Sleep(time.Second * 1)
              fmt.Println("I got scheduled!")
          }
      }

      运行这个程序后,你会发现main goroutine的"I got scheduled"再也无法输出了。deadloop goroutine在P1上被调度,由于deadloop内部逻辑没有给调度器任何抢占的机会,比如进入runtime.morestack_noctxt,所以即便是sysmon这样的监控goroutine,也仅仅是能将deadloop goroutine的抢占标志位设为true而已。由于deadloop内部没有任何进入调度器代码的机会,始终无法重新调度goroutine。main goroutine只能躺在P1的本地队列中等待。那么如何在死循环这种情况下,让goroutine有被抢占的机会:我们可以尝试进行函数调用:

    • // chapter6/sources/go-scheduler-model-case3.go
      
      func add(a, b int) int {
          return a + b
      }
      
      func deadloop() {
          for {
              add(3, 5)
          }
      }
      
      func main() {
          runtime.GOMAXPROCS(1)
          go deadloop()
          for {
              time.Sleep(time.Second * 1)
              fmt.Println("I got scheduled!")
          }
      }

      是不是以为进行了这个函数调用就可以正常输出回到main了,实际上并不是,"I got scheduled!"字样依旧没有出现!也就是说main goroutine没有得到调度!为什么呢?其实所谓的“有函数调用,就有了进入调度器代码的机会”,实际上是Go编译器在函数的入口处插入了一个运行时的函数调用:runtime.morestack_noctxt。这个函数会检查是否需要扩容连续栈,并进入抢占调度的逻辑中。一旦所在goroutine被置为可被抢占的,那么抢占调度代码就会剥夺该goroutine的执行权,将其让给其他goroutine。但是上面代码为什么没有实现这一点呢?因为在main.add代码中,我们没有发现morestack函数的踪迹,也就是说即便调用了add函数,deadloop也没有机会进入Go运行时的goroutine调度逻辑中。为什么Go编译器没有在main.add函数中插入morestack的调用呢?那是因为add函数位于调用树的leaf(叶子)位置,编译器可以确保其不再有新栈帧生成,不会导致栈分裂或超出现有栈边界,于是就不再插入morestack。这样位于morestack中的调度器的抢占式检查也就无法执行。既然明白了原理,我们就在deadloop和add函数之间再加入一个dummy函数

    • // chapter6/sources/go-scheduler-model-case4.go
      
      func add(a, b int) int {
          return a + b
      }
      
      func dummy() {
          add(3, 5)
      }
      
      func deadloop() {
          for {
              dummy()
          }
      }
      
      func main() {
          runtime.GOMAXPROCS(1)
          go deadloop()
          for {
              time.Sleep(time.Second * 1)
              fmt.Println("I got scheduled!")
          }
      }

      执行该代码,最后发现调用成功了。所以说这就是基于协作的抢占式调用,想要抢占式调度,除了要sysmon识别到之外,还需要函数本身协作,也就是开启morestack检查,举一个汇编代码如下所示(这段汇编是简单main函数的汇编,不是上术代码,此处只是举例):

    • 0x0000 00000 (morestack.go:5)	TEXT	"".main(SB), $64-0
      0x0000 00000 (morestack.go:5)	MOVQ	TLS, CX
      0x0009 00009 (morestack.go:5)	MOVQ	(CX)(TLS*2), CX // 获取当前g
      // 比较当前SP和g.stackguard0,如果小于则需要触发morestarck
      0x0010 00016 (morestack.go:5)	CMPQ	SP, 16(CX) 
      0x0014 00020 (morestack.go:5)	JLS	110
      0x0016 00022 (morestack.go:5)	SUBQ	$64, SP    // SP-64,相当于设置栈帧大小64字节
      0x001a 00026 (morestack.go:5)	MOVQ	BP, 56(SP) // 保存caller的BP,可以看到BP是保存到当前函数的栈帧中的,如果一个函数栈帧大小为0,则不需要保存BP
      0x001f 00031 (morestack.go:5)	LEAQ	56(SP), BP // 设置当前BP
         ...
      0x0064 00100 (morestack.go:7)	MOVQ	56(SP), BP // 还原BP
      0x0069 00105 (morestack.go:7)	ADDQ	$64, SP    // SP+64,相当于销毁栈帧
      0x006d 00109 (morestack.go:7)	RET
      0x006e 00110 (morestack.go:7)	NOP
      0x006e 00110 (morestack.go:5)	CALL	runtime.morestack_noctxt(SB)
      0x0073 00115 (morestack.go:5)	JMP	0

      介绍一下stackguard0,在上一章有提过,它是g结构体的一个元素,stackguard0 是对比 Go 栈增长的 prologue 的栈指针 ,如果 sp 寄存器比 stackguard0 小(由于栈往低地址方向增长),会触发栈拷贝和调度 ,通常情况下:stackguard0 = stack.lo + StackGuard,但被抢占时会变为 StackPreempt(此处的逻辑就是我sysmon发现你占用很长时间了,我就把你的stackguard0调到很大,你就肯定会小于它,然后触发调度

    • 进入函数之后,首先会检查当前函数的SP寄存器是否已经达到g.stackguard0,如果是的话,则需要先调用runtime.morestack_noctxt方法扩张当前函数栈(现在的实现是重新分配一个更大的函数栈,然后把旧的函数栈内容拷贝过去),然后再根据栈帧大小设置SPBP指针,而在函数返回前需要先恢复BPSP指针。上面的BP和SP寄存器的相关设置是在morestack之后,也就是在执行morestack的时候,0(SP)为函数返回地址

    • 总结
    • 得出结论:这里的抢占是通过编译器插入函数实现的,还是需要函数调用作为入口(并且是非叶子节点的函数)才能触发抢占,所以这是一种协作式的抢占式调度。理一理整体的逻辑,就是首先后台线程sysmon对于长时间占用的goroutine会进行可抢占标记,gp.preempt = true(把抢占标记位置true),然后在函数进行调用时,如果没有栈扩容需求,则不会执行morestack,如果有栈扩容需求,则会执行morestack,此时才会检查preempt,然后放开p,给其他G使用。

后续基于信号的抢占式调度器请看第三章

  • 25
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值