mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3485 字
10 分钟
Go 并发调度模型:GMP 原理
2023-06-13

一、Go 并发调度模型:GMP 原理#

在上一篇文章中,追踪了 Go 程序从汇编入口到用户 main 函数的完整启动链路,其中多次出现了 g0m0 这些特殊对象。接下来看 Go 运行时的并发调度模型 GMP,理解它是如何在大规模并发下高效调度 goroutine 的。

goroutine 是 Go 并发的核心原语。与操作系统线程动辄数 MB 的栈空间相比,goroutine 的初始栈仅有 2KB,一个程序可以轻松创建成千上万个 goroutine 而不会耗尽内存。要把这些 goroutine 调度到有限的 CPU 核心上执行,还需要一个设计得当的调度器。GMP 模型就是 Go 的答案。

二、GMP 模型概述#

Go 调度器的设计借鉴了操作系统线程调度的思想,但又进行了创新性的改造。理解 GMP 模型,可以想象一个繁忙的工厂:

  • G(Goroutine):工厂中的工人,每个工人负责执行一项具体任务
  • M(Machine):工厂中的工作台,真正干活的地方
  • P(Processor):工厂中的工位,配备了一套工具和待办任务队列

这种设计把”执行能力”(M)和”调度上下文”(P)分离开,让调度器能更灵活地管理资源。

GMP 架构全景图#

graph TD subgraph "全局调度器 schedt" GQ["全局 G 队列<br/>(runq)"] MIDLE["空闲 M 列表"] PIDLE["空闲 P 列表"] end subgraph "P0(Processor)" P0Q["本地 G 队列[256]"] P0M["M0(OS 线程)"] P0G["G: running"] end subgraph "P1(Processor)" P1Q["本地 G 队列[256]"] P1M["M1(OS 线程)"] P1G["G: running"] end subgraph "P2(Processor)" P2Q["本地 G 队列[256]"] P2M["M2(OS 线程)"] P2G["G: running"] end GQ --> |"全局队列分发"| P0Q GQ --> |"全局队列分发"| P1Q GQ --> |"全局队列分发"| P2Q P1Q -.-> |"work stealing"| P0Q P0Q -.-> |"work stealing"| P2Q

调度循环#

flowchart TD A["schedule()"] --> B["findRunnable()"] B --> C{"本地队列有 G?"} C --> |"是"| D["执行 G"] C --> |"否"| E{"全局队列有 G?"} E --> |"是"| D E --> |"否"| F{"其他 P 可偷取?"} F --> |"是"| G["work stealing"] F --> |"否"| H{"netpoll 就绪?"} H --> |"是"| D H --> |"否"| I["休眠 M"] G --> D D --> J{"G 阻塞?"} J --> |"channel/IO"| K["gopark → 挂起 G"] J --> |"系统调用"| L["entersyscall → 解绑 P"] J --> |"时间片用完"| A K --> A L --> A

G:Goroutine#

G 是 goroutine 的抽象表示,它封装了一个并发任务的全部状态。在 Go runtime 中,G 的定义如下:

type g struct {
// 栈参数
stack stack // 栈范围 [stack.lo, stack.hi)
stackguard0 uintptr // 栈溢出检查,用于 go 的栈增长
stackguard1 uintptr // 栈溢出检查,用于 cgo 的栈增长
// 与调度器交互的字段
m *m // 当前绑定到的 M
sched gobuf // 保存调度上下文(PC、SP 等)
atomicstatus atomic.Uint32 // goroutine 状态
goid uint64 // goroutine ID
// 其他字段省略...
}
type gobuf struct {
sp uintptr // 栈指针
pc uintptr // 程序计数器
g guintptr // 指向 goroutine 的指针
ret uintptr // 返回值
// ...
}
type stack struct {
lo uintptr // 栈底地址
hi uintptr // 栈顶地址
}

G 的状态通过 atomicstatus 字段表示,主要状态包括:

状态含义
_Gidle0刚分配,尚未初始化
_Grunnable1在运行队列中,等待执行
_Grunning2正在执行
_Gsyscall3正在执行系统调用
_Gwaiting4被阻塞(如 channel 操作)
_Gdead6已经退出或正在被复用

