写 Go 的人,大概率都听过一句话:goroutine 很轻量,随随便便开几十万个没压力。我第一次听到这话就去写了一个创建 50 万 goroutine 的 demo,结果并不如想象中美好——程序内存涨得很快,部分 goroutine 迟迟不被执行,整个程序的调度延迟也肉眼可见地变大。后来我把 Go 调度器的源码和运行时机制认真走了一遍,才意识到问题不在 goroutine 本身,而在于我没有搞懂一件事:go 关键字创建出来的东西,最终是怎么被放到 CPU 上真正执行的。
这个问题的答案,就是 Go 自己的协程调度系统。goroutine 不是“更轻量的线程”,而是 Go runtime 在用户态维护的调度单元;runtime 通过 M:N 模型把大量 goroutine 映射到少量操作系统线程上,自行决定谁先跑、谁后跑、谁让出、谁被抢占。理解这套 GMP 模型、调度循环和抢占机制,不只是在应付面试题,而是真正排查并发性能问题、调整 GOMAXPROCS、看懂 goroutine 泄漏的底层前提。这篇文章我会从线程成本说起,讲到 GMP 三个角色,再拆一次调度循环的完整过程,最后分享几个我实际调 Go 服务时用过的排查和调优手段。
1. 为什么 Go 非要自己搞一套调度器
1.1 操作系统线程到底贵在哪
很多人对比协程和线程,第一反应是“线程切换贵,协程切换便宜”。这么说没错,但只停留在结论上。真正要理解 Go 为什么要造一套用户态调度器,得先看清楚普通系统线程的成本都花在哪儿了。
第一是内存成本。一个操作系统线程在 Linux 上创建时,默认会带一个不小的栈,很多发行版上 pthread 默认栈大小是 8MB。这只是虚拟内存的预留,不是一开始就用掉 8MB 物理内存,但 8MB 的地址空间依然让线程数量变得很受限。10000 个线程光是虚拟栈就是 80GB 的规模,这还没算线程内核对象和 task_struct 本身的开销。goroutine 呢?在 Go runtime 源码里,goroutine 初始栈的最小值大约是 2048 字节,也就是 2KB 左右,差了三个数量级。这个“起步尺寸”决定了你能同时开多少个并发单元。
第二是切换成本。线程切换是需要陷入内核的。操作系统要把当前线程的寄存器上下文、指令指针、栈指针保存下来,再选出下一个线程,把它的上下文恢复出来,整个过程还要处理时钟中断、调度队列和缓存失效。一次线程切换的实际耗时和负载相关,但普遍在几微秒到十几微秒这个量级。协程切换则完全不同,它发生在用户态,由 Go runtime 自己的调度器完成,不需要系统调用,只需要保存和恢复少数寄存器以及栈指针,量级通常在几十到几百纳秒。这个数值差异放在高频并发的场景里会被放大得很明显。
第三是调度成本。操作系统调度器面对的是整个系统的所有线程,它不知道你的业务里谁依赖谁。如果代码里开了几千个线程,每个线程都在等待不同事件,内核要维护一大堆等待队列、唤醒逻辑和时间片切换,并发一高很容易出现 CPU 空转或者调度延迟。Go 想解决的核心问题之一就是:你只管用 goroutine 表达并发逻辑,至于它们怎么塞进少量线程、线程怎么复用,runtime 来替你兜底。
1.2 用户态调度和 M:N 模型的基本思路
Go 采用的调度模型常被称为 M:N 模型,意思是有 N 个 goroutine 作为用户态执行单元,最终映射到 M 个操作系统线程上去运行,注意这里 N 通常远大于 M。
如果你写过 C++ 或者 Java 的线程池,思路其实有点类似:线程池中固定几个线程,从任务队列里不断取任务执行。Go 的调度器本质上就是一个被运行时高度优化的线程池加任务调度系统,只不过这个“任务”是整个 goroutine,“线程”则是操作系统线程。用户代码里的 go func() 不是真的创建一个线程,而是把一个可执行任务放进 runtime 的调度队列里,等待某个空闲线程把它取走。
用户态调度的最大优势是灵活:调度器可以看到 goroutine 之间的状态,比如某个 goroutine 在等 channel、等锁、等网络事件,它可以立刻把当前线程让给其他 goroutine 使用,而不像内核那样只能靠阻塞和唤醒机制来处理。用大白话讲,线程像是一个员工被派出去等人回复,期间工位空着;goroutine 则像一个员工发现要等人回复,就先把手里其他任务做完,等人回复了再回来继续处理这一件。
1.3 先从调度器视角看一遍全貌
在进入 GMP 角色拆解之前,先用一个极度简化的流程感知一下 Go 调度器的日常。假设现在有一个 M,也就是一个真正在跑的操作系统线程,它维护着一个循环:
- 先看自己绑定的 P 的本地队列里有没有可运行的 goroutine;
- 没有就去全局队列取一批;
- 还没有就去别的 P 的本地队列里“偷”几个过来;
- 实在找不到可运行的 G,就让自己休眠或者把 P 让出来;
- 找到之后,切换栈到目标 goroutine,把 CPU 交给它;
- goroutine 退出、阻塞或者被抢占后,M 回到第 1 步,继续找下一个。
这个循环就是 Go 调度器的心脏。后面所有复杂机制,包括 GMP、抢占、系统调用、网络轮询,都是围绕这个循环展开的。理解了这棵循环的“主干”,后续的内容就不容易迷路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GMP 三个角色到底分别负责什么
2.1 G:轻量但并不是没有成本
G 是 goroutine 在 runtime 里的结构体,它代表一个独立的执行单元。每个 G 内部至少包含几样东西:自己的栈信息、当前执行状态、保存寄存器上下文的 gobuf,以及和它关联的 M、P 信息。
为什么说 goroutine 的“轻量”是相对的?因为虽然初始栈只有 2KB,但 runtime 为每个 G 还要维护一堆元数据。我自己计算过一个简单场景:如果真开 100 万个 goroutine,即使每个 goroutine 什么都不干,仅按初始栈 2KB 估算,光栈内存就是 2GB 左右,再加上 runtime 结构体本身的开销,整体可能到 3GB 以上。所以“百万 goroutine 没压力”这句话是有条件的,它指的是内存规模可控,而不是说可以无限挥霍。
G 的状态机也是理解调度器的一把钥匙。常见状态包括:
| 状态 | 含义 |
|---|---|
| _Gidle | 刚分配或者刚从池里取出,还没进入调度 |
| _Grunnable | 已在某个运行队列中,等待被 M 取走执行 |
| _Grunning | 正在某个 M 上执行用户代码 |
| _Gsyscall | 正在执行系统调用,还没回到调度器 |
| _Gwaiting | 阻塞中,比如等 channel、等锁、等网络事件 |
| _Gdead | 执行完毕,等待被回收复用 |
注意 _Gdead 这个状态。Go runtime 不会每次都新分配 G,而是在 G 退出后把它丢进空闲池,下次 go func() 时优先复用。这也是为啥高并发短任务场景下 goroutine 的创建开销可以压得很低。
2.2 M:真正干活的系统线程
M 是 machine 的缩写,对应一个操作系统线程。用户代码永远运行在某个 M 对应的系统线程上。M 本身不做调度决策,它只提供了“能跑代码的物理环境”。
M 比较容易被忽略的一个角色是 g0。每个 M 除了运行用户 goroutine 之外,还绑定了一个特殊的 g0,它不执行任何用户代码,而是负责执行调度器自身的逻辑。为什么需要 g0?因为调度器在做任务切换时也需要一个栈来跑自己的代码,如果直接用用户 goroutine 的栈来跑调度逻辑,一旦发生栈切换就会乱套。所以每次调度发生时,正在运行的 G 会先把执行权交给 g0,在 g0 的栈上完成下一个 G 的选择,然后再切换到下一个 G 的用户栈。
M 还有一个特点是它不会被“回收”得太频繁。runtime 会维护一个空闲 M 列表,当某个 M 暂时没有 G 可跑但系统线程还活着时,它会进入休眠待命状态,等有任务了再被唤醒。这样可以有效避免反复创建销毁线程带来的系统调用开销。
2.3 P:调度器的关键中间层
P 是 processor 的缩写,但它的含义很容易被误解。P 不是 CPU 核心,也不是操作系统线程,而是 Go 调度器里的“处理器”抽象,可以理解为一个执行配额或者说本地调度资源。
每个 P 内部持有一块本地运行队列,这个队列本质上是一个环形数组,容量通常认为是 256 个 G。P 是 G 和 M 之间的缓冲层,为什么要这么设计?如果每次调度都去全局队列抢 G,所有 M 都要竞争同一把全局锁,并发一高这里就会成为瓶颈。加上 P 之后,每个 M 优先从自己绑定的 P 的本地队列拿任务,大部分调度操作不需要锁,缓存局部性也更好。
P 的数量几乎等于 GOMAXPROCS 的值,默认情况下和 CPU 核数一致。这个数量决定了一个重要上限:真正同时在用户态执行的 goroutine 数量最多不超过 P 的数量。举个极端例子,如果你的机器有 8 核,GOMAXPROCS 是 8,那么无论你创建了多少 goroutine,同一时刻最多只有 8 个 goroutine 在并行执行,其他的都在队列里排队或者处于就绪状态。理解这一点,是为了打破“goroutine 越多越快”的错觉。
2.4 runnext 和本地队列:任务是怎么排队的
每个 P 的队列分两部分:一个容量很小的 runnext 槽位和一个本地 runq 队列。Go runtime 创建新 goroutine 时,会优先把它放到当前 P 的 runnext 槽位里,如果 runnext 里已经有 G,旧的 G 会被挤到 runq 队列尾部。
为什么要搞一个 runnext?这是为了“亲和性”。新创建的 goroutine 往往和创建它的 goroutine 有数据关联,如果刚创建完马上就能在当前 P 上执行,CPU 缓存命中率会更高。所以 runtime 给新 G 一个优先执行的待遇。不过它如果一直霸占 runnext,其他 G 就容易被饿着,所以调度器也会在合适的时机把它丢进正常队列。这个权衡是 Go 调度器里很有意思的细节。
当 runq 本地队列满了之后,新的 G 就不会硬塞到这个 P 了,而是会把本地队列里的一部分 G 和新鲜 G 一起搬到全局队列。全局队列是一个有锁保护的双端队列,容量没有固定限制。这样做相当于把溢出的任务“上交”给全局池,让其他 P 也能过来拿。全局队列的存在是一种兜底机制,它牺牲了一点性能,换来了任务调度的公平性和可扩展性。
3. 调度循环的核心:一次真实的调度过程
3.1 从 go 关键字到 gogo 汇编
平时写代码只要敲 go f(),但这条语句背后会经历好几次跳转。go 关键字在编译阶段会被翻译成对 runtime 的 newproc 调用,newproc 最终会创建一个 G,并把 G 的状态设置为 _Grunnable,然后放入当前 P 的 runnext 或本地 runq 中。
创建 G 时还有一个细节是复用机制。runtime 不会每次都从堆上分配全新的 G 结构体,而是先从全局空闲 G 列表和 P 的空闲 G 列表中找可以复用的对象,找不到才会真正 new 一个。这也是为什么短生命周期 goroutine 大量创建销毁,内存分配压力仍然可控。
当 M 决定执行某个 G 后,会调用 execute 函数,接着会执行到一段汇编代码 gogo。gogo 做的事情非常纯粹:从 G 的 gobuf 里恢复寄存器,把栈指针切换到目标 G 的栈上,然后跳转到 G 要执行的函数入口。整个过程不经过任何系统调用,这也是用户态协程切换比线程切换快的根本原因。
与之对应的是 G 执行结束后的退出路径。当函数 f 返回时,不会直接回到操作系统,而是会进入 goexit 流程。runtime 会把当前 G 的状态设置为 _Gdead,回收它的资源,然后重新回到调度循环,继续帮这个 M 寻找下一个可运行的 G。所以一个 goroutine 的生命周期结束时,真正的“线程”并没有退出,它只是又开始处理下一个任务了。
3.2 队列空了怎么办:全局队列和 work stealing
如果每个 P 本地队列一直有任务,调度循环就非常简单:取一个跑一个。但真实世界的任务是波动的,某些 P 可能瞬间被塞满,某些 P 则无事可做。这时候 Go 调度器不会让空闲的 M 直接休眠,它会先尝试去别的地方“找活干”。
找活的顺序大致是:先看当前 P 的 runnext 和本地 runq,如果为空,就去全局队列取一批任务。Go 不会一次只取一个,而是会批量取多个放到本地队列里,这样可以减少频繁操作全局锁的次数。
如果全局队列也没有任务,就轮到 work stealing 机制登场了。当前 P 会随机挑一些其他 P,从它们的本地 runq 里“偷”走一部分 G,偷的时候通常是偷走队列后半段的 G,而不是把对方队列全拿走。为什么要偷一半而不是全拿?因为全拿会破坏对方原本的调度顺序,还会让两边的任务分布重新失衡。work stealing 在 Go 里是一种负载均衡手段,它让空闲的 P 不必干等,能主动去帮助繁忙的 P 分担任务。
如果偷了一圈也没偷到任何任务,M 会进入自旋状态,在短暂空转后如果仍然没有任务,才会让 P 进入空闲列表,M 自身进入休眠等待。runtime 会限制自旋线程的数量,避免出现几十个线程都在空转抢锁的惨状。
3.3 系统调用、网络 IO 和阻塞:三种“卡住”场景
goroutine 阻塞这件事,需要区分是“让出 M”还是“占用 M 不放”。这两种情况的代价完全不同。
第一种是 goroutine 主动等待,比如从一个没有数据的 channel 读数据,或者等一个 mutex。这种同步原语不会让操作系统线程陷入等待。runtime 会把当前 G 的状态改为 _Gwaiting,然后把 G 挂到 channel 或锁的等待队列上,M 随即返回调度循环,去执行其他可运行的 G。一旦另一方往 channel 写入数据,runtime 会从等待队列里把那个 G 唤醒,重新放回运行队列。整个过程线程本身一直是活跃的,不会被内核阻塞。
第二种是网络 IO,比如读 socket、连接远程服务。Go 在底层会通过 netpoller 将 fd 注册到 epoll(Linux 上)或对应的多路复用机制上。goroutine 发起的网络操作实际上是异步的:如果 fd 暂时不可读,G 会进入 _Gwaiting 状态,M 继续执行其他任务。当 fd 就绪时,后台的 netpoller 机制会发现它,并把这些 G 标记为可运行。所以高并发网络服务里,大量 goroutine 等待连接并不会拖垮系统线程。
第三种是真正的系统调用,比如阻塞式的一次本地文件读,或者调用某些会陷入内核的 C 代码。这类调用无法做到“异步”,M 本身会被内核阻塞住。Go 的解决方式是:在进入系统调用前,runtime 会提前把当前 M 和 P 解绑,让 P 可以被其他空闲 M 接管,继续执行其他 G。系统调用返回后,原来的 M 需要重新找一个 P 才能继续跑当前的 G,如果所有 P 都被占着,它就要排队等别人让出。
这三种场景的区别,可以总结为:能用协程调度解决的,绝不让线程堵住;实在堵住线程的,就让 P 换人。正是因为 P 可以“流转”,Go 才能用很少的线程去支撑海量 goroutine 的调度,而不会出现一个 goroutine 阻塞
