Skip to content

协程调度

1.调度流程

img

2.Go程序运行和调度初始化

Go 程序运行会经过以下几个阶段:

  1. 从磁盘上读取可执行文件,加载到内存
  2. 操作系统执行 runtime 包中的程序入口
  3. runtime 执行初始化,最后调用 main 函数

img

上图中的流程是根据源码文件 src/runtime/asm_amd64.s 和 src/runtime/proc.go 而画的,只是大致描述了Go程序运行的流程,对具体细节感兴趣的可以阅读源码了解或亲自反汇编程序了解

值得注意的是,GMP模型在初始化中的变化:

img

G0是特殊的goroutine,它的栈直接分配到线程栈中,与M直接关联。goroutine 的创建和 goroutine 的调度等任务都在G0上执行。

至于调度初始化的内容,就在 schedinit() 函数中。

schedinit() 函数调用了很多函数,程序运行环境的初始化几乎都在这里进行:

img

3.goroutine状态

  • Gidle:空闲状态,表示Goroutine未被调度执行。
  • Grunnable:可运行状态,表示Goroutine已经准备好运行,等待被调度到一个线程(M)上执行。
  • Grunning:运行状态,表示Goroutine正在一个线程(M)上执行。
  • Gsyscall:系统调用状态,表示Goroutine正在执行阻塞的系统调用。
  • Gwaiting:等待状态,表示Goroutine正在等待某个事件(如通道操作、同步原语等)。
  • Gdead:死亡状态,表示Goroutine已经执行完成或被终止。

4.goroutine创建

goroutine的创建是由 newproc 函数执行,而 newproc 主要做的就是切换到G0栈上去调用 newproc1 函数。

newproc1 的步骤:

  1. 禁止抢占,并获取当前 goroutine 的 M 和 P
  2. 先从当前 P 和全局队列中获取空闲的 goroutine;如果没有就创建一个 goroutine,并添加到全局变量 allgs 中
  3. 如果 go func() 的函数有参数,就把参数移到 goroutine 栈上
  4. 设置各个寄存器的值,设置pc寄存器值为 &goexit + sys.PCQuantum(sys.PCQuantum 在x86上为1,大多数其他系统上为4),可以理解成将返回地址设为 goexit 的第二条指令。
  5. 调用 gostartcallfn,其主要功能是调用 gostartcall;而 gostartcall 的作用是将goexit 函数入栈,将 goroutine 的函数伪装成是 goexit 调用的,当函数执行完时就会返回 goexit 来清理线程
  6. 设置 goroutine 的状态,调用 runqput,然后把 goroutine 放入p的待执行队列中
  7. 尝试唤起一个P来执行当前 goroutine
  8. 允许抢占

伪装结果大致如下:

go
func goexit() {
    goroutine 函数()    //伪装成是 goexit 第一条指令调用的
    清理现场()    //返回地址就是第二条指令
}

img

5.goroutine切换

goroutine 的切换一般会在以下几种情况发生:

  1. 基于信号抢占式的调度,一个 goroutine 如果运行很长,会被踢掉
  2. 发生系统调用,系统调用会陷入内核,开销不小,暂时解除当前 goroutine
  3. channel 阻塞,当从channel读不到或者写不进的时候,会切换 goroutine

切换就是让出和恢复:

goroutine让出

通过 runtime.gopark 来实现:

  1. 禁止抢占

  2. 将 Grunning 状态的 goroutine 设置为 Gwaiting

  3. 允许抢占

  4. 调用 mcall:

  5. 保存当前 goroutine 上下文,切换到 G0 栈上

  6. 调用 runtime.park_m:

  7. 解除 goroutine 和当前工作线程M的关系

  8. 调用 schedule 获取一个新 goroutine 来运行

goroutine恢复

通过 runtime.goready 来实现:

  1. 切换到 G0 栈上,并执行 runtime.ready:

  2. 获取goroutine的状态,并禁止抢占

  3. 将 Gwaiting 状态的 goroutine 切换到 Grunable 状态,放到当前P的本地队列

  4. 尝试唤起一个P来执行当前 goroutine

  5. 允许抢占

让出和恢复的源码文件都在:src/runtime/proc.go 中

6.goroutine监控

