Go goroutine 调度器

本文将介绍 Go 中的 goroutine 调度器。

一、为什么需要 goroutine 调度器?

在传统编程语言的并发实现大多基于操作系统线程,程序负责创建线程,操作系统负责调度线程。

对于 Go 语言的并发实现而言,goroutine 对操作系统而言是不可见的,其具体调度应该由 Go 负责,因此,Go 设计了 goroutine 调度器,它会按照一定的算法将 goroutine 放到操作系统线程中执行。

二、goroutine 调度器的迭代

1. 单线程调度器

  • 所有的 goroutine 只能被调度到一个线程之上
  • 基于 G-M 模型:
    • G 对应 goroutine,M 对应线程
    • 调度器工作就是将 G 调度到 M 上运行

2. 多线程调度器

  • Go 1.0 版本的实现
  • goroutine 可以被调度到多个线程之上
  • M 之间经常需要传递 G,导致调度延迟和性能损耗
  • 每个 M 都需要处理内存缓存,导致内存占用过高,并且数据局部性差

3. 任务窃取调度器

  • 基于 G-P-M 模型:

    • G 对应 goroutine,P 对应处理者,M 对应线程
    • 每个 P 会维护一个 G 队列,队列中包含所有 “待运行” 的 G
    • 每个 P 会绑定一个 M,P 只会将 G 调度到其绑定的 M 上执行
  • 当某个 P 的 G 队列为空时,它会触发任务窃取,从其它 P 的 G 队列中随机 “偷走” 一些,从而实现任务的再分配

  • 不支持抢占式调度,只能依靠 goroutine 主动让出 CPU

4. 基于协作的抢占式调度器

  • Go 1.2 版本的实现
  • 抢占方式:
    • 编译器编译时,在函数的入口处增加额外的插入抢占检查指令
    • 当希望中断 G 时,向 G 发出抢占请求
    • G 进行函数调用前,将会执行抢占检查指令,若发现有抢占请求,便会让出 CPU
  • 只能在函数调用处抢占

5. 基于信号的抢占式调度器

  • Go 1.14 版本的实现
  • 抢占方式:
    • 程序启动时,注册 SIGURG 信号的处理函数 doSigPreempt
    • 当希望中断 G 时,向 M 发送 SIGURG 信号
    • 信号发送给 M 后,操作系统将中断 M 的运行并执行 doSigPreempt,该函数将正在执行的 G 放回队列,M 继续寻找其它 G 来运行

6. 非均匀内存访问调度器

  • 分区,G - P 将只在分区下的 M 中运行,从而能够就近获取资源,减少锁竞争,增加数据局部性

三、调度的触发

  • 当 Go 运行时认为 G 执行时间过长时,将会发出抢占请求 / SIGURG 信号,中断其运行
  • 当 G 堵塞在 channel 上,G 将会被放置到等待队列中;待操作完成后,会被唤醒并重新放入 P 的队列中等待执行
  • 当 G 堵塞在 I/O 操作上,G 将会被放置到等待队列中;待操作完成后,会被唤醒并重新放入 P 的队列中等待执行
  • 当 G 堵塞在系统调用上时,G 和 M 都将会阻塞,与 M 绑定的 P 将会绑定其它 M(寻找一个空闲的 M 或者创建一个新的 M);当系统调用返回后,M 继续挂起,G 寻找可用的 P

参考