前身 GM 模型
12年的 GO 1.1版本之前用的都是 GM 模型,但是由于 GM 模型性能不好,饱受用户诟病。之后官方对调度器进行了改进,变成了我们现在用的 GMP 模型。
GM 模型中的 G 全称为 Goroutine 协程, M 全称为 Machine 内核级线程,调度过程如下: M (内核线程)从加锁的 Goroutine 队列中获取 G (协程)执行,如果 G 在运行过程中创建了新的 G ,那么新的 G 也会被放入全局队列中。
M 想要执行、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G 队列是有互斥锁进行保护的。
老调度器有几个缺点:
- 创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
- M1 转移 G1 会造成延迟和额外的系统负载。比如当 G1 中包含创建新协程的时候,M1 创建了 G2,为了继续执行 G1 ,需要把 G2 交给 M2 执行,也造成了很差的局部性,因为 G2和 G1 是相关的,最好放在 M1 上执行,而不是其他 M2。
- 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
GMP 模型
G 全称为 Goroutine 协程, M 全称为 Machine 内核级线程,P全称为 Processor 协程运行所需的资源,在 GM 模型的基础上增加了一个 P 层,来管理两种存放 G 的队列。
GMP模型和GM模型的区别
- 每个 P 有自己的本地队列,而不是所有的G操作都要经过全局的 G 队列,这样锁的竞争会少的多的多。而 GM 模型的性能开销大头就是锁竞争。
- P的本地队列平衡上,在 GMP 模型中也实现了 Work Stealing 算法,如果 P 的本地队列为空,则会从全局队列或其他 P 的本地队列中窃取可运行的 G 来运行(通常是偷一半),减少空转,提高了资源利用率。
- hand off 机制当 M1 线程因为 G1 进行系统调用阻塞时,线程释放绑定的 P1 ,把 P1 转移给其他空闲的线程 M2 执行,同样也是提高了资源利用率。
调度设计
-
复用线程:避免频繁的创建、销毁线程,而是对线程的复用
- work stealing 机制 当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程
- hand off 机制 当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行
-
利用并行:GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。
-
抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。
-
全局 G 队列:在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。
调度器的生命周期
- runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
- 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
- 示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 ------runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
- 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
- G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
- M 运行 G
- G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。
调度过程
P 的队列
- 全局队列:当 P 中的本地队列中有协程 G 溢出时,会被放到全局队列中
- 本地队列: P 内置的 G 队列,存的数量有限,不超过256个
- wait队列:为io阻塞就绪态goroutine队列 队列处理的逻辑:
- 当队列 P1 中的 G1 在运行过程中新建 G2 时, G2 优先存放到 P1 的本地队列中,如果队列满了,则会把 P1 队列中一半的G移动到全局队列
- 当P的本地队列为空时,会先到全局队列中获取 G ,如果全局队列中也没有 G ,则会尝试从其他线程绑定的P中偷取一半的 G (即任务窃取)
P 和 M
- P 的创建
在确定了 P 的最大数量n 后,运行时系统会根据这个数量创建n 个 P - M 何时创建
内核级线程的初始化是由内核管理的,当没有足够的 M 来关联 P 并运行其中的可运行的 G 时会请求创建新的 M 。比如M在运行 G1 时被阻塞住了,此时需要新的 M 去绑定 P ,如果没有在休眠的M则需要新建 M
G 的流动过程
- 调用 go func() 创建一个 goroutine
- 新创建的 G 优先保存在 P 的本地队列中,如果 P 的本地队列已经满了 G 就会保存在全局的队列中
- 如果 P 的本地队列为空,则先会去全局队列中获取 G ,如果全局队列也为空则去其他 P 中偷取 G 放到自己的 P 中
- M 会优先从在 P 的本地队列中获取一个可执行的 G ,其次取全局队列,最后取wait队列
- G 将相关参数传输给 M ,为 M 执行 G 做准备
- 当 M 执行某一个 G 时候如果发生了系统调用产生导致 M 会阻塞,如果当前 P 队列中有一些 G , runtime 会将线程 M 和 P 分离,然后再获取空闲的线程或创建一个新的内核级的线程来服务于这个 P ,阻塞调用完成后 G 被销毁将值返回
- 销毁 G ,将执行结果返回
- 当 M 系统调用结束时候,这个 M 会尝试获取一个空闲的 P 执行,如果获取不到 P ,那么这个线程 M 变成休眠状态, 加入到空闲线程中
数据结构
G 的数据结构
go
type g struct {
// ...
m *m
// ...
sched gobuf
// ...
}
type gobuf struct {
sp uintptr
pc uintptr
ret uintptr
bp uintptr // for framepointer-enabled architectures
}
- m:在 p 的代理,负责执行当前 g 的 m
- sched.sp:保存 CPU 的 rsp 寄存器的值,指向函数调用栈栈顶
- sched.pc:保存 CPU 的 rip 寄存器的值,指向程序下一条执行指令的地址
- sched.ret:保存系统调用的返回值
- sched.bp:保存 CPU 的 rbp 寄存器的值,存储函数栈帧的起始位置.
M 的数据结构
go
type m struct {
g0 *g // goroutine with scheduling stack
// ...
tls [tlsSlots]uintptr // thread-local storage (for x86 extern register)
// ...
}
- g0:一类特殊的调度协程,不用于执行用户函数,负责执行 g 之间的切换调度,与 m 的关系为 1:1。g0 也叫系统堆栈,runtime通常使用
systemstack
、mcall
或asmcgocall
临时切换到系统堆栈,以执行必须不被抢占的任务、不得增加用户堆栈的任务或切换用户goroutines。在系统堆栈上运行的代码隐式不可抢占,垃圾收集器不扫描系统堆栈。在系统堆栈上运行时,不会使用当前用户堆栈执行。 - tls:thread-local storage,线程本地存储,存储内容只对当前线程可见。 线程本地存储的是 m.tls 的地址,m.tls[0] 存储的是当前运行的 g,因此线程可以通过 g 找到当前的 m、p、g0 等信息。因此,TLS 代表每个线程中的本地数据。写入 TLS 中的数据不会干扰到其余线程中的值。 Go 操作 TLS 会使用系统原生的接口,以Linux X64为例,go在新建M时候会调用 arch_prctl 这个syscall来设置FS寄存器的值为M.tls的地址,运行中每个M的FS寄存器都会指向它们对应的M实例的tls, linux内核调度线程时FS寄存器会跟着线程一起切换,这样go代码只需要访问FS寄存器就可以获取到线程本地的数据。
P 的数据结构
go
type p struct {
// ...
runqhead uint32
runqtail uint32
runq [256]guintptr
runnext guintptr
// ...
}
- runq:本地 goroutine 队列,最大长度为 256
- runqhead:队列头部
- runqtail:队列尾部
- runnext:下一个可执行的 goroutine
## schedt的数据结构
go
type schedt struct {
// ...
lock mutex
// ...
runq gQueue
runqsize int32
// ...
}
sched 是全局 goroutine 队列的封装:
- lock:一把操作全局队列时使用的锁
- runq:全局 goroutine 队列
- runqsize:全局 goroutine 队列的容量