GO调度器

2023-09-16 ⏳1.4分钟(0.6千字)

在Go开发过程中,Go调度器是无法避开的一座大山。我们在本文中详细了解下Go调度器是如何工作的。

历史

数据结构 GMP模型

G

表示 Goroutine,它是一个待执行的任务,占用了更小的内存空间,也降低了上下文切换的开销。相当于用户态的线程,作为一种粒度更细的调度单元。常见状态:_Grunnable_Grunning_Gsyscall_Gwaiting_Gpreempted

M

表示操作系统的线程,它由操作系统的调度器调度和管理;Go调度器最多可以创建10000个线程,有GOMAXPROCS 个活跃线程能够正常运行比较关键的字段:

type m struct {
	g0   *g
	curg *g
	...
}

g0 是持有调度栈的 Goroutine.是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行。 curg 是在当前线程上运行的用户 Goroutine

P

调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。

在启动时就会创建 GOMAXPROCS 个处理器

struct P {
    m         muintptr

	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	runnext guintptr
};

反向存储的线程维护着线程与处理器之间的关系,而 runqhead、runqtail 和 runq 三个字段表示处理器持有的运行队列,其中存储着待执行的 Goroutine 列表,runnext 中是线程下一个需要执行的 Goroutine。

调度器的目的是为了把可运行的G分配到工作M上。而P可以理解为拥有G的资源,所以而M想要运行G时,先获取P。 P在启动时就会创建,GOMAXPROCS(可配置) 个。 M数量受SetMaxThreads限制,P会主动去寻找空闲的M,如果没有则创建,堵塞后就会切换另一个M.

调度过程

  1. 调度器启动时 g0会初始化线程并调用runtime.schedule进入调度循环
  2. runtime.schedule函数会从下面几个地方查找待执行的 Goroutine:
    • 当全局运行队列中有待执行的 Goroutine 时,通过 schedtick 保证有一定几率会从全局的运行队列中查找对应的 Goroutine
    • 从处理器本地的运行队列中查找待执行的 Goroutine;
    • 如果前两种方法都没有找到 Goroutine,会通过 runtime.findrunnable 进行阻塞地查找 Goroutine;
      • 从本地运行队列、全局运行队列中查找;
      • 从网络轮询器中查找是否有 Goroutine 等待运行;
      • 通过 runtime.runqsteal 尝试从其他随机的处理器中窃取待运行的 Goroutine,该函数还可能窃取处理器的计时器;
  3. runtime.execute执行获取的 Goroutine
  4. 最终在当前线程的 g0 的栈上调用runtime.goexit0函数.该函数会将 Goroutine 转换会 _Gdead 状态、清理其中的字段、移除 Goroutine 和线程的关联并调用 runtime.gfput 重新加入处理器的 Goroutine 空闲列表 gFree
  5. 再循环runtime.schedule函数

触发调度

  1. 主动挂起
    • channel堵塞/锁自旋完之后/time.Sleep/io操作
    • 将当前 Goroutine 的状态从 _Grunning 切换至 _Gwaiting
      • 当 Goroutine 等待的特定条件满足后,运行时会调用 runtime.goready 将因为调用 runtime.gopark 而陷入休眠的 Goroutine 唤醒。
      • runtime.ready 会将准备就绪的 Goroutine 的状态切换至 _Grunnable 并将其加入处理器的运行队列中,等待调度器的调度。
    • 移除线程和 Goroutine 之间的关联
    • 调用 runtime.schedule 触发新一轮的调度
  2. 系统调用
    • 系统调用也会触发运行时调度器的调度
    • Go 语言通过syscall.Syscallsyscall.RawSyscall等使用汇编语言编写的方法封装操作系统提供的所有系统调用
    • 在通过汇编指令 INVOKE_SYSCALL 执行系统调用前后,上述函数会调用运行时的 runtime.entersyscall 和 runtime.exitsyscall,正是这一层包装能够让我们在陷入系统调用前触发运行时的准备和清理工作。
      • 准备工作
        • 禁止线程上发生的抢占,防止出现内存不一致的问题;
        • 保证当前函数不会触发栈分裂或者增长;
        • 保存当前的程序计数器 PC 和栈指针 SP 中的内容;
        • 将 Goroutine 的状态更新至 _Gsyscall;
        • 将 Goroutine 的处理器和线程暂时分离并更新处理器的状态到 _Psyscall;
        • 释放当前线程上的锁;
      • 恢复工作
        • 调用 runtime.exitsyscallfast;
          • 如果 Goroutine 的原处理器处于 _Psyscall 状态,会直接调用 wirep 将 Goroutine 与处理器进行关联;
          • 如果调度器中存在闲置的处理器,会调用 runtime.acquirep 使用闲置的处理器处理当前 Goroutine;
        • 切换至调度器的 Goroutine 并调用 runtime.exitsyscall0;
          • 当我们通过 runtime.pidleget 获取到闲置的处理器时就会在该处理器上执行 Goroutine;
          • 在其它情况下,我们会将当前 Goroutine 放到全局的运行队列中,等待调度器的调度;
  3. 协作式调度
    • runtime.Gosched 函数会主动让出处理器,允许其他 Goroutine 运行
      • 占用处理器超过10s
      • 触发GC