每个 G 都有自己的栈空间,初始为 2KB,可以根据需要动态增长。当 goroutine 执行完毕或被阻塞时,其栈空间会被回收或保存,以便后续复用。

M:Machine(OS 线程)#

M 代表操作系统的内核线程,它是真正执行代码的载体。Go runtime 对 M 的定义如下:

type m struct {
g0 *g // 用于执行调度代码的特殊 goroutine
curg *g // 当前正在运行的 goroutine
p puintptr // 绑定的 P
nextp puintptr // 即将绑定的 P(用于 handoff)
oldp puintptr // 系统调用前的 P
// 用于信号处理和系统调用
tls [tlsSlots]uintptr // 线程本地存储
mstartfn func() // M 启动时执行的函数
// 用于阻塞/唤醒
park note
alllink *m // 链接到 allm 列表
// 其他字段省略...
}

M 的特性:

  1. g0:每个 M 都有一个特殊的 g0,它使用系统栈(约 64KB),专门用于执行调度相关的代码。当 M 需要进行调度决策时,会切换到 g0 的栈上执行,避免与用户 goroutine 的栈混淆。

  2. curg:当前正在执行的普通 goroutine,当用户代码运行时,M 的 curg 指向该 goroutine。

  3. 最大数量限制:默认情况下,Go 限制最多创建 10000 个 M,这个值可以通过 debug.SetMaxThreads 调整。

M 的生命周期包括:创建(通过 newm)、阻塞/唤醒、销毁。当没有可运行的 G 时,M 会进入休眠状态;当有新任务到来时,M 会被唤醒。

P:Processor(处理器)#

P 是 Go 1.1 引入的概念,它代表了调度的上下文,包含了运行 goroutine 所需的资源。P 的引入解决了旧调度器在多线程环境下的锁竞争问题。

type p struct {
id int32
status uint32 // P 的状态
link puintptr // 链接到空闲 P 列表
m muintptr // 绑定的 M
// 本地运行队列
runqhead uint32
runqtail uint32
runq [256]guintptr // 本地 G 队列
runnext guintptr // 优先运行的 G
// 内存分配缓存
mcache *mcache // 内存分配器的本地缓存
// GC 相关
gcAssistTime int64
gcBgMarkWorker guintptr
// 其他字段省略...
}

P 的状态主要包括:

状态含义
_Pidle空闲,没有执行用户代码
_Prunning被某个 M 持有,正在执行代码
_Psyscall被某个 M 持有,正在进行系统调用
_Pgcstop被 GC 持有,GC 期间暂停
_Pdead已废弃(GOMAXPROCS 减少时)

P 的数量决定了并行度,通常等于 GOMAXPROCS 的值(默认为 CPU 核心数)。每个 P 拥有一个本地运行队列,最多可容纳 256 个 G,这大大减少了全局队列的锁竞争。

G、M、P 的关系#

三者之间的协作关系可以用下图表示:

graph TD subgraph "全局调度器 schedt" GQ["runq: [G1, G2, G3, ...]<br/>runqsize: N"] end GQ --> P0 GQ --> P1 GQ --> P2 subgraph "P0" P0Q["本地队列: G, G, G<br/>runnext: G"] end subgraph "P1" P1Q["本地队列: G, G<br/>runnext: G"] end subgraph "P2" P2Q["本地队列: G<br/>runnext: nil"] end P0 --> M0["M0 (OS 线程)<br/>curg &rarr; G"] P1 --> M1["M1 (OS 线程)<br/>curg &rarr; G"] P2 --> M2["M2 (OS 线程)<br/>curg &rarr; G"]

当一个 M 想要执行 goroutine 时,它必须先绑定一个 P。P 为 M 提供了执行环境:本地运行队列、内存分配缓存等。这种设计使得:

  1. 并行执行:多个 P 可以并行工作,每个 P 绑定一个 M
  2. 资源隔离:每个 P 有独立的本地队列,减少锁竞争
  3. 灵活调度:M 和 P 可以动态绑定和解绑

