本文详细介绍Golang的GPM调度器,包括底层源码及其实现,以及一些相关的补充知识。
文章目录
- 前情提要
- Go的并发模型-CSP
- Go的调度模型-GPM源码
- Go的调度模型-流程
- 监控线程`sysmon`
- [`runtime` 包及相关功能](#
runtime
包及相关功能)
- `runtime.Gosched()`
- `runtime.Goexit()`
- [`runtime.GOMAXPROCS(n int)`](#
runtime.GOMAXPROCS(n int)
)- [GMP 限制分析](#GMP 限制分析)
- [M 的限制](#M 的限制)
- [G 的限制](#G 的限制)
- [P 的限制](#P 的限制)
前情提要
并发与并行
并行 (Parallel)
- 定义 :
在同一时刻,多条指令在多个处理器上同时执行。- 宏观: 看起来是一起执行的。
- 微观: 实际上多个任务同时进行。
并发 (Concurrency)
- 定义 :
在同一时刻只能有一条指令执行,但多个进程指令快速轮换执行,宏观上看像是多个任务同时进行。- 宏观: 多个进程同时执行。
- 微观: 单一时间段内交替执行。
关键区别
- 并行强调在 同一时刻,多个任务在不同的处理器上执行。
- 并发强调在 同一时间段内,多个任务交替执行,给人一种同时进行的假象。
进程和线程的区别
-
资源分配和调度单位
- 进程: 是资源分配的最小单位。
- 线程: 是程序执行的最小单位(资源调度的最小单位)。
-
地址空间
- 进程 : 有独立的地址空间。
- 启动一个进程需要分配地址空间和维护段表,开销较大。
- 线程: 共享进程的地址空间,切换和创建的开销更小。
- 进程 : 有独立的地址空间。
-
通信方式
- 线程: 共享地址空间,直接访问全局变量等数据。可以通过共享内存、同步机制(互斥锁(Mutex)、读写锁(RWMutex)、信号量(Semaphore))等进行通信。
- 进程 :
- 同一进程内的线程可以直接共享全局变量,通信方便。
- 不同进程需要使用 IPC(如管道、共享内存、消息队列、套接字(Socket))进行通信
-
健壮性
- 进程: 独立运行,一个进程崩溃不会影响其他进程。
- 线程: 多线程中任意一个线程崩溃会导致整个进程终止。
协程
协程是"用户态的轻量级线程",协程的调度完全由用户控制,不为操作系统所知的,它由编程语言层面实现,上下文切换不需要经过内核态,再加上协程占用的内存空间极小,所以有着非常大的发展潜力。
解决的问题
主要用来解决操作系统线程太"重"的问题,所谓的太重,主要表现在以下两个方面:
- 创建和切换的高开销
- 系统线程创建和切换需要进入内核,开销较大。
- 内存使用浪费
- 系统线程栈空间较大,且一旦创建不可动态缩减或扩展。
协程的优势
-
轻量化
- goroutine 是用户态线程,创建和切换无需进入内核。
- 开销远小于系统线程。
-
灵活的栈内存管理
- 启动时栈大小为 2KB。
- 栈可以根据需要自动扩展和收缩。
-
高并发能力
- goroutine 支持创建成千上万甚至上百万的协程并发执行,性能和内存开销较低。
接下来讲解Go到并发调度------GPM调度器
Go的并发模型-CSP
常见的并发模型有七种:
- 线程与锁
- 函数式编程
- Clojure之道
- actor
- 通讯顺序进程(CSP)
- 数据级并行
- Lambda架构
Go 语言的并发模型是通信顺序进程(Communicating Sequential Processes,CSP) 的范型(paradigm),核心观念是将两个并发执行的实体通过通道channel连接起来,所有的消息都通过channel传输。CSP 是一种消息传递模型,通过在 goroutine 之间传递数据来传递消息,而不是对数据进行加锁来实现同步访问。用于在goroutine之间同步和传递数据的关键数据类型叫作通道(channel)。
channel详情请参考:【TODO】
Go的调度模型-GPM源码
GPM代表了三个角色,分别是Goroutine、Processor、Machine。下面详细介绍他们。
-
Goroutine:协程,用go关键字创建的执行体,它对应一个结构体g,结构体里保存了goroutine的堆栈信息。
-
Machine:表示操作系统的线程。
-
Processor:表示处理器,管理G和M的联系。
Goroutine
Goroutine就是代码中使用go关键词创建的执行单元,也是大家熟知的有"轻量级线程"之称的协程,协程是不为操作系统所知的,它由编程语言层面实现,上下文切换不需要经过内核态,再加上协程占用的内存空间极小,所以有着非常大的发展潜力。
- goroutine建立在操作系统线程基础之上,协程与操作系统线程之间是多对多(M:N)的关系。
- 这里的 M:N 是指M个goroutine运行在N个操作系统线程之上,内核负责调度这N个操作系统线程;N个系统线程又负责调度这M个goroutine。
- 所谓的对goroutine的调度,是指程序代码按照一定的算法在适当的时候挑选出合适的goroutine并放到CPU上去运行的过程,这些负责对goroutine进行调度的程序代码我们称之为goroutine调度器。
在Go语言中,声明一个Goroutine很简单。如下:
go
go func() {}()
为了实现对goroutine的调度,使用 g
结构体来保存CPU寄存器的值以及goroutine的其它一些状态信息。
g
结构体保存了goroutine的所有信息,调度器代码可以通过 g
对象来对goroutine进行调度:
- 当goroutine被调离CPU时,调度器代码负责把CPU寄存器的值保存在g对象的成员变量之中
- 当goroutine被调度起来运行时,调度器代码又负责把g对象的成员变量所保存的寄存器的值恢复到CPU的寄存器。
g 结构体
g结构体用于代表一个goroutine,该结构体保存了goroutine的所有信息,包括栈,gobuf结构体和其它的一些状态信息:
go
// 前文所说的g结构体,它代表了一个goroutine
type g struct {
// Stack parameters.
// stack describes the actual stack memory: [stack.lo, stack.hi).
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
// stackguard1 is the stack pointer compared in the C stack growth prologue.
// It is stack.lo+StackGuard on g0 and gsignal stacks.
// It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
// 记录该goroutine使用的栈
stack stack // offset known to runtime/cgo
// 下面两个成员用于栈溢出检查,实现栈的自动伸缩,抢占调度也会用到stackguard0
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
......
// 此goroutine正在被哪个工作线程执行
m *m // current m; offset known to arm liblink
// 保存调度信息,主要是几个寄存器的值
sched gobuf
stktopsp uintptr // 期望 sp 位于栈顶,用于回溯检查
param unsafe.Pointer // wakeup 唤醒时候传递的参数
......
// schedlink字段指向全局运行队列中的下一个g,
//所有位于全局运行队列中的g形成一个链表
schedlink guintptr
......
// 抢占调度标志,如果需要抢占调度,设置preempt为true
preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
......
}
g结构体中两个主要的子结构体(stack和gobuf)介绍如下:
stack结构体
stack结构体主要用来记录goroutine所使用的栈的信息,包括栈顶和栈底位置:
go
// Stack describes a Go execution stack.
// The bounds of the stack are exactly [lo, hi),
// with no implicit data structures on either side.
//用于记录goroutine使用的栈的起始和结束位置
type stack struct {
lo uintptr // 栈顶,指向内存低地址
hi uintptr // 栈底,指向内存高地址
}
gobuf结构体
gobuf结构体用于保存goroutine的调度信息,主要包括CPU的几个寄存器的值:
go
type gobuf struct {
// The offsets of sp, pc, and g are known to (hard-coded in) libmach.
//
// ctxt is unusual with respect to GC: it may be a
// heap-allocated funcval, so GC needs to track it, but it
// needs to be set and cleared from assembly, where it's
// difficult to have write barriers. However, ctxt is really a
// saved, live register, and we only ever exchange it between
// the real register and the gobuf. Hence, we treat it as a
// root during stack scanning, which means assembly that saves
// and restores it doesn't need write barriers. It's still
// typed as a pointer so that any other writes from Go get
// write barriers.
sp uintptr // 保存CPU的rsp寄存器的值
pc uintptr // 保存CPU的rip寄存器的值
g guintptr // 记录当前这个gobuf对象属于哪个goroutine
ctxt unsafe.Pointer
// 保存系统调用的返回值,因为从系统调用返回之后如果p被其它工作线程抢占,
// 则这个goroutine会被放入全局运行队列被其它工作线程调度,其它线程需要知道系统调用的返回值。
ret sys.Uintreg
lr uintptr
// 保存CPU的rip寄存器的值
bp uintptr // for GOEXPERIMENT=framepointer
}
要实现对goroutine的调度,仅仅有g结构体对象是不够的,至少还需要一个存放所有(可运行)goroutine的容器,便于工作线程寻找需要被调度起来运行的goroutine。
Go调度器引入了schedt结构体,
- 保存调度器自身的状态信息;
- 保存goroutine的全局运行队列。
schedt结构体
schedt结构体用来保存调度器的状态信息和goroutine的全局运行队列:
schedt结构体被定义成了一个共享的全局变量,这样每个工作线程都可以访问它以及它所拥有的goroutine运行队列,我们称这个运行队列为全局运行队列。
go
type schedt struct {
// accessed atomically. keep at top to ensure alignment on 32-bit systems.
goidgen uint64
lastpoll uint64
lock mutex
// When increasing nmidle, nmidlelocked, nmsys, or nmfreed, be
// sure to call checkdead().
// 由空闲的工作线程组成链表
midle muintptr // idle m's waiting for work
// 空闲的工作线程的数量
nmidle int32 // number of idle m's waiting for work
nmidlelocked int32 // number of locked m's waiting for work
mnext int64 // number of m's that have been created and next M ID
// 最多只能创建maxmcount个工作线程
maxmcount int32 // maximum number of m's allowed (or die)
nmsys int32 // number of system m's not counted for deadlock
nmfreed int64 // cumulative number of freed m's
ngsys uint32 // number of system goroutines; updated atomically
// 由空闲的p结构体对象组成的链表
pidle puintptr // idle p's
// 空闲的p结构体对象的数量
npidle uint32
nmspinning uint32 // See "Worker thread parking/unparking" comment in proc.go.
// Global runnable queue.
// goroutine全局运行队列
runq gQueue
runqsize int32
......
// Global cache of dead G's.
// gFree是所有已经退出的goroutine对应的g结构体对象组成的链表
// 用于缓存g结构体对象,避免每次创建goroutine时都重新分配内存
gFree struct {
lock mutex
stack gList // Gs with stacks
noStack gList // Gs without stacks
n int32
}
......
}
因为全局运行队列是每个工作线程都可以读写的,因此访问它需要加锁,然而在一个繁忙的系统中,加锁会导致严重的性能问题。
-
调度器为每个工作线程引入了一个私有的局部goroutine运行队列,工作线程优先使用自己的局部运行队列,只有必要时才会去访问全局运行队列,这大大减少了锁冲突,提高了工作线程的并发性。
-
在Go调度器源代码中,局部运行队列被包含在p结构体的实例对象之中,参考下面Proccessor中的p结构体。
Processor
Proccessor负责Machine与Goroutine的连接,它的作用如下:
- 它能提供线程需要的上下文环境,
- 分配G到它应该去的线程上执行。(局部goroutine运行队列被包含在p结构体)
p结构体
同样的,处理器的数量也是默认按照GOMAXPROCS来设置的,与线程的数量一一对应。主要存储
- 性能追踪、垃圾回收、计时器等相关的字段外
- 处理器的待待执行的局部goroutine运行队列。
go
type p struct {
lock mutex
status uint32 // one of pidle/prunning/...
link puintptr
schedtick uint32 // incremented on every scheduler call
syscalltick uint32 // incremented on every system call
sysmontick sysmontick // last tick observed by sysmon
m muintptr // back-link to associated m (nil if idle)
......
// Queue of runnable goroutines. Accessed without lock.
//本地goroutine运行队列
runqhead uint32 // 队列头
runqtail uint32 // 队列尾
runq [256]guintptr //使用数组实现的循环队列
// runnext, if non-nil, is a runnable G that was ready'd by
// the current G and should be run next instead of what's in
// runq if there's time remaining in the running G's time
// slice. It will inherit the time left in the current time
// slice. If a set of goroutines is locked in a
// communicate-and-wait pattern, this schedules that set as a
// unit and eliminates the (potentially large) scheduling
// latency that otherwise arises from adding the ready'd
// goroutines to the end of the run queue.
runnext guintptr
// Available G's (status == Gdead)
gFree struct {
gList
n int32
}
......
}
重要的全局变量
go
allgs []*g // 保存所有的g
allm *m // 所有的m构成的一个链表,包括下面的m0
allp []*p // 保存所有的p,len(allp) == gomaxprocs
ncpu int32 // 系统中cpu核的数量,程序启动时由runtime代码初始化
gomaxprocs int32 // p的最大值,默认等于ncpu,但可以通过GOMAXPROCS修改
sched schedt // 调度器结构体对象,记录了调度器的工作状态
m0 m // 代表进程的主线程
g0 g // m0的g0,也就是m0.g0 = &g0
- 全局变量的初始状态 :
- 切片(
allgs
和allp
)初始化为空。 - 指针(
allm
)初始化为nil
。 - 整数变量(如
ncpu
和gomaxprocs
)初始化为0
。 - 结构体(如
sched
)的所有成员初始化为对应类型的零值。
- 切片(
- 程序启动时 :
- 这些全局变量逐步被赋值和初始化,表示当前程序的调度器状态、线程信息和可用的处理器。
Machine
M就是对应操作系统的线程
- 最多有
GOMAXPROCS
个活跃线程(M
)同时运行,默认情况下GOMAXPROCS
的值等于 CPU 核心数。 - 每个
M
对应一个runtime.m
结构体实例。
为什么线程数等于 CPU 核数?
每个线程分配到一个 CPU 核心,可以避免线程的上下文切换,从而减少系统开销,提高性能。
m 结构体用来代表工作线程,它保存了g p m 三方的信息:
- m 自身使用的栈信息
- 当前 m 上正在运行的 goroutine
- m 绑定的 p 的信息等
详见下面定义中的注释:
go
type m struct {
// g0主要用来记录工作线程使用的栈信息,在执行调度代码时需要使用这个栈
// 执行用户goroutine代码时,使用用户goroutine自己的栈,调度时会发生栈的切换
g0 *g // goroutine with scheduling stack
// 通过TLS实现m结构体对象与工作线程之间的绑定
tls [6]uintptr // thread-local storage (for x86 extern register)
mstartfn func()
// 指向工作线程正在运行的goroutine的g结构体对象
curg *g // current running goroutine
// 记录与当前工作线程绑定的p结构体对象
p puintptr // attached p for executing go code (nil if not executing go code)
nextp puintptr
oldp puintptr // the p that was attached before executing a syscall
// spinning状态:表示当前工作线程正在试图从其它工作线程的本地运行队列偷取goroutine
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note
// 没有goroutine需要运行时,工作线程睡眠在这个park成员上,
// 其它线程通过这个park唤醒该工作线程
park note
// 记录所有工作线程的一个链表
alllink *m // on allm
schedlink muintptr
// Linux平台thread的值就是操作系统线程ID
thread uintptr // thread handle
freelink *m // on sched.freem
......
}
M里面存了两个比较重要的东西,一个是g0,一个是curg。
M 和 G 的关系
g0
- 保存 m 使用的调度栈,负责运行时任务的管理,与用户
goroutine
栈分离。 - 调度代码运行时使用
g0
的栈,而用户代码运行时使用用户goroutine
的栈。 - 主要用于
goroutine
的创建、内存分配、任务切换等运行时调度操作。
curg
- 当前线程正在运行的用户
goroutine
。 - 每个线程在同一时刻只能运行一个
goroutine
,curg
指向该任务。
- 当前线程正在运行的用户
M 和 P 的关系
-
p
- 当前线程正在运行的处理器。
- 提供执行 Go 代码所需的上下文资源,比如本地运行队列和内存分配缓存。
-
nextp
- 暂存处理器,通常用于 M 在需要切换任务时暂存 P。
-
oldp
- 系统调用之前的处理器,用于在系统调用结束后恢复原处理器环境。
Go的调度模型-流程
PM绑定
默认启动GOMAXPROCS(四)个线程GOMAXPROCS(四)个处理器,然后互相绑定。
创建G并加入P的私有队列
一旦G被创建,在进行栈信息和寄存器等信息以及调度相关属性更新之后,它就要进到一个P的队列等待发车。
创建多个G,轮流往其他P的私有队列里面放。
P的私有队列满,加入G对应的全局队列
假如有很多G,都塞满了,那就把G塞到全局队列里(候车大厅)。
M通过P消费G
除了往里塞之外,M这边还要疯狂往外取:
- 首先去处理器的私有队列里取G执行;
- 如果取完的话就去全局队列取;
- 如果全局队列里也没有的话,就去其他处理器队列里偷。
没有可执行的G,M和P断开绑定
如果哪里都没找到要执行的G,那M就会和P断开关系,然后去睡觉(idle)了。
其他情况
G被阻塞
如果两个Goroutine正在通过channel执行任务时阻塞,PM会与G立刻断开,找新的G继续执行。
P随M进入系统调用
如果G进行了系统调用syscall,M也会跟着进入系统调用状态,那么这个P留在这里就浪费了,P不会等待G和M系统调用完成,而是P与GM立刻断开,找其他比较闲的M执行其他的G。
当G完成了系统调用,因为要继续往下执行,所以必须要再找一个空闲的处理器发车。
如果没有空闲的处理器了,那就只能把G放回全局队列当中等待分配。
监控线程sysmon
sysmon
是 Go 运行时中的一个特殊线程(M
),也被称为 监控线程 。就像 保洁阿姨,定时清理系统中"不干活"的资源,确保调度器的正常运行和系统的高效运转。
-
特点:
- 不需要
P
(处理器)即可独立运行。 - 每 20 微秒到 10 毫秒 被唤醒一次,频率动态调整,执行系统级的维护任务。
- 不需要
-
主要职责:
-
垃圾回收
- 执行垃圾回收相关的辅助任务,释放不再使用的内存资源。
-
回收长时间阻塞的
P
- 当某个
P
长时间处于系统调用或其他阻塞状态时,sysmon
会将其从M
中解绑,使资源得到重新利用。
- 当某个
-
发起抢占调度
- 监控长时间运行的
G
(goroutine
),如果某个G
运行时间过长,sysmon
会发出抢占信号,强制切换到其他G
。 - 确保调度的公平性,防止某个
goroutine
独占资源。
想在程序中实现抢占,可以使用:
runtime.Gosched()
- 监控长时间运行的
-
runtime
包及相关功能
runtime.Gosched()
- 作用 :
- 将当前的
goroutine
暂停,让出 CPU 时间片,让调度器切换其他的goroutine
执行。 - 当前的
goroutine
会在稍后重新进入运行状态。
- 将当前的
- 使用场景 :
- 实现协作式的任务切换,避免单个
goroutine
长时间占用 CPU。
- 实现协作式的任务切换,避免单个
runtime.Goexit()
- 作用 :
- 终止当前
goroutine
,并执行该goroutine
的defer
语句。
- 终止当前
- 注意事项 :
- 不会影响其他
goroutine
或整个程序的运行,仅结束调用它的goroutine
。 - 用于提前退出某些任务。
- 不会影响其他
runtime.GOMAXPROCS(n int)
- 作用 :
- 设置 Go 运行时使用的最大 CPU 核心数。
- 返回之前设置的值。
- 默认值 :
- Go 1.5 之前,默认使用单核。
- Go 1.5 及之后,默认使用所有 CPU 核心数。
- 作用解释 :
- 决定同时运行的系统线程(
M
)的数量,对调度性能有直接影响。
- 决定同时运行的系统线程(
- 使用场景 :
- 限制程序对 CPU 核心的使用,避免过度竞争。
runtime.NumGoroutine()
- 作用 :
- 返回当前程序启动的
goroutine
数量。
- 返回当前程序启动的
- 使用场景 :
- 用于监控
goroutine
的数量,帮助判断程序是否有潜在的内存或调度问题。
- 用于监控
GMP 限制分析
M 的限制
- M 是什么 :
- M 代表操作系统线程,最终执行所有的任务。
- 限制 :
- 默认最大数量为 10000。
- 超过限制会报错:
GO: runtime: program exceeds 10000-thread limit
。
- 异常场景 :
- 通常只有当大量
goroutine
阻塞时,才会触发这种限制。
- 通常只有当大量
- 调整方法 :
- 使用
debug.SetMaxThreads
增加限制。
- 使用
G 的限制
- G 是什么 :
- G 代表
goroutine
,是 Go 的轻量级线程。
- G 代表
- 限制 :
- 没有硬性限制 ,理论上可以创建任意数量的
goroutine
。 - 实际数量受 内存大小 的限制。
- 没有硬性限制 ,理论上可以创建任意数量的
- 内存占用 :
- 每个
goroutine
大约占用 2~4 KB 的连续内存块。
- 每个
- 注意事项 :
- 申请的
goroutine
过多,如果内存不足,可能会导致创建失败或系统崩溃。当然 这种情况出现在 Goroutine 数量非常庞大(如数百万)。几十个或几百个 Goroutine: 一般是合理的,具体需根据任务性质决定。
- 申请的
P 的限制
- P 是什么 :
- P 代表调度器中的处理器,负责连接 G 和 M。
- 限制 :
- 数量由
GOMAXPROCS
参数决定。 - 默认值等于 CPU 核心数。
- 数量由
- 影响 :
- P 的数量影响任务并行执行的程度,但不影响
goroutine
的创建数量。
- P 的数量影响任务并行执行的程度,但不影响
- 合理设置 :
- 一般情况下,设置为机器的 CPU 核数即可。