菜单

Goroutine 调度器

相关源文件

Goroutine 调度器负责将就绪的 Goroutine 分发到工作线程上。它通过工作窃取模型实现并发,该模型可将 Goroutine 高效地多路复用到 OS 线程上,同时保持高性能和可伸缩性。本文档描述了调度器的架构、核心组件以及支持 Go 轻量级并发模型的调度机制。

有关运行时内存管理的信息,请参阅 内存管理。有关与调度器交互的垃圾回收器的信息,请参阅 垃圾回收

核心概念:G-P-M 模型

Go 调度器建立在三个主要抽象之上

  • G (Goroutine):Goroutine 是 Go 中的基本执行单元。每个 G 都有自己的堆栈,可以根据需要增长和收缩。Goroutine 是轻量级的(初始堆栈大小仅为几 KB),允许程序创建数千个 Goroutine。

  • M (Machine):M 代表一个 OS 线程。M 的数量在执行期间可能会变化,但通常受 GOMAXPROCS 的限制。M 必须附加到 P 才能执行 Go 代码,尽管它们可以在不附加 P 的情况下阻塞在系统调用中。

  • P (Processor):P 是一个逻辑处理器,提供执行 Goroutine 所需的资源。P 的数量由 GOMAXPROCS 确定。每个 P 维护一个本地运行队列,其中包含已准备好执行的 Goroutine。

来源:src/runtime/proc.go22-32 src/runtime/runtime2.go395-757

调度器数据结构

调度器管理几个关键数据结构来跟踪和调度 Goroutine

G (Goroutine) 结构

每个 Goroutine 由一个 g 结构体表示,包含

  • 堆栈信息 (lo, hi, stackguard)
  • 当前状态 (运行中、可运行、等待中等)
  • 调度数据
  • 通道等待队列和其他阻塞信息
  • 用于保存/恢复状态的执行上下文

P (Processor) 结构

每个 P 包含

  • 一个本地运行队列,限制为 256 个 Goroutine
  • 一个用于高优先级 Goroutine 的 runnext 字段
  • 未使用的 Goroutine 缓存
  • 当前在该 P 上执行的 M 的引用

M (Machine) 结构

M 结构跟踪

  • 当前正在执行的 Goroutine
  • 附加到的 P
  • 系统线程状态
  • 切换 Goroutine 时使用的调度器堆栈

调度器结构

全局调度器结构 (schedt) 维护

  • 空闲的 P 和 M
  • 超过本地队列容量的 Goroutine 的全局运行队列
  • 统计信息和协调机制

来源:src/runtime/runtime2.go395-507 src/runtime/runtime2.go527-622 src/runtime/runtime2.go636-757 src/runtime/runtime2.go759-829

Goroutine 状态和转换

Goroutine 在其生命周期中会经历各种状态

关键的 Goroutine 状态包括:

  • _Gidle:刚分配,未初始化
  • _Grunnable:在运行队列中,准备执行
  • _Grunning:当前正在 M 上执行
  • _Gsyscall:正在执行系统调用
  • _Gwaiting:阻塞在同步原语上(例如,通道操作、互斥锁)
  • _Gdead:已退出,可重用

这些状态通过原子操作进行管理,以确保线程安全。状态转换由 casgstatus() 等函数处理,这些函数会执行仔细的状态更改,并带有适当的内存屏障。

来源:src/runtime/runtime2.go36-107 src/runtime/proc.go1149-1168

调度流程

Goroutine 创建

当执行 go 语句时

  1. 运行时会分配一个新的 G 结构体
  2. 设置堆栈,包含要执行的函数
  3. G 被标记为可运行
  4. G 被放入运行队列(通常是当前 P 的本地队列)

运行队列管理

调度器同时使用本地和全局运行队列

  1. 本地运行队列:每个 P 都有一个最多包含 256 个 Goroutine 的队列
  2. 全局运行队列:用于没有 P 亲和性或本地队列已满的 Goroutine

当 P 需要查找工作时

  1. 首先检查其本地运行队列
  2. 如果为空,则检查全局运行队列
  3. 如果仍然没有工作,则尝试从其他 P 的本地队列窃取工作

调度函数

schedule() 函数是调度器的核心。当一个正在运行的 Goroutine 放弃处理器(由于阻塞、系统调用或抢占)时,会调用 schedule() 来查找要运行的新 Goroutine。

  1. 尝试从本地运行队列获取 Goroutine
  2. 如果没有可用 Goroutine,则尝试全局队列
  3. 如果仍然没有,则尝试从其他 P 窃取
  4. 如果没有可运行的 Goroutine,则轮询网络或让 M 挂起

Goroutine 挂起和唤醒