三、调度策略#

Go 调度器采用了两种策略来提高调度效率:work-stealing(工作窃取)hand-off(线程移交)

Work-Stealing:工作窃取#

当一个 P 的本地队列为空时,它不会闲置等待,而是主动从其他地方”窃取”任务。这就是 work-stealing 策略的思想。

窃取的优先级顺序如下:

  1. 本地队列的 runnext:优先检查是否有”插队”的 G
  2. 本地队列:从本地队列获取 G
  3. 全局队列:从全局队列批量获取 G(每次最多取 61 个)
  4. 网络轮询器:检查是否有就绪的网络连接
  5. 其他 P 的本地队列:随机选择一个 P,窃取其一半的 G

调度循环的代码位于 runtime/proc.goschedule 函数:

func schedule() {
mp := getg().m
// 尝试从各种来源获取可运行的 G
var gp *g
if gp == nil {
// 1. 优先检查全局队列(每 61 次调度检查一次全局队列)
if mp.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
lock(&sched.lock)
gp = globrunqget(mp.p.ptr(), 1)
unlock(&sched.lock)
}
}
if gp == nil {
// 2. 从本地队列获取
gp, inheritTime = runqget(mp.p.ptr())
}
if gp == nil {
// 3. 从全局队列、网络轮询器或其他 P 窃取
gp, inheritTime = findrunnable()
}
// 执行找到的 goroutine
execute(gp, inheritTime)
}

findrunnable 函数实现了完整的窃取逻辑:

func findrunnable() (gp *g, inheritTime bool) {
mp := getg().m
top:
pp := mp.p.ptr()
// 检查全局队列
if sched.runqsize > 0 {
lock(&sched.lock)
gp := globrunqget(pp, 0)
unlock(&sched.lock)
if gp != nil {
return gp, false
}
}
// 检查网络轮询器
if netpollinited() && netpollWaiters.Load() > 0 {
if list := netpoll(0); !list.empty() {
gp := list.pop()
injectglist(&list)
return gp, false
}
}
// 从其他 P 窃取
if !mp.spinning {
mp.spinning = true
}
for i := 0; i < 4; i++ {
for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
if sched.gcwaiting.Load() {
goto top
}
p2 := allp[enum.position()]
if pp == p2 {
continue
}
// 尝试从 p2 窃取一半的 G
if gp := runqsteal(pp, p2, stealRunNextQ); gp != nil {
return gp, false
}
}
}
// 没有找到可运行的 G,进入休眠
stopm()
goto top
}

窃取过程可以用下图说明:

graph LR subgraph "窃取前" P1B["P1 (忙碌)<br/>runq: G1-G6<br/>runnext: G7"] P2B["P2 (空闲)<br/>runq: 空<br/>runnext: nil"] end P1B -->|"窃取一半"| P2B subgraph "窃取后" P1A["P1<br/>runq: G1-G3<br/>runnext: G7"] P2A["P2<br/>runq: G4-G6<br/>runnext: nil"] end

Work-stealing 的优势在于:

  • 负载均衡:自动将任务从繁忙的 P 转移到空闲的 P
  • 无锁设计:本地队列的操作大部分是无锁的
  • 缓存友好:P 绑定的 M 在同一 CPU 核心上执行,提高缓存命中率

Hand-off:线程移交#

当 M 被阻塞(如执行系统调用或 cgo 调用)时,调度器会将 M 当前绑定的 P 移交给另一个 M,保证其他 goroutine 能够继续执行。这就是 hand-off 机制。

考虑这样一个场景:一个 goroutine 执行了阻塞的系统调用(如文件 I/O)。如果没有 hand-off,绑定的 P 就会被”绑架”,其本地队列中的所有 goroutine 都无法执行。