监控****线程 sysmon 是由 main goroutine 创建的。它很特殊,它不依赖P,也不由GMP模型调度,它会重复执行一系列任务:

  1. 释放闲置的内存减少内存占用
  2. 若距离上次GC已经超过了两分钟,会强制触发GC
  3. 进行 netpool,即对IO事件主动轮询
  4. 针对处于运行中 prunning 或者系统调用 psyscall 状态的P判断是否满足抢占条件,如果满足则进行抢占

7.协程抢占

有两种情况会发生抢占:

监控抢占

当 G 阻塞在 M 上时(系统调用、channel 等),系统监控会将 P 从 M 上抢夺并分配给其他的 M 来执行其他的 G,而位于被抢夺 P 的 M 本地调度队列中 的 G 则可能会被偷取到其他 M 中。

GC抢占

当需要进行垃圾回收时,为了保证不具备主动抢占处理的函数执行时间过长,导致导致垃圾回收迟迟不得执行而导致的高延迟,而强制停止 G 并转为执行垃圾回收。

在Go 1.14之前使用的是基于协作的抢占式调度,Go 1.14后使用的是基于信号的抢占式调度

基于协作的抢占式调度流程

  • 编译器会在调用函数前插入 runtime.morestack,让运行时有机会在这段代码中检查是否需要执行抢占调度
  • Go语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms,那么会在这个协程设置一个抢占标记
  • 发生函数调用时,可能会执行编译器插入的 runtime.morestack,它调用的 runtime.newstack会检查抢占标记,如果有抢占标记就会触发抢占让出cpu,切到调度主协程里

基于协作的抢占,很明显能看出有问题:当没有发生函数调用,只是纯算法运算的 G,调度器就没办法抢占,只能等待 G 运行结束。而基于信号的抢占,不管G有没有主动让出,都会抢占。

基于信号的抢占式调度流程

  • M 注册一个 SIGURG 信号的处理函数:sighandler
  • sysmon启动后会间隔性的进行监控,最长间隔10ms,最短间隔20us。如果发现某协程独占P超过10ms,会给M发送抢占信号
  • M 收到信号后,内核执行 sighandler 函数把当前协程的状态从_Grunning正在执行改成 _Grunnable可执行,把抢占的协程放到全局队列里,M继续寻找其他 goroutine 来运行
  • 被抢占的 G 再次调度过来执行时,会继续原来的执行流

总结和思考

  • goroutine 的创建、切换和调度都是在G0栈上实现的
  • G0 栈分配在线程栈中,与M直接关联,每个M都有对应的 G0
  • 基于协作和基于信号的抢占式调度的区别,是需不需要G主动让出才能抢占

一个 Go 程序默认至少有多少 goroutine ?

答:5个

  • main:用户主协程
  • forcegchelper:监控计时器触发垃圾回收
  • bgsweep:负责垃圾回收的并发执行
  • scavenger :负责mhead(堆内存)的回收
  • finalizer:专门运行最终附加到对象的所有终结器(Finalizer)

goroutine 什么时候发生阻塞?

答:

  • 阻塞在系统调用:当 goroutine 执行一个阻塞的系统调用(如文件I/O、网络I/O等)时,它会被阻塞,直到系统调用完成。
  • 阻塞在通道操作:goroutine 在执行通道(channel)操作时,如发送数据到一个已满的通道或从一个空的通道接收数据,可能会发生阻塞。阻塞会持续到通道中有足够的空间或数据可用。
  • 阻塞在同步原语:goroutine 在等待锁(如sync.Mutex)或其他同步原语(如sync.WaitGroup)时,可能会发生阻塞。
  • 阻塞在GC:在GC过程中,所有 goroutine 都可能会短暂地被阻塞,以便垃圾回收器完成内存回收。

P能够存储多少个 goroutine ?

答:257个;runq队列存储256个,runnext存一个

写在后面的话

关于协程调度还有很多没有写出来,例如循环调度schedule函数的细节,协程抢占的图示步骤等等,这些东西理解了但是要写出来真是令人绞尽脑汁,作图也是非常繁琐(今天突然发现之前的图背景都是空白,放大看非常难受,有时间再改吧)。本来一开始只是打算快速基础学习,结果学习发散不小心深入了,这些缺的细节之后复习还会再弥补回来的。

如有转载或 CV 的请标注本站原文地址