当 Goroutine 阻塞时(例如,在通道或互斥锁上),它会

  1. 调用 gopark() 并提供原因和唤醒函数
  2. 被标记为等待中
  3. 从处理器中移除
  4. 调度器运行另一个 Goroutine

当它等待的条件满足时,运行时会调用 goready()

  1. 将 Goroutine 标记为可运行
  2. 将其放入运行队列
  3. 在需要时唤醒 M

来源:src/runtime/proc.go385-411 src/runtime/proc.go441-457 src/runtime/proc.go1062-1083

工作窃取

Go 调度器使用工作窃取来平衡处理器之间的负载

当 P 耗尽工作时

  1. 它首先检查全局运行队列
  2. 如果全局队列为空,它会随机选择另一个 P
  3. 它尝试从该 P 的本地队列中窃取大约一半的 Goroutine
  4. 它会持续此过程,直到找到工作或决定挂起

这种工作窃取方法有助于保持良好的负载平衡,同时保留局部性,因为 Goroutine 倾向于在创建它们的 P 上运行。

来源:src/runtime/proc.go127-145 src/runtime/export_test.go127-169

线程管理

调度器会仔细管理 OS 线程以优化资源使用

线程挂起和唤醒

当没有可用工作时,M 可能会自行挂起

  1. M 将自己放在空闲 M 列表上
  2. 它释放其 P
  3. 它进入睡眠状态,等待信号量
  4. 当有工作可用时,会通过信号量信号唤醒一个 M

线程创建

创建新线程的时机是:

  1. Goroutine 执行阻塞系统调用,导致其 M 释放 P
  2. 调度器找不到任何挂起的 M 来处理就绪的 Goroutine
  3. 系统扩展需要更多并行性(例如,GOMAXPROCS 增加)

自旋线程

一个重要的性能优化是“自旋”线程的概念

  1. 当 P 没有工作,但系统中存在可运行的 Goroutine 时
  2. M 会自旋(积极寻找工作)而不是立即挂起
  3. 这减少了新创建 Goroutine 的延迟
  4. 调度器会限制自旋 M 的数量以控制 CPU 使用率

来源:src/runtime/proc.go34-114 src/runtime/proc.go1039-1042 src/runtime/proc.go1062-1083

Goroutine 抢占

Go 使用多种机制来抢占正在运行的 Goroutine

协作式抢占

Goroutine 在以下时机进行协作式抢占

  1. 函数调用(通过堆栈检查)
  2. 内存分配
  3. 通道操作

异步抢占

自 Go 1.14 起,运行时还支持异步抢占

  1. 运行时会定期向线程发送 SIGURG 信号
  2. 信号处理程序会设置一个标志,强制在下一个安全点进行抢占
  3. 这确保了长时间运行的 Goroutine 不会垄断线程

抢占对于公平性至关重要,可确保所有 Goroutine 都能获得执行时间,尤其是在垃圾回收期间。

来源:src/runtime/preempt.go5-13 src/runtime/proc.go407-411

调度器初始化和启动

调度器初始化发生在运行时启动过程中

  1. schedinit() 中,运行时会

    • 初始化各种锁和数据结构
    • 设置初始 Goroutine G0
    • 根据 GOMAXPROCS 分配 P 结构
    • 准备调度器状态
  2. 创建主 Goroutine 并进行调度

  3. 启动系统监控 Goroutine

  4. 用户代码开始执行

来源:src/runtime/proc.go821-921 src/runtime/proc.go147-186

与垃圾回收器的交互

调度器与垃圾回收器紧密协调

  1. 在垃圾回收期间,调度器确保 Goroutine 协助标记工作
  2. GC 可以请求抢占 Goroutine 来执行 GC 工作
  3. 一些 GC 操作需要所有 Goroutine 都到达安全点

这种协调对于在垃圾回收周期中保持良好性能至关重要。

来源: src/runtime/mgc.go22-82 src/runtime/proc.go1144-1146

计时器管理

调度器集成了 Go 的计时器系统

  1. 每个 P 都维护一个计时器堆
  2. 调度器在适当的时候会检查已过期的计时器
  3. 当计时器到期时,它可以
    • 在通道上发送值
    • 执行一个函数
    • 唤醒一个睡眠的 goroutine

这种集成使得 time.Sleep()time.After() 及其相关函数能够得到高效实现。

来源: src/runtime/time.go46-117 src/runtime/time.go316-351

结论

Go 调度器的设计通过 G-P-M 模型、工作窃取方法和高效的线程管理,实现了高度高效的 goroutine 管理,并将线程调度的复杂性从程序员那里抽象出来。这使得 Go 程序能够有效地利用可用的 CPU 资源进行扩展,同时保持 goroutine 创建和上下文切换的低开销。

调度器仍处于积极开发阶段,在抢占、公平调度和极端情况下的性能方面不断改进。