之前在团队里做代码评审,看到一段用 goroutine 处理批量请求的服务代码,随口问了一句:你现在系统线程数大概有多少?同事愣了一下。他用 goroutine 写并发写得很溜,但真让他解释“为什么一个 Go 进程可以开几十万个 goroutine 还不把操作系统打挂”“这些任务底层到底是谁在调度”,他就开始含糊了。
这是很多 Go 开发者的共同状态:go func() 用了一年,可能都没完整读过一遍调度器相关的东西。Go 的并发核心从来不只是语法层面那个 go 关键字,而是运行时里的调度器。它用 GMP 三个角色把十几万任务安排得明明白白,靠工作窃取让所有 CPU 核都尽量不闲着。这篇我把 goroutine 从创建、运行、阻塞、被偷、再到继续跑的完整路径拆开讲,也会分享一些排查调度瓶颈时真正用得上的工具和实测思路。
1. 在拆 GMP 之前,先回答一个问题:为什么 Go 不直接用线程池
1.1 一个进程里跑十万个“任务”为什么还能活
要理解调度器,先理解 goroutine 和操作系统线程的本质差异。
系统线程是内核管理的执行体,创建线程要陷入内核态做一堆资源分配;线程栈默认就有几 MB 起步,Linux 上 ulimit -s 经常是 8MB。线程切换时,内核需要保存/恢复完整的寄存器上下文、更新调度数据结构、处理 TLB 和缓存失效,这些成本轻松到微秒级。线程一旦阻塞在系统调用上,整个线程就卡住,哪怕只是等一个网络读,也占着一个系统线程。
goroutine 完全不同。它是 Go 运行时自己管理的用户态协程,新 goroutine 初始栈只有 2KB 左右(具体值由 _StackMin = 2048 决定),随使用动态增长。创建成本极低,绝大部分情况就是内存里分配一个结构体,不需要进内核。goroutine 之间的切换发生在用户态,由运行时的调度器完成,开销比线程切换低一个数量级甚至更多。
有个很容易被误解的点:goroutine 虽然叫“协程”,但一旦遇到 channel 等待、mutex 竞争、网络 I/O 这类操作,它并不是“阻塞整个线程”,而是把自己挂起到等待队列,把当前的操作系统线程让出来,让调度器从队列里取另一个就绪的 goroutine 继续跑。这才是 Go 能支撑超高并发的根本原因。
我见过有人把 goroutine 类比成“轻量级线程”,这个说法很容易让人误以为它可以随便创建、没有代价。实际上它更像一个“待办任务单”,真正干活的人是操作系统线程。任务单可以有几万张,但同一时刻真正在 CPU 上执行的用户态 goroutine,最多不会超过 GOMAXPROCS 个。
1.2 用户态调度器要解决的三件事
既然不用线程池,Go 运行时就得自己解决几个问题:
- 谁来执行:几万个 goroutine 散落在内存里,必须有一个机制决定“下一个让哪个 goroutine 跑”。
- 阻塞怎么办:goroutine 在运行时可能等 channel、等锁、等网络包,也可能真的调用系统调用阻塞住。调度器要保证某些 goroutine 卡住时,其他 goroutine 还能继续跑。
- CPU 空闲时去哪找活:某个线程跑完了本地任务,不能就让 CPU 闲着,得想办法从别的地方“偷”任务过来。
GMP 模型就是围绕这三个问题设计的:G 表示 goroutine,M 表示操作系统线程,P 是调度器里的处理器资源。三者咬合在一起,构成了整个 Go 运行时的调度骨架。
提示:本文基于 Go 1.21 附近的主线源码来聊。不同小版本调度器的细节调整很多,比如 Go 1.22 之后本地队列和窃取策略有优化,但 GMP 这个宏观框架没有变,理解它对排查问题、读源码都够用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. G、M、P 到底分别是什么,各管哪一段
2.1 G:一张轻量的“任务状态卡”,不是一个执行体
G 在运行时里对应一个 g 结构体,它描述的是一个 goroutine 的执行状态。G 自己不会“跑”,它只是一张任务卡,上面记录了:
- 当前执行到哪条指令(
sched.pc、sched.sp等),也就是 goroutine 被暂停时的完整现场; - 栈信息(
stack),Go 的栈是动态增长的,结构体里维护了栈的边界; - 当前被哪个 M 执行(
m字段); - 当前处于什么状态(
atomicstatus); - 这个 goroutine 从哪里开始执行(
startpc)。
类比一下:G 就像办公室里的待办事项单,上面写着“这个任务做到哪一步了”。执行者 M 拿起一张任务单就开始干,干到一半如果碰到要等的事情,就把进度写回任务单,把单子放下,去拿另一张。
每个 goroutine 从创建到销毁,会经历一系列状态变化:_Grunnable(就绪,等着被调度)、_Grunning(正在某个 M 上执行)、_Gwaiting(因为 channel、锁、网络等原因等待)、_Gsyscall(正在执行系统调用)、_Gpreempted(被抢占,等重新调度),等等。
很多人把“G 的数量”直接等同于“并发能力”,这是个误会。G 再多,真正运行的也只是少数几个,其余都处于就绪或者等待状态。G 多不代表性能好,只代表你有大量任务在排队或者挂起。
2.2 M:真正干活的系统线程
M 对应运行时里的 m 结构体,它包装了一个操作系统线程。M 的职责很简单:拿到一个可运行的 G,执行它;执行完或者 G 阻塞了,再找下一个。
M 本身也记录了很多东西:自己当前在跑哪个 goroutine(curg)、自己绑定了哪个 P(p)、是不是处于自旋找任务的状态(spinning)、有没有需要处理的本地缓存等。
M 和 G 之间还有一个重要关系:不是每个 M 都需要一直绑着 P 才能干活。M 创建后如果找不到 P,它只能去休眠或者尝试窃取工作;只有拿到 P 的 M 才具备执行用户 goroutine 的资格。你可以把 P 看成“工位”,M 是“工人”,工人必须占到一个工位才能开工,而工位数量是固定的。
M 的数量不是无限制的。Go 运行时默认限制最多创建 10000 个系统线程,超过就会直接抛 fatal error。正常业务很难触到这个上限,但如果你的 goroutine 大量阻塞在没有被网络轮询器接管的原生系统调用上,M 的数量就会悄悄涨上去。这类问题用 GODEBUG=schedtrace 能看得很清楚。
2.3 P:调度资源、工位、本地任务队列
P 是 GMP 里最容易被忽略、却最核心的角色。它对应运行时里的 p 结构体,数量由 GOMAXPROCS 决定,默认等于 CPU 逻辑核心数,上限 256。
P 不止是一个“许可”,它还持有一个本地运行队列(local runq),容量是 256 个 G。每个 P 还有一个叫 runnext 的字段,可以理解成一个优先级最高的“插队位”,只放一个 G,调度时优先执行它。
于是任务队列实际上分了三层:
runnext:当前 P 私有的“下一个任务”,优先级最高;- 本地 runq:挂在当前 P 下面的环形数组,容量 256,无锁访问,速度快;
- 全局 runq:所有 P 共享的一个链表,用一把全局锁保护,任务多或者本地满了才放到这里。
P 的存在让 M 和 G 解耦了。M 在跑一个 G 时如果遇到阻塞型系统调用,整个 M 会卡住,但 P 可以迅速被移交给另一个空闲的 M,让这个 P 继续执行本地队列里的其他 G。没有 P 这一层,M 一卡,队列就全卡住了。
G、M、P 的关系可以画成这样一条链路:
code复制OS Thread (M) 持有 工位 (P) 执行 任务卡 (G)
M --持有--> P --队列--> G
M 没拿 P 时,不能执行用户 goroutine
一眼看过去,GMP 就像银行柜台:G 是排队叫号的客户,P 是柜台窗口,M 是窗口后面的柜员。窗口数固定,客户再多也只能在等候区排队;某个柜员如果碰到一个业务要打电话确认(系统调用)卡住了,行长可以把窗口分给后面闲着的大堂经理(另一个 M),让窗口别闲着。
我把这个类比讲给新同事以后,他对排队、抢占、工作窃取的理解就顺畅了很多。
3. 一个 goroutine 从诞生到结束要走的完整链路
3.1 go func() 那一刻到底发生了什么
我们写 go func(),编译器会把它翻译成 runtime.newproc 调用。这一小步背后干了不少事:
- 从运行时缓存里拿一个空闲的
g结构体,如果没有就新建并初始化栈; - 把
func的入口地址、参数复制到新 goroutine 的栈上; - 把新 G 的状态设为
_Grunnable; - 优先放到当前 M 所持 P 的
runnext里,如果runnext已经被占,就放到本地 runq 队尾;本地队列满了,才会连带一半本地任务一起搬到全局 runq; - 如果当前有空闲的 P,或者有自旋的 M 不够用,runtime 会唤醒或新建一个 M 来取活。
细节上不同版本会有所调整,但核心思路是:新创建的 goroutine 优先留在当前 P 手里,而不是立刻丢给全局队列。这是很重要的一条局部性优化,能减少跨线程搬运。新任务大概率马上要被执行,放在 runnext 里,下一次调度就直接取它了。
如果你的程序里大量 go func() 被创建后并没有立刻执行,而是堆积在本地队列里,那说明生产任务的速度远大于消费速度。这时候要关注的不是调度器,而是上游任务速率和下游处理能力的匹配问题。
3.2 调度循环:schedule() 在死循环里做了什么
M 一旦拿到 P,就会进入一个近乎无限循环:取一个 G、执行、再取一个 G。这个循环体在源码里核心叫 schedule()。
它的取任务顺序可以简化理解成:
- 先看当前 P 的
runnext,如果有就直接用; - 从本地 runq 里取;
- 本地队列空了,才去全局 runq 里拿(为了公平,调度器大约每 61 次调度会强制查看一次全局队列);
- 再检查网络轮询器是否已经有就绪的 goroutine;
- 以上都拿不到,进入工作窃取流程(后文细讲);
- 所有 P 都没有工作,M 进入休眠,等待网络事件或者新任务唤醒。
这里有个值得品的设计思路:runnext 和本地队列无锁访问,性能极好,是绝大多数调度发生的路径;全局队列访问要抢一把大锁,所以调度器尽量避免走这条路。
每一轮调度循环,P 里的 schedtick 都会增加。上面说的“第 61 次强制查全局队列”用的就是这个计数,61 是一个质数,可以避免不同 P 之间产生同步节奏,防止它们同时去抢全局锁造成惊群。
一个 goroutine 执行完后,并不会真的被立刻回收。它会被放回 P 或全局的 free 列表里,下次创建 goroutine 直接复用结构体。这也是大量 goroutine 频繁创建销毁时,GC 压力依然可控的原因之一。
3.3 阻塞的三种情况与 P 的去留
goroutine 运行过程中不可能永远顺风顺水,遇到不同的阻塞,技术处理方式完全不同:
第一种:channel、mutex 这类“用户态等待”。
比如 <-ch 没有数据可读,sync.Mutex.Lock() 锁被别人持有,这是最常见的情况。runtime 会把当前 G 的状态切成 _Gwaiting,挂到对应的等待队列,然后让出 M,调度器从本地队列取下一个 G 继续跑。这种等待完全不占系统线程,是 goroutine 能支撑超大规模并发的关键。
第二种:纯 CPU 密集型长循环。
这种情况 G 没有“主动让出”,而是被调度器强制抢走。Go 1.14 之前主要是协作式抢占:只有函数调用等少数安全点才检查抢占标志,一个大死循环能把整个 P 的本地队列饿死。Go 1.14 之后引入了基于信号的异步抢占,后台的 sysmon 监控线程发现某个 G 运行时间过长,会给对应的 M 发送信号,强制它在安全点停下,把 M 让给别的 G。这也是很多人升级 Go 版本以后,“死循环卡死所有 goroutine”问题大幅减少的原因。
第三种:真正的系统调用,比如文件读写、time.Sleep 有时也会让线程进入阻塞睡眠。
系统调用是内核态的,一旦 M 阻塞在内核里,就没法继续执行用户任务。此时 runtime 会把 M 和 P 松绑:如果 syscall 很快返回,M 回来后还能拿回自己的 P;如果阻塞时间较长,sysmon 会把 P 没收,交给其他空闲 M 继续跑本地队列。等 M 从系统调用回来发现 P 没了,它只能把当前 G 放到全局队列,然后自己去找空闲 P,找不到就去休眠。
我早期排查过一个服务,现象是 threads 数量持续上涨。最后定位到是有人在热路径上直接调用了原生 socket 系统调用而不是走 Go 的 net 包,导致每个请求都真实占住一个 M。改成标准网络库之后,线程数立刻回落。这个经历让我对“goroutine 阻塞会占线程吗”这个问题特别敏感:用户态等待不占,内核态 syscall 占。排查问题先分清这一点,方向就对了。
4. 工作窃取:什么时候偷、偷谁的、凭什么
4.1 findrunnable 的寻宝顺序
一个 M 的本地队列空了,它不会马上睡觉,而是进入一套层层递进的搜索流程。这个流程在源码里叫 findrunnable,可以说它是 Go 调度器里最复杂的函数之一。
简化后的搜索顺序是:
- 再看看当前 P 的本地 runq;
- 加锁看一眼全局 runq,有就拿;
- 调用
netpoll,非阻塞地查看网络是否已有就绪事件,有就取出来; - 都没拿到,就开始工作窃取:随机挑一个 P,从它的本地队列偷一批 G 过来;
- 所有 P 都偷不到,就检查自己是不是还能自旋;
- 彻底没有希望了,把 M 自己 park 休眠,直到有人唤醒。
第四步就是传说中的“工作窃取”(work stealing)。
为什么会有这一步?因为任务分配很难做到绝对均匀。某个 P 可能因为接到了大量新任务,本地队列塞满;另一个 P 可能刚把队列跑空。如果空转的 M 直接休眠,等到有新任务时再唤醒,延迟会很高。更好的做法是让暂时没活的 M“主动出击”,去别人队列里借点活干。
工作窃取的过程非常像办公室里的场景:你这个工位没活了,旁边工位任务堆成山,你不可能干坐着等领导派活,肯定要凑过去问:“哥们儿,你这任务分我几个呗。”
4.2 偷取策略里的工程折衷
工作窃取听起来简单,实现里全是细节。核心问题有三个:偷谁的、偷多少、怎么偷才能不引发竞争。
偷谁? 不能每次固定从 0 号 P 开始,否则低号 P 会被反复抢,产生不公平。runtime 的做法是从一个随机起点开始遍历所有 P,每一轮遍历最多尝试 4 次(源码里 stealTries = 4)。如果这轮没偷到,再换一个随机起点,最多尝试 4 轮。随机化会打散同步节奏,避免多个空转 M 同时涌向同一个目标。
偷多少? 不是把对方队列整体搬空,而是只偷一半左右。这么做的原因有两点:
- 如果全搬走,目标 P 自己马上就要面对空队列,它下一轮也要去偷别人,任务在两个 P 之间来回搬家,缓存局部性全毁了;
- 偷一半,让两边都有活可干,后续不用频繁触发窃取。
如果目标队列里的数量很少,比如只有 1 到 2 个,偷取规则也允许直接拿走,而不是要求凑够一半。核心目的是保证“偷”这个动作有实际价值,而不是空手而归。
这里顺带说一个细节:窃取目标只针对对方 P 的本地 runq,不会碰对方的 runnext。runnext 被看作 P 的“私人物品”,是当前 P 为下一个任务预留的热身位置,偷走它更容易造成缓存和语义上的混乱。
**怎么避免竞争?**每个 P 的本地队列在正常情况下只有它自己的 M 访问,所以可以做成无锁结构。但工作窃取意味着别的 M 也要读它,这就必须有并发控制。源码里偷取时会对目标 P 加锁,但因为窃取动作本身发生频率不高,平时基本碰不到锁竞争。Go 调度器把“高频无锁路径”和“低频有锁路径”分得很清楚,这就是它高性能的核心原因之一。
4.3 为什么不能只维护一个全局队列
看完窃取机制,你会有一个疑问:搞这么复杂,为什么不把任务全部放到一个全局队列,每个 M 空了就从全局队列拿?加锁就加锁,全局队列还能完美保证公平性呢。
这个方案在最早期确实存在,但它有个致命问题:锁竞争会让 CPU 空转在“抢队列锁”上。假设有几十个线程同时空闲,它们同时去抢一把全局锁,绝大多数线程都在自旋等待,实际干活的时间占比会非常难看。每执行完一个 goroutine 都要碰一次全局锁,这种锁竞争会成为绝对瓶颈。
P + 本地队列的方案把任务分散到了每个工位上,大多数时候 M 只从自己的队列拿任务,根本不需要锁。只有本地队列为空、全局负载不均时才走跨 P 的路径。这就好比每个柜员自己手里有一叠叫号单,大部分情况按自己的顺序办就行;只有某个柜员单子办完了,才去问旁边的柜员借几张,而不是所有人随时都要抢一个中央叫号器。
全局队列也不是完全取消,它作为“兜底”保存那些本地装不下的任务和跨 P 迁移的任务。有,但不常用。
这背后的设计哲学我每次做技术分享都愿意强调:高性能系统里,最贵的是共享可变状态的同步。Go 调度器用 P 把“无锁路径”做成了常态,把“加锁路径”做成了例外,这才是工作窃取没有成为性能负担的原因。
5. 真实世界的调度瓶颈:几个我实测过的坑和调优姿势
5.1 先学会看调度器的“体检报告”
排查调度问题,别上来就猜。先开 GODEBUG=schedtrace 是约定俗成的第一步:
bash复制GODEBUG=schedtrace=1000 ./your_app
这会每秒输出一行调度器快照,类似:
code复制SCHED 1002ms: gomaxprocs=8 idleprocs=2 threads=9 spinningthreads=1 idlethreads=3 runqueue=0 [0 0 0 1 0 0 0 0]
每一列的含义我整理成了一张速查表:
| 字段 | 含义 | 需要警惕的信号 |
|---|---|---|
| gomaxprocs | 当前 P 的数量 | 小于业务期望值时影响并行度 |
| idleprocs | 空闲 P 的数量 | 长期为 0 说明计算打满或任务持续堆积 |
| threads | 当前 M 的数量 | 持续上涨,优先怀疑 syscall 阻塞 |
| spinningthreads | 正在自旋找任务的 M | 持续很高说明任务分配很不均衡 |
| idlethreads | 休眠的 M 数量 | 大量休眠是正常的,线程数多不代表负载高 |
| runqueue | 全局队列中的 G 数量 | 长期较大说明任务生产速度远超消费速度 |
| 方括号数组 | 每个 P 本地队列的 G 数量 | 某些 P 长期满、某些长期空,可能存在热点 |
如果只看进程指标看不出问题,我建议用 runtime/trace 抓一段更细的现场:
go复制import (
"os"
"runtime/trace"
)
func main() {
f, _ := os.Create("trace.out")
_ = trace.Start(f)
defer trace.Stop()
// 你的业务启动逻辑
}
运行后用 go tool trace trace.out 打开,可以看每个 P 上 goroutine 的状态变化、调度延迟、syscall 阻塞时间。这是定位调度问题最直观的手段。
5.2 GOMAXPROCS:默认值不一定适合你的部署环境
GOMAXPROCS 默认取 CPU 逻辑核心数。在物理机上这通常没问题,但在容器场景要小心:runtime 在 Linux 上主要通过 sched_getaffinity 看可用 CPU,如果容器没绑核,它可能看到宿主机几十个核,于是创建几十个 P。P 多本身不是错,但 P 多了会伴随更多 M 和线程切换,在明明只分配了 2 个 CPU 配额的容器里,这种浪费比较明显。
第三方库 go.uber.org/automaxprocs 就是干这个的:
go复制import _ "go.uber.org/automaxprocs"
在 init 阶段它会读取 cgroup 里的 CPU 配额,动态设置 GOMAXPROCS。如果你在容器里跑 Go 服务且没有显式设置过 GOMAXPROCS,我建议把它加上,算是成本最低的一次调优。
什么时候需要手工把 GOMAXPROCS 调小?我看到比较多的是纯计算场景配合 NUMA 架构。在多路 CPU 服务器上,P 跨 NUMA 节点访问内存会有额外开销。如果你能明确业务主要是 CPU 密集且内存局部性极重要,可以试试把 GOMAXPROCS 设为本节点核心数,再对比压测数据。不必盲从“越大越好”。
5.3 一个真实复现过的坑:锁竞争严重时,调度器在“假忙”
有段时间我维护一个统计服务,压力测试时发现 p99 抖动特别厉害。第一反应是 CPU 不够,但监控显示 CPU 使用率一直没跑满。开 trace 一看,大量 goroutine 卡在 sync.Mutex 的等待上,调度器不断在这些 goroutine 之间切换,看起来线程很忙,实际有用的计算没推进多少。
问题出在代码里一把全局锁保护一个 map,几千个 goroutine 高频读写。锁竞争导致每次 Lock 都要把大量等待者唤醒、重新排队,P 和 M 被大量无意义的调度切换消耗掉了。
优化方案是给这把大锁分片:把数据按 key 哈希到 64 个分片,每个分片一把锁。改造后同样的 QPS 下,p99 从 300ms 级别降到了 80ms 以内,调度器立刻“安静”了。spinningthreads 和线程切换量都明显下降。
这个案例对我个人的教训很深:调度器本身很少是瓶颈,调度器进入“假忙”状态,根源几乎都是业务代码引入了大量阻塞型等待或锁竞争。遇到调度问题不要急着调 GOMAXPROCS,先找找 goroutine 到底在等什么。
5.4 goroutine 数量大不是问题,真正的危险是“占用 P 的任务太多”
最后聊一个常见的误区。很多人一看到 goroutine 数量上万就紧张,其实大可不必。goroutine 在 channel、网络、锁上等待的时候,它是不占 P 的,同进程几万甚至几十万 goroutine 很正常。
真正需要警惕的是:大量 goroutine 同时处于 CPU 计算状态,把 P 全部占满。这时新的任务即使已经就绪,也只能在队列里排队,延迟会明显升高。
举个例子,如果你写一个服务,每个请求进来都开一个 goroutine 做 CPU 密集的 JSON 序列化,同时机器只有 8 核,那同一时刻最多只有 8 个请求在真正推进,其他请求的 goroutine 都在等 P。此时增加再多的 goroutine 也不会提高吞吐,只会增加内存和 GC 压力。正确的做法是限制并发数,比如用带缓冲的 channel 做信号量,或者拆成固定数量的 worker。
我自己判断一个 Go 服务并发设计是否合理,会看两个指标:一是 GOMAXPROCS 是不是和实际可用 CPU 匹配,二是 trace 里 goroutine 在 _Grunnable 状态下等待的时间占比。第二个指标高,说明队列在排队,而不是任务不够,瓶颈方向立刻就清楚了。
顺手再推荐一个我在压测时保持的习惯:让运维平台把 GODEBUG=schedtrace 的输出集成到排查工具里,平时不用开,出问题按需开启看几秒,判断调度器宏观健康状况比猜快得多。
工作窃取和 GMP 这套设计,拆开看每个机制都很朴素:任务排队、工位、随机找活。但组合在一起,就成了一个能轻松扛住百万级并发任务的运行时引擎。理解它之后,你再看 go func() 就不会觉得它只是一行语法糖了。