sequenceDiagram participant M1 participant P1 participant G0 participant M2 Note over M1,P1: 系统调用前:M1 绑定 P1,执行 G0 M1->>P1: bind P1->>G0: running Note over M1,M2: 系统调用期间(hand-off) M1->>M1: blocked(系统调用) P1-->>M2: P1 移交给 M2 M2->>P1: bind Note over M2,P1: M2 继续执行 P1 本地队列中的 G1, G2, G3 Note over M1,M2: 系统调用返回 M1-->>P1: 尝试重新绑定原 P alt 有空闲 P M1->>P1: rebind 到空闲 P else 无空闲 P M1->>M1: 将 G0 放入全局队列,M1 休眠 end

系统调用的处理逻辑在 entersyscallblockexitsyscall 函数中:

func entersyscallblock() {
mp := getg().m
pp := mp.p.ptr()
// 保存当前状态
save(gp, pc)
// 将 P 从 M 解绑
pp.m = 0
mp.p = 0
atomic.Store(&pp.status, _Psyscall)
// 尝试将 P 移交给其他 M
if sched.sysmonwait.Load() {
systemstack(func() {
handoffp(pp)
})
}
}
func exitsyscall() {
mp := getg().m
// 尝试重新绑定原来的 P
pp := mp.oldp.ptr()
if pp != nil && mp.p == 0 {
// 尝试快速路径:原来的 P 还在
if atomic.Cas(&pp.status, _Psyscall, _Pidle) {
wirep(pp)
exitsyscallfast()
return
}
}
// 慢速路径:需要找一个空闲 P 或创建新的 M
exitsyscallslow()
}

handoffp 函数负责将 P 移交给其他 M:

func handoffp(pp *p) {
// 如果 P 本地有任务或全局有任务
if pp.runqhead != pp.runqtail || sched.runqsize > 0 {
startm(pp, false)
return
}
// 如果有 GC 工作
if gcBlackenEnabled != 0 && gcMarkWorkAvailable(pp) {
startm(pp, false)
return
}
// 没有工作,将 P 放入空闲列表
pidleput(pp)
}

Hand-off 机制确保了即使有 goroutine 被阻塞,CPU 资源也能被充分利用,不会因为单个 goroutine 的阻塞而影响整体并发性能。

四、内存分配与 GC 对 GMP 调度的影响#

GMP 模型并非孤立运行,内存分配器和垃圾回收器都会直接介入调度流程。理解这些交互,才能完整把握 Go 运行时的行为。

mcache:为什么绑在 P 上而不是 M 上#

Go 的内存分配器基于 TCMalloc 的多级缓存设计:mcache(每个 P 独有)对应线程缓存,mcentral 对应中央缓存,mheap 对应页堆。小对象分配在绝大多数情况下只需访问 mcache,完全无锁。

mcache 绑定在 P 上而非 M 上,原因在于 M 可能频繁阻塞和唤醒(系统调用、cgo),而 P 相对稳定。如果把 mcache 绑在 M 上,每次 hand-off 都要切换内存分配上下文,既增加开销又破坏缓存局部性。P 持有 mcache 后,M 只需绑定 P 就能无锁分配内存,这也是 P 作为”调度粘合层”的核心价值之一。

关于 Go 内存分配器的完整解析,包括 mcache/mspan/mcentral/mheap 的详细结构和分配流程,请参阅 Go 内存管理深度解析

GC Assist:分配偿还如何影响调度#

Go 的 GC 采用并发标记-清除算法,大部分工作与用户代码并发执行。但当用户代码分配内存的速度超过 GC 标记速度时,堆会持续增长。为此 Go 引入了”分配偿还”机制:每个 goroutine 维护一个 assist credit(协助信用),分配内存时检查信用余额,若为负(即”欠债”),goroutine 必须暂停用户代码,转而协助 GC 完成标记工作,直到债务清偿。

这对 GMP 调度的直接影响是:一个正在运行的 G 可能因为 GC Assist 被迫让出执行时间,转而执行标记工作。在 gcAssistAlloc 中,如果 goroutine 无法在当前时间片内清偿债务,它会被挂起(gopark),等待 GC 标记进度推进后再被唤醒。这意味着在高分配速率的场景下,调度器的时间片分配会被 GC Assist 打断,goroutine 的实际执行时间可能远少于预期。

