在讲 gmp 模型之前,要先对进程、线程、协程的一些概念要有清晰的认识。若不懂可以先看下我之前文章【Go 并发编程之协程】。
1.gmp模型概念
基于宏观图,对 gmp 模型中各个名词概念逐一介绍:
1.1 g (Goroutine)
- g 是 goroutine 的缩写,是 Go 语言中对协程的抽象。它代表了一个可以被调度和执行的任务
- g 只有绑定到 p 上后,才能被调度执行。这意味着 g 需要一个处理器 p 来管理其执行。
1.2 m (Machine)
- m 是 machine 的缩写,表示一个底层的操作系统线程,是 Go 语言中对线程的抽象。
- m 不能直接运行 g,要运行 g,必须先与 p 绑定。
- m 从 p 的本地队列中获取 g,如果 p 的 队列为空,m 会尝试从全局队列中取一批 g 放入 p 的本地队列,或者从其他 p 的本地队列中偷取一半的 g 放入自己 p 的本地队列中。
- 正因为 m 无需与 g 直接绑定,也不需要记录 g 的状态,因此 g 才能在不同的 m 上切换运行。
1.3 p (Processor)
- p 是 processor 的缩写,是 Go 语言中的调度器。
- p 在 m 和 g 之间起到桥梁作用:对于 m 来说,只有绑定了 p 才能运行 g;而对于 g 来说,只有被 p 调度后才能执行。
- p 的数量决定了 g 的最大并行度,这个数量由 GOMAXPROCS 参数决定。
1.4 p 的本地队列
- p 的本地队列用于存放等待运行的 g,数量有限制,一般不超过 256 个。
- 当新建 g 时,会优先加入 p 的本地队列,如果本地队列已满,会将队列中一半的 g 移动到全局队列中。
1.5 全局队列
- 全局队列用于存放当本地队列满后等待运行的 g。由于全局队列的访问需要加锁,因此性能相对较低。
2.gmp模型调度策略
gmp 模型的调度策略是 Go 语言中实现高效并发的关键,它确保了大量的 goroutines(g)能够在多个处理器(p)上有效地执行,同时利用底层操作系统线程(m)来最大化 CPU 的使用率。
2.1 调度器的工作机制
- 协作式调度: Go 语言使用协作式调度机制,意味着 g 在某些特定点(如系统调用、I/O 操作)会主动让出 CPU,从而使调度器有机会调度其他 g 执行。这种机制减少了不必要的上下文切换,提高了调度效率。
- 抢占式调度: 为了防止某个 g 长时间占用 CPU 资源,Go 语言在 1.14 版本中引入了抢占式调度。如果一个 g 占用 CPU 时间过长,调度器会强制中断其执行,并将控制权交还给调度器,确保其他 g 能够得到执行机会。
2.2 gmp模型的工作流程
- 新建goroutine: 当新建一个 g 时,调度器会优先将其放入当前 p 的本地队列。如果本地队列已满,会将一半的 g 移动到全局队列中。
- 优先本地队列调度: 当一个 m 需要执行 g 时,优先从绑定的 p 的本地队列中获取 g 进行执行。这种方式达到无锁,提高调度效率。
- 全局队列调度: 如果 p 的本地队列为空,m 会尝试从全局队列中获取 g。访问全局队列需要加锁,所以相对较慢,但是能保证系统的负载均衡。
- 工作窃取(Work Stealing): 如果 m 从 p 的本地队列和全局队列都无法获取到 g,会尝试从其他 p 的本地队列中偷取一部分 g。这种机制能够避免某些 p 长时间空闲而其他 p 过载的情况。
- goroutine 的阻塞与唤醒(Hand Off): 当 g 由于 I/O 操作或系统调用而阻塞时,m 会解除与 p 的绑定,并尝试从其他 p 获取 g 继续执行。当阻塞的 g 被唤醒时,调度器会将其重新分配到某个 p 的本地队列中,等待执行。
3. 代码层面深入理解 gmp 调度模型
gmp 数据结构定义为 runtime/runtime2.go 文件中,摘取核心字段进行介绍。
3.1 g
核心数据结构:
type g struct {
goid int64 // 协程id
stack stack // 协程栈
m *m // 当前协程被哪一个m调度执行
sched gobuf // 协程上下文,存储g的调度相关数据
_panic *_panic // 最内侧的 panic 结构体
_defer *_defer // 最内侧的 defer 延迟函数结构体
atomicstatus uint32 // g的状态
...
}
// g 调度的相关数据
type gobuf struct {
sp uintptr // 指向函数调用栈栈顶
pc uintptr // 程序计数器,指向程序下一条执行指令的地址
g guintptr // 持有 runtime.gobuf 的 g
ret uintptr // 系统调用的返回值
bp uintptr // 存储函数栈帧的起始位置
......
}
g 生命周期状态:
const(
_Gidle = itoa // 0 g刚刚被创建,还未初始化
_Grunnable // 1 在待执行队列中,等待被执行
_Grunning // 2 正在执行
_Gsyscall // 3 系统调用
_Gwaiting // 4 运行时被阻塞
_Gdead // 6 协程刚初始化完成或者已经被销毁
_Gcopystack // 8 栈拷贝中,可能处理栈扩容
_Gpreempted // 9 抢占被阻塞
)
g 的 状态变化流程图:
3.2 m
核心数据结构:
type m struct {
g0 *g // 特殊的调度协程,不用于执行用户函数,负责执行 g 之间的切换调度. 与 m 的关系为 1:1
curg *g // 当前正在调度执行的协程
p puintptr // 当前绑定的p
nextp puintptr //保存下一次可能要执行的 P,用于调度过程中快速切换。
spinning bool // 是否处于自旋状态
lockedg guintptr // 表示与当前 m 锁定的g
...
}
主要状态:
- Idle 空闲: 没有任何goroutine,也没有与任何 p 绑定。
- Spinning(自旋中): m 处于自旋状态时,它在等待获取一个可用的 p。自旋状态是为了减少 m 的阻塞开销,通过短暂的自旋来等待 p,避免频繁的休眠和唤醒操作。当 m 自旋失败(未能获取到 p),则可能会进入休眠状态。
- Running(运行中): 在运行状态下,m 已经获取了一个 p,并且正在执行 p 中的 Goroutine。
- Blocked(阻塞): m 进入阻塞状态的原因通常是因为执行系统调用或等待某种资源。
3.3 p
核心数据结构:
type p struct {
status uint32 // 状态,如空闲,正在运行(已经被M绑定)等等
m muintptr // 当前绑定的m
runqhead uint32 // 队列头部
runqtail uint32 // 队列尾部
runq [256]guintptr // 本地可运行协程队列
runnext guintptr // 线程下一个需要执行的g
...
}
主要状态:
- _Pidle: p 已经初始化,但未与任何 m 关联
- _Prunning: p正在与某个m关联并运行g
- _Psyscall: 当前运行的g正在执行系统调用
- _Pgcstop: 运行时系统需要停止调度
- _Phead: p 已经不被使用
3.4 schedt
type schedt struct {
lock mutex // 全局锁
runq gQueue // 全局队列
runqsize int32 // 全局队列容量
...
}
4. 常见调度场景解析
4.1 场景一 优先放入本地队列
p拥有g1,m获取p后开始运行g1,g1使用
go func()
创建了g2,为了局部性g2优先加入到p的本地队列
4.2 场景二 本地队列满了移到全局队列
假设每个p的本地队列只能存4个g。g1创建了6个g
4.3 场景三 尝试从全局队列中取
m 尝试从全局队列取一批g放到P的本地队列,假设本地队列容量是4,gomaxprocs大小为4
第二步从全局队列取,那为啥是取1个呢?看下具体源码
func globrunqget(pp *p, max int32) *g {
// 从全局队列中偷取,调用时必须锁住调度器
assertLockHeld(&sched.lock)
// 全局队列长度为0 直接返回
if sched.runqsize == 0 {
return nil
}
// 至少取一个,但是不要取太多,留一点给其他p 达到负载均衡
n := sched.runqsize/gomaxprocs + 1
if n > sched.runqsize {
n = sched.runqsize
}
if max > 0 && n > max {
n = max
}
// 计算本地队列能不能放在
if n > int32(len(pp.runq))/2 {
n = int32(len(pp.runq)) / 2
}
// 修改本地队列的剩余空间
sched.runqsize -= n
// 取出全局队列队头元素
gp := sched.runq.pop()
n--
for ; n > 0; n-- {
gp1 := sched.runq.pop()
runqput(pp, gp1, false)
}
return gp
}
源码总结公式:
n
=
m
i
n
(
l
e
n
(
全局队列
)
/
g
o
m
a
x
p
r
o
c
s
+
1
,
c
a
p
(
本地队列
)
)
/
2
)
n=min(len(全局队列)/gomaxprocs + 1,cap(本地队列))/2)
n=min(len(全局队列)/gomaxprocs+1,cap(本地队列))/2)
上述例子:
n
=
m
i
n
(
3
/
4
+
1
,
4
/
2
)
=
1
n=min(3/4+1,4/2)=1
n=min(3/4+1,4/2)=1
4.4 场景四 尝试从其他m绑定的p的本地队列中偷
全局队列为空,m2尝试从m1中偷取一半g放到p2
4.5 场景五 系统调用
系统调用
总结
gmp模型能够在高并发场景下高效地管理和执行goroutine,最大化利用系统资源,同时保证系统的公平性和负载均衡。如果您觉得有帮助,请关注我,另公众号【小张的编程世界】,如有任何错误或建议,欢迎指出。感谢您的阅读!