【Golang】有关任务窃取调度器和抢占式调度器的笔记
任务窃取调度器和抢占式调度器
- 前言
- GMP的简单介绍
- 什么是GMP?
- 任务窃取调度器
- 抢占式调度器
- 基于协作的抢占式调度器
- 基于信号的抢占式调度
前言
每一个OS线程都有一个固定大小的内存块(一般会是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。这个固定大小的栈同时很大又很小。因为2MB的栈对于一个小小的goroutine来说是很大的内存浪费,比如对于我们用到的,一个只是用来WaitGroup之后关闭channel的goroutine来说。而对于go程序来说,同时创建成百上千个goroutine是非常普遍的,如果每一个goroutine都需要这么大的栈的话,那这么多的goroutine就不太可能了。除去大小的问题之外,固定大小的栈对于更复杂或者更深层次的递归函数调用来说显然是不够的。修改固定的大小可以提升空间的利用率,允许创建更多的线程,并且可以允许更深的递归调用,不过这两者是没法同时兼备的。
相反,一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB。一个goroutine的栈,和操作系统线程一样,会保存其活跃或挂起的函数调用的本地变量,但是和OS线程不太一样的是,一个goroutine的栈大小并不是固定的;栈的大小会根据需要动态地伸缩。而goroutine的栈的最大值有1GB,比传统的固定大小的线程栈要大得多,尽管一般情况下,大多goroutine都不需要这么大的栈。
以上内容来自于《Go语言圣经》中对于goroutine的诠释。这篇博客主要是记录goroutine中调度的任务窃取调度器和抢占式调度器
GMP的简单介绍
GMP
模型是 Go 语言并发调度器的核心设计,它让 Go 能够高效、合理地组织和管理成千上万个 goroutine
什么是GMP?
-
G - Goroutine (协程)
这是 Go 并发的基本单位。它是一种用户态的轻量级线程,由 Go 运行时(runtime)管理,而不是操作系统。 -
M - Machine (工作线程/内核线程)
M 是真正在操作系统线程上执行的实体。它与内核线程是 1:1 的关系。M 的主要职责就是执行 G 的代码。一个 M 必须持有一个 P 才能运行 Go 的代码。 -
P - Processor (调度器/上下文)
P 是 G 和 M 之间的协调者,是 Go 调度器的关键创新。你可以把它看作一个“所需的运行上下文”或“资源包”。P 的数量决定了 Go 程序中真正可同时运行的 G 的最大数量,默认等于当前机器的 CPU 逻辑核心数(可通过GOMAXPROCS
环境变量调整)。每个 P 都维护着一个本地运行队列(Local Run Queue
, LRQ),用于存放本地的 G。这极大地减少了全局锁的竞争。
任务窃取调度器
任务窃取调度器是GMP模型中的一个补充,首先来看一下GMP模型是如何运作的
- G 的创建:当你执行 go func(…)时,一个新的 G 被创建。优先会加入到当前 P 的本地运行队列中。如果队列已满,则放入全局队列。
- M 与 P 绑定:一个空闲的 M 会尝试获取一个空闲的 P。如果获取成功,这个 M 就被激活,开始寻找 G 来执行。
- M 寻找 G(执行):M 会按照以下顺序寻找 G:从当前 P 的本地队列获取:这是最快的方式,几乎无锁。从全局队列获取:为了防止全局队列中的 G 被“饿死”,调度器会每隔一段时间就去全局队列检查一次。任务窃取(
Work-Stealing
):如果其他方式都找不到 G,当前 M 会随机选择另一个 P,并尝试从它的本地队列中“偷”一半的 G 过来执行。
任务窃取调度器的工作流程
- 检查本地运行队列(
LRQ
):这是常规操作,但既然 P 是空闲的,这里肯定为空。 - 检查全局运行队列(
GRQ
):它会检查全局队列中是否有等待的 G。为了防止全局队列中的 G 被“饿死”,调度器规定每调度 61 次(通过一个计数器 schedtick控制),就会优先从全局队列获取 G。 - 检查网络轮询器(
Netpoller
):检查是否有网络 IO 操作已经准备就绪的 G。如果有,就唤醒它来执行。 - 任务窃取(
Work-Stealing
):如果以上地方都找不到工作,P 就会开始“窃取”行为。(窃取者会尝试从其他P中的本地运行队列的尾部偷走一半的Goroutine
)
抢占式调度器
基于协作的抢占式调度器
首先在了解这个调度器的如何工作之前,需要知道几点前置知识
- 系统监控线程(
sysmon
)每10ms会扫描所有运行中的G,当发现G运行的时间超过10ms或者GC需要STW
的时候,会将stackguard0
字段设置StackPreempt
(StackPreempt
=0xfffffade
是最大地址值),代码类似于这样:atomic.Storeuintptr(&gp.stackguard0, StackPreempt)
- 由于栈是“向下”增长的,使用的时候也是先从高地址的空间开始使用(其实这也就是为什么要将
stackguard0
设置为StackPreempt
,在了解该调度器的时候,心里一定要有这个概念)。
前置知识了解完,现在开始说这个调度器的执行步骤
1、在编译期的时候,会在函数入口处插入指令,用来检查比较记录栈顶位置的寄存器SP
和stackguard0
中值的大小
2、如果SP
中的值小于stackguard0
的值,说明当前有两种情况。
- 第一种情况:检查
stackguard0
的值是否为StackPreempt
,如果是的话,说明当前要执行垃圾回收的逻辑,需要暂停当前当前程序 - 第二种情况,也就是
stackguard0
的值不是StackPreempt
,此时的SP
中的值小于stackguard0
的值,说明当前栈空间不足需要扩容
为什么说SP
中的值小于stackguard0
的值(前提是stackguard0
的值不为StackPreempt
)就需要扩容?
首先来看一张图
高地址
├─────────────┤
│ 正常栈空间 │ ← SP通常在这里
├─────────────┤ ← stackguard0 (安全线)
│ 缓冲区928B │ ← 当SP接近这里时触发检查
├─────────────┤ ← stack.lo (真正的栈底)
│ 危险! │
└─────────────┘
低地址
当SP
指向的位置小于stackguard0
时,其实就已经超出了限定的“安全范围”,此时SP
指向的位置处于缓冲区,还没有超过当前协程栈的边界
基于信号的抢占式调度
基于协作的抢占式调度有一个问题在于它需要程序中有函数调用才有机会触发,如果没有函数调用,那可能永远没办法执行调度的逻辑。为了解决这个问题,有引入了基于信号的抢占式调度。下面来看它的工作流程
- 初始化信号处理:在Go程序启动时,注册
SIGURG
信号的处理函数为runtime.doSigPreempt
。 - 触发抢占:当需要抢占某个
Goroutine
时(例如GC栈扫描需要挂起Goroutine
),运行时会调用runtime.suspendG
函数,该函数将Goroutine
标记为可抢占(设置preemptStop
为true),然后调用runtime.preemptM
。 - 发送信号:
runtime.preemptM
通过runtime.signalM
向目标线程发送SIGURG
信号。 - 中断处理:目标线程收到
SIGURG
信号,操作系统中断当前线程的执行,并跳转到预先注册的信号处理函数runtime.doSigPreempt
。 - 修改执行上下文:在
runtime.doSigPreempt
中,通过runtime.sigctxt.pushCall
修改被中断线程的上下文。具体来说,它会在栈上模拟一个函数调用,使得信号处理返回后,程序会跳转到runtime.asyncPreempt
函数。 - 执行异步抢占:当信号处理返回后,程序执行
runtime.asyncPreempt
(汇编函数),该函数会保存寄存器状态,然后调用runtime.asyncPreempt2
(Go函数)。 - 处理抢占:
runtime.asyncPreempt2
调用runtime.preemptPark
,该函数将当前Goroutine
的状态从_Grunning
改为_Gpreempted
,然后调用runtime.schedule
触发调度,让出当前线程。 - 重新调度:调度器
runtime.schedule
会选择一个就绪的Goroutine
在当前线程上执行。
流程图如下
基于信号的抢占式调度只解决了垃圾回收和栈扫描时存在的问题,它到目前为止没有解决所有问题,但是这种真抢占式调度是调度器走向成熟的开始