一、Go 并发调度模型:GMP 原理
在上一篇文章中,追踪了 Go 程序从汇编入口到用户 main 函数的完整启动链路,其中多次出现了 g0、m0 这些特殊对象。接下来看 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 架构全景图
调度循环
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 字段表示,主要状态包括:
| 状态 | 值 | 含义 |
|---|---|---|
_Gidle | 0 | 刚分配,尚未初始化 |
_Grunnable | 1 | 在运行队列中,等待执行 |
_Grunning | 2 | 正在执行 |
_Gsyscall | 3 | 正在执行系统调用 |
_Gwaiting | 4 | 被阻塞(如 channel 操作) |
_Gdead | 6 | 已经退出或正在被复用 |
每个 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 的特性:
-
g0:每个 M 都有一个特殊的
g0,它使用系统栈(约 64KB),专门用于执行调度相关的代码。当 M 需要进行调度决策时,会切换到 g0 的栈上执行,避免与用户 goroutine 的栈混淆。 -
curg:当前正在执行的普通 goroutine,当用户代码运行时,M 的
curg指向该 goroutine。 -
最大数量限制:默认情况下,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 的关系
三者之间的协作关系可以用下图表示:
当一个 M 想要执行 goroutine 时,它必须先绑定一个 P。P 为 M 提供了执行环境:本地运行队列、内存分配缓存等。这种设计使得:
- 并行执行:多个 P 可以并行工作,每个 P 绑定一个 M
- 资源隔离:每个 P 有独立的本地队列,减少锁竞争
- 灵活调度:M 和 P 可以动态绑定和解绑
三、调度策略
Go 调度器采用了两种策略来提高调度效率:work-stealing(工作窃取) 和 hand-off(线程移交)。
Work-Stealing:工作窃取
当一个 P 的本地队列为空时,它不会闲置等待,而是主动从其他地方”窃取”任务。这就是 work-stealing 策略的思想。
窃取的优先级顺序如下:
- 本地队列的 runnext:优先检查是否有”插队”的 G
- 本地队列:从本地队列获取 G
- 全局队列:从全局队列批量获取 G(每次最多取 61 个)
- 网络轮询器:检查是否有就绪的网络连接
- 其他 P 的本地队列:随机选择一个 P,窃取其一半的 G
调度循环的代码位于 runtime/proc.go 的 schedule 函数:
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}窃取过程可以用下图说明:
Work-stealing 的优势在于:
- 负载均衡:自动将任务从繁忙的 P 转移到空闲的 P
- 无锁设计:本地队列的操作大部分是无锁的
- 缓存友好:P 绑定的 M 在同一 CPU 核心上执行,提高缓存命中率
Hand-off:线程移交
当 M 被阻塞(如执行系统调用或 cgo 调用)时,调度器会将 M 当前绑定的 P 移交给另一个 M,保证其他 goroutine 能够继续执行。这就是 hand-off 机制。
考虑这样一个场景:一个 goroutine 执行了阻塞的系统调用(如文件 I/O)。如果没有 hand-off,绑定的 P 就会被”绑架”,其本地队列中的所有 goroutine 都无法执行。
系统调用的处理逻辑在 entersyscallblock 和 exitsyscall 函数中:
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 自己做标记?因为让分配者承担标记成本,是防止堆失控增长最直接的反馈回路。
参考资料
- 深入 golang runtime 的调度 - Go 调度器深度解析
- 图解 Go 运行时调度器 - Go 调度器可视化解析
- Memory Management Every Programmer Should Know - 程序员必知的内存管理知识
- Golang 调度器深度分析 - Go 调度器源码分析
- Go 内存分配器设计 - Go 内存分配器源码
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






