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