关于 GC 的完整四阶段流程、调优参数和 GC Assist 的详细实现,请参阅 Go GC 机制深度解析

五、常见问题#

Q1:GMP 模型中,为什么需要 P 这个中间层?#

P(Processor)是 GMP 模型的创新。没有 P 时,M 每次调度 G 都需要访问全局队列,导致锁竞争严重。P 将本地运行队列和 mcache 绑定到逻辑处理器上,M 只需绑定 P 即可无锁地获取 G 和分配内存。P 的数量默认等于 CPU 核心数(GOMAXPROCS),控制了真正的并行度。

Q2:work-stealing 会不会导致负载不均衡?#

work-stealing 的目标正是动态均衡,但说”不会不均衡”过于绝对。机制上,当某个 P 的本地队列为空时,它会从其他 P 偷取一半的 G,这本身就是在纠正已有的不均衡。偷取顺序是先其他 P 的本地队列,其次全局队列,最后 netpoller,覆盖了多级资源。需要看到的是,work-stealing 是被动反应:只有当某个 P 空了才会触发偷取,在负载刚涌入、所有 P 都还没消费完本地队列的短窗口内,仍可能出现短暂的倾斜,只是倾斜会被下一轮偷取纠正回来。

Q3:goroutine 泄漏和 GMP 调度有什么关系?#

goroutine 泄漏通常是业务逻辑问题,goroutine 永久阻塞在 channel 操作、锁或 I/O 上,无法退出。调度器会正常调度这些 goroutine(它们在等待队列中),但它们永远不会完成。使用 runtime.NumGoroutine() 监控 goroutine 数量是排查泄漏的第一步。

Q4:为什么 GOMAXPROCS 默认值是 CPU 核心数?#

Go 团队经过大量测试发现,将 P 的数量设为 CPU 核心数能在大多数场景下取得最佳性能。P 数量过多会导致 M 频繁切换上下文,增加调度开销;P 数量过少则无法充分利用多核。在容器环境中,Go 1.25+ 已支持 GOMAXPROCS 自动适配 cgroup CPU 限制。

Q5:hand-off 机制如何保证 M 阻塞时不影响其他 G?#

当 M 因系统调用阻塞时,hand-off 机制会将 M 绑定的 P 分离出来,交给其他空闲的 M 或创建新的 M 继续调度。这样 P 上的本地队列中的 G 不会被阻塞的 M 拖累。当阻塞的 M 从系统调用返回时,它会尝试获取一个空闲的 P;如果没有空闲 P,则将 G 放入全局队列,M 自己进入休眠。

小结#

GMP 模型的设计哲学可以归结为一条主线:用最小的内核参与换取最大的并发吞吐。G 是轻量级的工作单元,M 是真正执行代码的 OS 线程,P 则是二者之间的调度粘合层,它把本地运行队列和 mcache 绑在一起,使得 M 无需访问全局锁就能获取 G 和分配内存。这一层抽象是整个模型的灵魂:没有 P,M 每次调度都要争抢全局队列,并发度一高就成了瓶颈。work-stealing 让空闲的 P 主动找活干,而不是等着任务分配;hand-off 让阻塞的 M 立刻把 P 让出来,不至于拖累整个调度器。但 GMP 也有它的边界,当你的工作负载是大量阻塞式系统调用而非 CPU 密集型计算时,M 会不断被创建(因为 P 需要 M 来运行),debug.SetMaxThreads 的默认上限 10000 可能被触达。理解 GMP 的关键在于理解每个设计决策背后的取舍:P 的数量为什么等于 CPU 核心数?因为并行度的收益在核心数以上急剧递减。mcache 为什么绑在 P 上而不是 M 上?因为 M 可能频繁阻塞和唤醒,而 P 相对稳定。GC Assist 为什么要让 goroutine 自己做标记?因为让分配者承担标记成本,是防止堆失控增长最直接的反馈回路。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go 并发调度模型:GMP 原理
https://blog.souloss.cn/posts/golang/go-runtime/
作者
Souloss
发布于
2023-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时