自从我手头一个高并发服务在压测时出现诡异的吞吐量抖动,我才真正下决心把 Go 调度器的 GPM 模型从头到尾啃了一遍。之前零零散散看过不少文章,对 G、P、M 这三个字母都能说上一两句,但一旦遇到实际性能问题——比如为什么 GOMAXPROCS 设成 8 反而比设 16 时要稳?为什么某个 goroutine 卡住了会拖累整个服务?——就发现自己对底层的理解还是太浅。这篇文章我打算从头梳理一遍 GPM 模型,把调度器的设计动机、核心数据结构、调度循环的完整过程,以及我在实际项目中踩过的和调度器相关的坑都写出来。
这篇内容适合几类人看:正在学 Go 语言、想从“会用 goroutine”进阶到“理解 goroutine”的中级开发者;准备面试、需要系统梳理 GPM 相关知识点的求职者;以及像我一样在实际项目里遇到过 goroutine 数量失控、服务响应变慢、CPU 利用率上不去等问题的排障人员。我会尽量把每个机制背后的“为什么”也讲清楚,而不只是浮在“是什么”的层面。
1. 调度器的源头:多线程并发之痛与 Go 的解法
要理解 GPM 模型,得先回到最原始的问题:操作系统线程做并发,到底哪里不够用?
1.1 内核线程作为并发原语的三个硬伤
在 Go 出现之前,C/C++/Java 时代做高并发服务,主流方案是“一个连接一个线程”或“一个任务一个线程”。这套方案在连接数少的时候没什么问题,但一旦并发规模上来,内核线程的三大硬伤就暴露出来了。
第一个硬伤是创建和销毁的成本高。每次创建线程都要陷入内核态,分配内核栈、建立线程控制块(TCB)、完成一系列初始化工作。我记得 Linux 下 pthread_create 的耗时通常在几十微秒级别,虽然单看不吓人,但如果是瞬时涌入大量请求,每个请求都要创建线程,那光是线程创建就能把服务拖垮。
第二个硬伤是上下文切换开销大。线程切换涉及用户态到内核态的切换、寄存器现场的保存与恢复、内核栈的切换、CPU 缓存(cache)的失效等。一次线程上下文切换需要几百纳秒到几微秒不等,而现代 CPU 一个时钟周期才不到 1 纳秒——也就意味着一次线程切换浪费掉了成千上万个 CPU 周期。高并发场景下大量时间都耗在了切换而不是干活上。
第三个硬伤是内存占用高。内核线程默认栈空间通常是 1MB 到 8MB,即便你用线程池控制线程数量,每个线程占用的虚拟内存也不会少。一万个线程就是几个 GB 的虚拟内存开销,再加上调度负担,整个系统会变得非常沉重。
1.2 Go 的解法:用户态调度,让并发原语变轻
Go 的解决办法是——不在操作系统线程这个层面做并发,而是自己实现一个用户态调度器,把“小而轻的 goroutine”调度到“数量可控的操作系统线程”上执行。
你这个可以类比成:内核线程是公司里的工位,goroutine 是员工手里的任务。员工(M,操作系统线程)数量是有限的,但任务(G,goroutine)可以成千上万。调度器(P,处理器)负责决定哪个员工手头该干哪个任务。任务切换远比重换工位要快得多,因为整个过程都发生在用户态,不需要打扰操作系统。
goroutine 初识栈空间只有 2KB(Go 1.2 之前是 4KB),按需增长,极大降低了内存占用。一次 goroutine 切换的开销虽然也有几百纳秒,但它和操作系统线程切换最大的区别在于:不需要陷入内核态、不涉及 CPU 缓存的全量失效,整个调度过程是协作式的,由 Go 运行时自己掌控。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GPM 三步走:三大角色各管什么、为什么必须分成三层
聊 GPM 模型,第一步是把这三个字母代表什么彻底搞明白。这里我先给一个整体的角色定义,再逐个拆开讲细节。
2.1 G(Goroutine):被调度的最小单元
G 是 goroutine 的抽象,它是 Go 运行时调度的基本执行单元。每个 G 在运行时里对应一个 runtime.g 结构体,这个结构体里记录了什么?
- 栈信息:包括栈的低位地址、高位地址、当前栈指针位置等。G 的栈是动态增长的,初始只有 2KB 左右,用完了通过
morestack机制扩容。 - 上下文信息(gobuf):保存了当前执行状态的寄存器快照,保存了 CPU 的
rsp、rip等关键寄存器的值。G 被切换出去时把这些值存下来,再被调度回来时恢复现场,就能从上次执行的位置继续跑。 - 状态:G 有多个生命周期状态,比如
_Gidle、_Grunnable、_Grunning、_Gsyscall、_Gwaiting、_Gdead等,后面展开讲。 - 关联的 M:G 当前被哪个 M 执行,绑定在哪个 P 上。
每个 G 结构体几十字节,再加上初始栈空间 2KB,几万个 goroutine 同时存在也就占用几十 MB 内存,这在线程方案下是不可想象的。
2.2 P(Processor):逻辑处理器,真正决定并行度的角色
P 在中文技术社区里常被翻译成“处理器”或“上下文”,它代表的是一个“执行 goroutine 所需的环境和资源集合”。这是 GPM 模型里最关键、也最容易误解的一层。
P 的数量由环境变量 GOMAXPROCS 控制,默认等于 CPU 逻辑核心数。真正同时执行 goroutine 的并行度不会超过 P 的数量。哪怕你的机器有 64 核,如果 GOMAXPROCS=1,那么同时只有一个 goroutine 在真正跑。
每个 P 维护着一个本地可运行队列(local run queue),里面存放着等待被调度的 G。为什么要在每个 P 上维护一个本地队列而不是搞一个全局队列?这是调度器设计的核心优化之一——减少锁竞争。如
