Go-调度器
Go-调度器
多个线程可以属于同一个进程并共享内存空间。因此它们也不需要内存管理单元处理上下文的切换,线程之间的通信是基于共享的内存进行的。
虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1M 以上的内存空间,在切换线程时不止会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁资源,每一次线程上下文的切换都需要消耗 ~1us 左右的时间1,但是 Go 调度器对 Goroutine 的上下文切换约为 ~0.2us,减少了 80% 的额外开销。
Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载。
设计原理
包括以下几个版本:
- 单线程调度器:程序中只能存在一个活跃线程,由G-M模型组成;
单线程调度器
0.x 版本调度器只包含:Goroutine(G)和 线程(M)两种结构,全局只有一个线程。如下:
1 | static void |
包括以下步骤:
- 保存和恢复上下文:
lock(&sched)
获取调度器的全局锁,调用runtime.gosave:9682400
保存栈寄存器和程序计数器; - 选择下一个要运行的goroutine:调用
runtime.nextgandunlock:9682400
获取下一个需要运行的 Goroutine 并解锁调度器;修改全局线程m
上要执行的 Goroutine;调用runtime.gogo:9682400
函数运行最新的 Goroutine;
多线程调度器
整体的逻辑与单线程调度器差异不大。因为程序中可能同时存在多个活跃线程,所以多线程调度器引入 GOMAXPROCS
变量帮助灵活控制程序中的最大处理器数,即活跃线程数。
1 | static void schedule(G *gp) { |
多线程调度器的问题:锁竞争严重浪费资源。有以下问题待解决:
- 单一全局互斥锁(
Sched.Lock
)和集中的状态:当前的调度器使用一个全局的互斥锁保护所有与goroutine相关的操作(如创建、完成、重新调度等); - Goroutine的传递(
G.nextg
):工作线程(M)经常在它们之间传递可运行的goroutine,这可能导致延迟和额外的开销。每个M必须能够执行任何可运行的G,特别是刚刚创建的G; - 每个M的内存缓存(
M.mcache
):内存缓存和其他缓存(如栈分配)与所有M关联,但实际上这些缓存只需要与正在运行Go代码的M关联(处于系统调用中的M不需要缓存)。在一些情况下,运行Go代码的M与所有M的比例可能高达1:100,这会导致过度的资源消耗(每个MCache可能会占用多达2MB的内存)和较差的数据局部性; - 频繁的线程阻塞和解除阻塞:在系统调用的情况下,工作线程会频繁地被阻塞和解除阻塞,这增加了额外的开销。
任务窃取调度器
Scalable Go Scheduler Design Doc 对于多线程调度器的若干问题,引入处理器P,在此基础上实现任务窃取调度器。
M
表示操作系统线程(与现有实现相同);P
表示执行Go代码所需的资源:当M执行Go代码时,它需要一个与之关联的P;当M处于空闲或系统调用状态时,不需要P。系统中有GOMAXPROCS
个P
,所有P
被组织成一个数组(调整GOMAXPROCS
时,需要暂停并重新启动程序以调整P的数量)
处理器P
相较于多线程调度器:调度器的一些变量将从sched
中去中心化并移动到P
中,部分与Go代码执行相关的M的变量也将移到P
中。
1 | struct P { |
处理器持有一个由就绪的Goroutine组成的环形就绪队列runq
,且反向持有一个线程;调度时从处理器P的就绪队列中,选择队头的Goroutine放到线程M上执行。
基于工作窃取的多线程调度器将每一个线程绑定到了独立的 CPU 上(M和P绑定);这些线程会被不同处理器管理,不同的处理器P通过工作窃取对Goroutine进行再分配实现任务的平衡,能提升调度器和 Go 语言程序的整体性能。
调度
- 当创建一个新的
G
或者一个G
转为就绪状态时:当前G
被推入当前P
goroutines的就绪队列; - 当
P
完成G
的执行后,首先尝试从自己的goroutine就绪队列中弹出一个G
以继续执行;如果队列为空,P
会选择一个随机的受害者(另一个P
),并尝试从它那里窃取一半的就绪goroutines。(充分利用处理器资源)
Syscall/M的挂靠和解挂靠
当M
创建一个新的G时,必须确保有一个M
来执行该G
(如果所有M
都已经忙碌);同样,当M
进入系统调用时,它必须确保有其他的M
可以继续执行Go代码。有两种选择:立即阻塞和解除阻塞M
;或者使用一些自旋(消耗一些CPU周期)。这是性能和不必要的CPU消耗之间的冲突。为了避免影响GOMAXPROCS=1
的程序,采取了一些自旋策略。
自旋是分两级的:
- 与
P
关联的空闲M
在等待新G
时进行自旋; - 无
P
关联的M
等待可用的P
。
这种自旋方式主要是被动的(通过yield
或sched_yield()
),但可能会包括一些主动的自旋(通过循环消耗CPU),需要进一步研究和优化。
自旋锁:一种轻量级的同步机制,广泛应用于多线程编程和操作系统内核中。它通过忙等待(busy-waiting)的方式,让线程在尝试获取锁时不断循环检查锁的状态,直到成功获取锁为止。
看看一个版本的实现:
1 | static void |
工作流程:
- 当一个Goroutine被创建时:它被放入一个P的本地队列;
- 若P的本地队列满了,或者某个G长期未被调度执行:P尝试从全局队列获取G;
- 若全局队列为空:P从其他P的本地队列中,窃取一些G,放在当前M上运行,以保证尽可能多地利⽤所有的处理器。
工作窃取函数:runtime.findrunnable:779c45a
抢占式调度器
解决1.1版本中,程序只能依靠Goroutine主动让出CPU资源才能触发调度的问题。
基于协作的抢占式调度
工作原理:
- 编译器会在调用函数前插入
runtime.morestack
; - Go 语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms 时发出抢占请求
StackPreempt
; - 当发生函数调用时,可能会执行编译器插入的
runtime.morestack
,它调用的runtime.newstack
检查 Goroutine 的stackguard0
字段是否为StackPreempt
; - 如果
stackguard0
是StackPreempt
,就会触发抢占让出当前线程;
抢占通过编译器在函数调用时插入抢占检查指令实现:在函数调用时检查当前Goroutine是否发起了抢占请求,因此需要函数调用作为入口才能触发抢占。
然而,Goroutine可能因为垃圾回收和循环长时间占用资源导致程序暂停。
基于信号的抢占式调度
- 注册信号处理函数:程序启动时,在
runtime.sighandler
中注册SIGURG
信号的处理函数runtime.doSigPreempt
; - 在触发垃圾回收的栈扫描时,会调用
runtime.suspendG
挂起 Goroutine,该函数会执行下面的逻辑:- 将
_Grunning
状态的 Goroutine 标记成可以被抢占,即将preemptStop
设置成true
; - 调用
runtime.preemptM
触发抢占;
- 将
- 发送信号:
runtime.preemptM
会调用runtime.signalM
向线程发送信号SIGURG
; - 接收信号后,OS执行信号处理函数:操作系统会中断正在运行的线程,并执行预先注册的信号处理函数
runtime.doSigPreempt
; runtime.doSigPreempt
函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用r
untime.sigctxt.pushCall`;runtime.sigctxt.pushCall
会修改寄存器并在程序回到用户态时执行runtime.asyncPreempt
;- 汇编指令
runtime.asyncPreempt
会调用运行时函数runtime.asyncPreempt2
; runtime.asyncPreempt2
会调用runtime.preemptPark
;runtime.preemptPark
会修改当前 Goroutine 的状态到_Gpreempted
,并调用runtime.schedule
让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行;
这里抢占的安全点包括:STW(Stop-the-world,垃圾回收时暂停整个程序);栈扫描
数据结构
调度器包括3个重要组成部分:
- G:表示Goroutine,一个待执行的任务;
- M:表示操作系统线程;
- P:表示处理器。
G
Goroutine 是 Go 语言调度器中待执行的任务,只存在于Go语言运行时,是Go语言在用户态提供的线程。使用结构体runtime.g
。
1 | type g struct { |
sched
字段的runtime.gobuf
结构体
1 | type gobuf struct { |
Goroutine
的状态(对应atomicstatus
字段)
状态 | 描述 |
---|---|
_Gidle | 刚刚被分配并且还没有被初始化 |
_Grunnable | 没有执行代码,没有栈的所有权,存储在运行队列中 |
_Grunning | 可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P |
_Gsyscall | 正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上 |
_Gwaiting | 由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上 |
_Gdead | 没有被使用,没有执行代码,可能有分配的栈 |
_Gcopystack | 栈正在被拷贝,没有执行代码,不在运行队列上 |
_Gpreempted | 由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒 |
_Gscan | GC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在 |
主要归为3类:
- 等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括
_Gwaiting
、_Gsyscall
和_Gpreempted
几个状态; - 可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即
_Grunnable
; - 运行中:Goroutine 正在某个线程上运行,即
_Grunning
;
M
M是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS
个活跃线程能够正常运行。(默认情况下,运行时会将 GOMAXPROCS
设置成当前机器的核数,不会频繁触发操作系统的线程调度和上下文切换,所有的调度都发生在用户态,由 Go 语言调度器触发,减少额外开销)
在默认情况下,一个四核机器会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m
结构体。
1 | type m struct { |
P
处理器P是线程和Goroutine的中间层,能提供线程需要的上下文环境,负责调度线程上的等待队列;通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。
调度器在启动时会创建 GOMAXPROCS
个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS
,这些处理器会绑定到不同的内核线程上。
1 | type p struct { |
P
的状态(对应status
字段)
状态 | 描述 |
---|---|
_Pidle | 处理器没有运行用户代码或者调度器,被空闲队列或者改变其状态的结构持有,运行队列为空 |
_Prunning | 被线程 M 持有,并且正在执行用户代码或者调度器 |
_Psyscall | 没有执行用户代码,当前线程陷入系统调用 |
_Pgcstop | 被线程 M 持有,当前处理器由于垃圾回收被停止 |
_Pdead | 当前处理器已经不被使用 |
调度器启动
1 | func schedinit() { |
调用 runtime.procresize
是调度器启动的最后一步,在这一步过后调度器会完成相应数量处理器的启动,等待用户创建运行新的 Goroutine 并为 Goroutine 调度处理器资源。
创建Goroutine
通过关键字go
启动一个新的Goroutine来执行任务。编译器将该关键字转为runtime.newproc
函数调用,然后调用 runtime.newproc1
函数获取新的 Goroutine 结构体、将其加入处理器的运行队列并在满足条件时调用 runtime.wakep
唤醒新的处理执行 Goroutine:
1 | func newproc(siz int32, fn *funcval) { |
runtime.newproc1
会根据传入参数创建一个状态为_Grunnable
的 g 结构体,分为以下几个部分:
- 获取或者创建新的 Goroutine 结构体;
- 将传入的参数移到 Goroutine 的栈上;
- 更新 Goroutine 调度相关的属性。
1 | func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g { |
初始化结构体(runtime.gfget
)
调用链:newproc
->newproc1
->gfget
(+malg
)
runtime.gfget
通过两种不同方式获取新的runtime.g
- 从 Goroutine 所在处理器的
gFree
列表,或者调度器的sched.gFree
列表中获取runtime.g
;- 当处理器的
gFree
列表为空时:将调度器持有的空闲 Goroutine 转移到当前处理器上,直到gFree
列表中的 Goroutine 数量达到32; - 当处理器的 Goroutine 数量充足时:从列表头部返回一个新的 Goroutine;
- 当处理器的
- 调用
runtime.malg
生成一个新的runtime.g
,并将结构体追加到全局的 Goroutine 列表allgs
中。
设置调度信息sched
调用链:newproc
->newproc1
->...
1 | newg.sched.pc = funcPC(goexit) + sys.PCQuantum // 程序计数器设置为:runtime.goexit |
运行队列(runqput
)
调用链:newproc
->runqput
runtime.runqput
会将 Goroutine 放到运行队列上,这既可能是全局的运行队列,也可能是处理器本地的运行队列:
- 当
next
为true
时:将 Goroutine 设置到处理器的runnext
作为下一个处理器执行的任务; - 当
next
为false
且本地运行队列还有剩余空间时:将 Goroutine 加入处理器持有的本地运行队列; - 当
next
为false
且处理器的本地运行队列已经没有剩余空间时:把本地队列中的一部分 Goroutine 和待加入的 Goroutine 通过runtime.runqputslow
添加到调度器持有的全局运行队列上;
1 | func runqput(_p_ *p, gp *g, next bool) { |
两个运行队列辨析:处理器本地的运行队列 & 调度器持有的全局运行队列 仅当本地运行队列没有剩余空间时,才使用全局队列。
调度循环:查找可执行的Goroutine
调度器启动之后,Go 语言运行时会调用 runtime.mstart
以及 runtime.mstart1
:前者初始化 g0
的 stackguard0
和 stackguard1
字段;后者初始化线程并调用 runtime.schedule
进入调度循环,采用三种方法查找待执行的Goroutine:
1 | func schedule() { |
runtime.gogo
在不同处理器架构上的实现不同,
- 函数调用时,模仿
CALL
过程的几个关键指令如下:
1 | MOVL gobuf_sp(BX), SP // 将 runtime.goexit 函数的 PC 恢复到 SP 中 |
函数调用使用
CALL
指令:将调用方的返回地址加入栈寄存器SP,跳转到目标函数;当目标函数返回后,会从栈中查找调用的地址,并跳转回调用方继续执行剩下的代码。
- 从 Goroutine 中运行的函数返回时,跳转到
runtime.goexit
所在位置执行该函数:
1 | TEXT runtime·goexit(SB),NOSPLIT,$0-0 |
最终在当前线程的 g0
的栈上调用 runtime.goexit0
函数,该函数会将 Goroutine 转换为 _Gdead
状态、清理其中的字段、移除 Goroutine 和线程的关联并调用 runtime.gfput
重新加入处理器的 Goroutine 空闲列表 gFree
:
1 | func goexit0(gp *g) { |
以上是Goroutine正常执行和退出的逻辑;多数情况下Goroutine执行过程中,会经历协作式/抢占式调度,让出线程使用权并等待调度器唤醒。
触发调度
找到runtime.schedule
函数的调用方,也即所有触发调度的时间点:
除此之外,运行时还会在线程启动 runtime.mstart
和 Goroutine 执行结束 runtime.goexit0
触发调度。
主动挂起
调用链: runtime.gopark
-> runtime.park_m
runtime.gopark
是触发调度最常见的方法,该函数会将当前 Goroutine 暂停,被暂停的任务不会放回运行队列,而是等待唤醒:
1 | func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) { |
最后一行通过runtime.mcall
切换到g0的栈上调用runtime.park_m
:将当前 Goroutine 的状态从 _Grunning
切换至 _Gwaiting
,调用 runtime.dropg
移除线程和 Goroutine 之间的关联,在这之后就可以调用 runtime.schedule
触发新一轮的调度了。
1 | func park_m(gp *g) { |
当 Goroutine 等待的特定条件满足后,运行时会调用 runtime.goready
将因为调用 runtime.gopark
而陷入休眠的 Goroutine 唤醒。
1 | func goready(gp *g, traceskip int) { |
系统调用
在系统调用前后,会调用运行时的runtime.entersyscall
和 runtime.exitsyscall
,这层包装能够在陷入系统调用前触发运行时的准备和清理工作。
准备工作:分离线程和处理器,释放锁
runtime.entersyscall
会在获取当前程序计数器和栈位置之后调用runtime.reentersyscall
,完成 Goroutine 进入系统调用前的准备工作:使得处理器和线程分离,当前线程会陷入系统调用等待返回;在锁被释放后,会有其他Goroutine抢占处理器资源。
1 | func reentersyscall(pc, sp uintptr) { |
恢复工作:为当前Goroutine重新分配资源
调用退出系统调用的函数runtime.exitsyscall
为当前Goroutine重新分配资源,有两个不同的执行路径:
- 调用快速路径
runtime.exitsyscallfast
:- 如果Goroutine的原处理器处于
_Psyscall
状态:直接调用wirep
将 Goroutine 与处理器进行关联; - 如果调度器中存在闲置的处理器,会调用
runtime.acquirep
使用闲置的处理器处理当前 Goroutine;
- 如果Goroutine的原处理器处于
- 调用
runtime.exitsyscall0
,将当前Goroutine切换至_Grunnable
状态,并移除线程M和当前Goroutine的关联:- 如果通过
runtime.pidleget
获取到闲置的处理器:在该处理器上执行Goroutine; - 其他情况:将当前Goroutine放到全局的运行队列中,等待调度器的调度。
- 如果通过
1 | func exitsyscall() { |
协作式调度
runtime.Gosched
函数主动让出处理器,允许其他Goroutine运行。该函数无法挂起Goroutine,调度器可能会将当前 Goroutine 调度到其他线程上:
1 | func Gosched() { |
最终在g0
的栈上调用runtime.goschedImpl
,运行时更新Goroutine的状态到_Grunnable
,让出当前处理器并将Goroutine重新放回全局队列。最后,调用runtime.schedule
触发调度。
线程管理
Go的运行时通过调度器改变线程所有权,也提供runtime.LockOSThread
和runtime.UnlockOSThread
绑定Goroutine和线程。
线程生命周期
Go的运行时通过runtime.startm
启动线程以执行处理器P:
1 | func startm(_p_ *p, spinning bool) { |
创建新线程时调用runtime.newosproc
,在Linux平台上通过系统调用clone
创建新的操作系统线程:
1 | func newosproc(mp *m) { |
使用系统调用 clone
创建的线程会在线程主动调用 exit
、以及在传入的函数 runtime.mstart
返回时主动退出;runtime.mstart
会执行调用 runtime.newm
时传入的匿名函数 fn
,完成了从线程创建到销毁的整个闭环。