一、Go 并发调度模型:GMP 原理
在上一篇文章中,追踪了 Go 程序从汇编入口到用户 main 函数的完整启动链路,其中多次出现了 g0、m0 这些特殊对象。本文将深入探讨 Go 运行时最核心的并发调度模型——GMP,理解它是如何实现高效的大规模并发调度的。
Go 语言最引人注目的特性之一便是其轻量级的并发原语 goroutine。与操作系统线程动辄数 MB 的栈空间相比,goroutine 的初始栈仅有 2KB,一个程序可以轻松创建成千上万个 goroutine 而不会耗尽内存。然而,要高效地调度这些 goroutine 到有限的 CPU 核心上执行,需要一个精心设计的调度器。Go 的 GMP 模型正是这一设计智慧的结晶。
二、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 的关系
三者之间的协作关系可以用下图表示:
全局运行队列 (Global Run Queue) │ ▼ ┌─────────────────────────────────────────────────────┐ │ schedt │ │ ┌─────────────────────────────────────────────┐ │ │ │ runq: [G1, G2, G3, ...] (全局 G 队列) │ │ │ └─────────────────────────────────────────────┘ │ │ runqsize: N │ └─────────────────────────────────────────────────────┘ │ ┌─────────────────┼─────────────────┐ │ │ │ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ P0 │ │ P1 │ │ P2 │ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │ │runq │ │ │ │runq │ │ │ │runq │ │ │ │G,G,G│ │ │ │G,G │ │ │ │G │ │ │ └─────┘ │ │ └─────┘ │ │ └─────┘ │ │ runnext │ │ runnext │ │ runnext │ │ G │ │ G │ │ nil │ └────┬────┘ └────┬────┘ └────┬────┘ │ │ │ │ │ │ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ M0 │ │ M1 │ │ M2 │ │ (OS) │ │ (OS) │ │ (OS) │ │ curg→G │ │ curg→G │ │ curg→G │ └─────────┘ └─────────┘ └─────────┘当一个 M 想要执行 goroutine 时,它必须先绑定一个 P。P 为 M 提供了执行环境:本地运行队列、内存分配缓存等。这种设计使得:
- 并行执行:多个 P 可以并行工作,每个 P 绑定一个 M
- 资源隔离:每个 P 有独立的本地队列,减少锁竞争
- 灵活调度:M 和 P 可以动态绑定和解绑
三、调度策略
Go 调度器采用了两种核心策略来提高调度效率:work-stealing(工作窃取) 和 hand-off(线程移交)。这两种策略配合使用,使得 Go 能够高效地利用多核 CPU。
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}窃取过程可以用下图说明:
P1 (忙碌) P2 (空闲)┌──────────────┐ ┌──────────────┐│ runq: [G1-G6]│ ────▶ │ runq: [] ││ runnext: G7 │ 窃取 │ runnext: nil │└──────────────┘ 一半 └──────────────┘
结果:┌──────────────┐ ┌──────────────┐│ runq: [G1-G3]│ │ runq: [G4-G6]││ runnext: G7 │ │ runnext: nil │└──────────────┘ └──────────────┘Work-stealing 的优势在于:
- 负载均衡:自动将任务从繁忙的 P 转移到空闲的 P
- 无锁设计:本地队列的操作大部分是无锁的
- 缓存友好:P 绑定的 M 在同一 CPU 核心上执行,提高缓存命中率
Hand-off:线程移交
当 M 被阻塞(如执行系统调用或 cgo 调用)时,调度器会将 M 当前绑定的 P 移交给另一个 M,保证其他 goroutine 能够继续执行。这就是 hand-off 机制。
考虑这样一个场景:一个 goroutine 执行了阻塞的系统调用(如文件 I/O)。如果没有 hand-off,绑定的 P 就会被”绑架”,其本地队列中的所有 goroutine 都无法执行。
系统调用前:M1 ──bind──▶ P1 ──has──▶ [G1, G2, G3] │ └── 执行系统调用的 G0
系统调用期间(hand-off):M1 (blocked) P1 ──moved──▶ M2 (new/spare) │ └──▶ 继续执行 [G1, G2, G3]
系统调用返回:M1 (unblocked) ◀──rebind── P2 (if available) 或进入休眠队列系统调用的处理逻辑在 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 的阻塞而影响整体并发性能。
四、Go 内存分配
Go 的内存分配器基于 Google 的 TCMalloc(Thread-Caching Malloc)设计,采用了多级缓存策略来减少锁竞争,提高分配效率。理解 Go 的内存分配机制,对于理解 GMP 模型的完整工作方式至关重要。
TCMalloc 的核心思想
TCMalloc 将内存分配分为三个层级:
- Thread Cache(线程缓存):每个线程独有,小对象的分配完全无锁
- Central Cache(中央缓存):当线程缓存不足时,从中央缓存获取
- Page Heap(页堆):大对象直接从页堆分配
Go 的实现对应为:
- mcache:每个 P 独有,对应线程缓存
- mcentral:中央缓存,按大小级别分类
- mheap:页堆,全局唯一
┌─────────────────────────────────────────────────────────────────┐│ mheap (全局) ││ ┌────────────────────────────────────────────────────────────┐ ││ │ arenas, spans, bitmap 等核心数据结构 │ ││ └────────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────┘ ▲ │ 从 mcentral 获取 │┌─────────────────────────────────────────────────────────────────┐│ mcentral (全局,按大小级别分类) ││ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ││ │ mcentral │ │ mcentral │ │ mcentral │ ... │ mcentral │ ││ │ (8B) │ │ (16B) │ │ (32B) │ │ (32KB) │ ││ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │└───────┼────────────┼────────────┼────────────────┼─────────────┘ │ │ │ │ ▼ ▼ ▼ ▼┌─────────────────────────────────────────────────────────────────┐│ mcache (每个 P 一个) ││ ┌────────────────────────────────────────────────────────────┐ ││ │ P0 的 mcache │ ││ │ ┌────┐┌────┐┌────┐┌────┐┌────┐ ┌────┐ │ ││ │ │8B ││16B ││32B ││... ││32KB│ │... │ │ ││ │ │×16 ││×16 ││×16 ││ ││×8 │ │ │ │ ││ │ └────┘└────┘└────┘└────┘└────┘ └────┘ │ ││ └────────────────────────────────────────────────────────────┘ ││ ┌────────────────────────────────────────────────────────────┐ ││ │ P1 的 mcache │ ││ │ └─────────────────────────────────────────────────────────┘ ││ └────────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────┘分配路径与详细实现
根据对象大小,Go 采用不同的分配策略:
| 对象大小 | 分配路径 | 说明 |
|---|---|---|
| Tiny(< 16B,无指针) | mcache.tiny | 多个小对象合并到一个 16B 块 |
| Small(<= 32KB) | mcache → mcentral → mheap | 标准分配路径 |
| Large(> 32KB) | 直接从 mheap | 跳过 mcache |
这种多级缓存设计使得小对象的分配在绝大多数情况下只需要访问本地缓存,完全无锁,极大地提高了并发性能。这也是 P 持有 mcache 的关键原因——将内存分配上下文与调度上下文绑定,避免跨线程的锁竞争。
关于 Go 内存分配器的完整解析,包括 mcache/mspan/mcentral/mheap 的详细结构和分配流程,请参阅 Go 内存管理深度解析。
五、GC 协作
Go 的垃圾回收器(GC)采用并发标记-清除算法,大部分 GC 工作与用户代码并发执行。然而,为了保证 GC 在有限时间内完成,需要用户 goroutine 协助完成部分标记工作,这就是 GC Assist 机制。
GC 标记阶段
Go 的 GC 分为四个阶段:
- 标记准备(Mark Setup):停止所有 goroutine(STW),启用写屏障
- 并发标记(Concurrent Mark):与用户代码并发执行,遍历对象图
- 标记终止(Mark Termination):短暂 STW,完成标记
- 并发清除(Concurrent Sweep):与用户代码并发执行,回收未标记对象
在并发标记阶段,GC 工作线程(通常由一个 P 承担)负责从根对象开始遍历标记可达对象。但如果用户代码分配内存的速度超过了 GC 标记的速度,内存就会持续增长。为此,Go 引入了”分配偿还”机制——分配内存的 goroutine 必须协助 GC 完成一定量的标记工作。
GC Assist 机制
每个 goroutine 有一个”assist credit”(协助信用),表示它还需要协助 GC 完成多少标记工作。当 goroutine 分配内存时,会检查其 assist credit——如果信用为负(即”欠债”),goroutine 必须暂停用户代码的执行,转而协助 GC 完成标记工作,直到债务清偿。这就是 gcAssistAlloc 函数的核心逻辑:计算需要扫描的字节数,执行标记工作(gcDrainN),更新信用余额,若无法清偿则阻塞等待。
这种”分配偿还”机制确保了 GC 标记速度不会远远落后于内存分配速度,是 Go 运行时实现低延迟 GC 的关键设计之一。
关于 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 获取,确保了多级资源利用。
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 模型是 Go 并发的基石:G(goroutine)是轻量级并发单元,M(OS 线程)是执行载体,P(调度上下文)是连接 G 和 M 的桥梁
- work-stealing 实现负载均衡:空闲 P 从其他 P 的本地队列偷取 G,避免全局队列锁竞争
- hand-off 保证阻塞不扩散:M 阻塞时 P 被分离给其他 M,确保本地队列中的 G 继续被调度
- mcache 绑定 P 实现无锁分配:内存分配上下文与 P 绑定,小对象分配无需加锁
- GC Assist 让用户协程参与标记:分配速度过快时,goroutine 被要求协助 GC 标记,防止标记速度落后于分配速度
参考资料
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






