GO调度器
礼物说在Go开发过程中,Go调度器是无法避开的一座大山。我们在本文中详细了解下Go调度器是如何工作的。
历史
- 1.0 GM模型
- 缺点
- 全局G队列,锁竞争严重
- 缺点
- 1.1 引入P GMP模型
- 每个P都拥有本地G队列
- 解决
全局锁导致竞争严重
的问题 - 基于工作窃取
- 开启协程优先放入本地队列,一定几率放入全局队列
- 正常情况直接从本地队列获取下一个可运行的G
- 本地为空时 从全局获取一部分到本地/从其他P获取一部分到本地
- 缺点
某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;
垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作;
- 解决
- 每个P都拥有本地G队列
- 1.2-1.14:基于协作的
抢占式调度器
- 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
- G结构体上增加
stackguard0
,当值为StackPreempt
时表示允许抢占。 - 当STW或者运行时长>10ms时,设置字段为
StackPreempt
。 - 发生函数调用时会检查
stackguard0
字段,为StackPreempt
则触发抢占让出线程。 - 解决
程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度
- 解决
某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;
- 解决
垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作;
- G结构体上增加
- 缺点
- for循环/内联 无法被抢占,垃圾回收长时间占用线程。
- 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
- 1.14~now :基于信号的
抢占式调度器
- 程序启动时,在
runtime.sighandler
中注册 SIGURG 信号的处理函数runtime.doSigPreempt
; - sysmon 线程检测到执行时间过长的 goroutine、GC stw 时,会向相应的 M(或者说线程,每个线程对应一个 M)发送 SIGURG 信号。
- 线程让出线程,继续选择其他G执行
- 程序启动时,在
数据结构 GMP模型
G
表示 Goroutine,它是一个待执行的任务,占用了更小的内存空间,也降低了上下文切换的开销。相当于用户态的线程,作为一种粒度更细的调度单元。常见状态:_Grunnable
、_Grunning
、_Gsyscall
、_Gwaiting
和 _Gpreempted
。
M
表示操作系统的线程,它由操作系统的调度器调度和管理;Go调度器最多可以创建10000个线程,有GOMAXPROCS
个活跃线程能够正常运行比较关键的字段:
type m struct {
*g
g0 *g
curg ...
}
g0 是持有调度栈的 Goroutine.是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行。 curg 是在当前线程上运行的用户 Goroutine
P
调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。
在启动时就会创建 GOMAXPROCS 个处理器
struct P {
m muintptr
uint32
runqhead uint32
runqtail [256]guintptr
runq
runnext guintptr};
反向存储的线程维护着线程与处理器之间的关系,而 runqhead、runqtail 和 runq 三个字段表示处理器持有的运行队列,其中存储着待执行的 Goroutine 列表,runnext 中是线程下一个需要执行的 Goroutine。
调度器的目的是为了把可运行的G分配到工作M上。而P可以理解为拥有G的资源,所以而M想要运行G时,先获取P。 P在启动时就会创建,GOMAXPROCS(可配置) 个。 M数量受SetMaxThreads限制,P会主动去寻找空闲的M,如果没有则创建,堵塞后就会切换另一个M.
调度过程
- 调度器启动时 g0会初始化线程并调用
runtime.schedule
进入调度循环 runtime.schedule
函数会从下面几个地方查找待执行的 Goroutine:- 当全局运行队列中有待执行的 Goroutine 时,通过 schedtick 保证有一定几率会从全局的运行队列中查找对应的 Goroutine
- 从处理器本地的运行队列中查找待执行的 Goroutine;
- 如果前两种方法都没有找到 Goroutine,会通过 runtime.findrunnable 进行阻塞地查找 Goroutine;
- 从本地运行队列、全局运行队列中查找;
- 从网络轮询器中查找是否有 Goroutine 等待运行;
- 通过 runtime.runqsteal 尝试从其他随机的处理器中窃取待运行的 Goroutine,该函数还可能窃取处理器的计时器;
- 由
runtime.execute
执行获取的 Goroutine - 最终在当前线程的 g0 的栈上调用
runtime.goexit0
函数.该函数会将 Goroutine 转换会 _Gdead 状态、清理其中的字段、移除 Goroutine 和线程的关联并调用 runtime.gfput 重新加入处理器的 Goroutine 空闲列表 gFree - 再循环
runtime.schedule
函数
触发调度
- 主动挂起
- channel堵塞/锁自旋完之后/time.Sleep/io操作
- 将当前 Goroutine 的状态从 _Grunning 切换至 _Gwaiting
- 当 Goroutine 等待的特定条件满足后,运行时会调用 runtime.goready 将因为调用 runtime.gopark 而陷入休眠的 Goroutine 唤醒。
- runtime.ready 会将准备就绪的 Goroutine 的状态切换至 _Grunnable 并将其加入处理器的运行队列中,等待调度器的调度。
- 移除线程和 Goroutine 之间的关联
- 调用 runtime.schedule 触发新一轮的调度
- 系统调用
- 系统调用也会触发运行时调度器的调度
- Go 语言通过
syscall.Syscall
和syscall.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 放到全局的运行队列中,等待调度器的调度;
- 调用 runtime.exitsyscallfast;
- 准备工作
- 协作式调度
- runtime.Gosched 函数会主动让出处理器,允许其他 Goroutine 运行
- 占用处理器超过10s
- 触发GC
- runtime.Gosched 函数会主动让出处理器,允许其他 Goroutine 运行