很多刚开始写 Go 的朋友都会问一个问题:我写一个go func(),它到底跑在哪个线程上?这个问题说简单也简单,Go 的运行时把用户态的 goroutine 调度到操作系统线程上执行,也就是说,goroutine 与系统线程之间不是简单的 1:1 绑定,而是一种动态的映射关系。只要理解了这套映射逻辑,很多关于“高并发令人迷惑”的问题都会迎刃而解,比如为什么有时候 goroutine 几十个,系统线程却越来越多,为什么GOMAXPROCS调整之后程序行为变化巨大,为什么看似阻塞的代码反而会让整个进程的线程数飙升。
这篇文章不打算只是贴概念,我会把 Go 调度器里的 GMP 模型、系统线程的创建与复用、系统调用时的线程解绑与恢复全部拆开讲一遍,再附上可以直接照着跑的诊断实操,帮你彻底看懂协程和系统线程之间到底是怎么映射的。适合刚开始接触 Go 并发的读者,也适合已经写过一段时间并发代码,但遇到性能问题不知从何下手的人。
1. 先搞清楚 G、M、P 三种角色,才能看懂线程映射
1.1 不是“更轻量的线程”,而是“可调度的任务”
很多教程把 goroutine 称为轻量级线程,这个说法容易造成误解。线程是操作系统内核的调度单位,内核负责线程的切换、抢占、睡眠和唤醒。goroutine 虽然也有自己的栈、PC、寄存器的状态,但它本身并不被内核感知,真正执行 goroutine 指令的载体还是操作系统线程。
可以这样类比:进程是一家公司,系统线程是公司的正式员工,goroutine 是员工手上的工作任务单。员工只有一双干活的手,一次只能处理一个任务单,但他可以把手里的事放到一边,先处理另一件更紧急的事。所谓调度,就是让员工不断切换手头的工作,而老板(Go 运行时)决定先干哪个任务单、谁的任务单可以被别人抢走。员工数有限,任务单却可以非常多。
对比一下初始资源消耗会更直观:Linux 上一个系统线程默认栈空间在 8MB 左右,而一个 goroutine 初始栈只有 2KB,而且是按需增长的。所以 Go 程序可以轻易创建上百万个 goroutine,但不可能创建上百万个线程。goroutine 的价值在于“同一时刻真正在系统线程上执行的只有一小部分”,其余大量 goroutine 都是排队等待、挂起或者处于其他非运行状态。
1.2 系统线程在 Go 里的正式身份:M
在 Go 的运行时源码里,系统线程对应的结构体叫m,源码注释里叫 machine。每个m都绑定了一个真正的操作系统线程,m负责把 goroutine 的执行指令交给 CPU 去跑。一个进程里可能同时存在多个m,也可能有一部分m处于休眠状态等待被唤醒。
M 的数量并不是随便定的,它受几个因素影响:GOMAXPROCS决定的是同时执行用户态 Go 代码的 P 的数量,但 M 可能会因为系统调用、runtime.LockOSThread、CGo 调用等原因临时增多,甚至超过 CPU 核心数。在没有阻塞操作的情况下,M 的数量大致会被 P 的数量约束住,但一旦出现阻塞性系统调用,Go 运行时会临时增加 M,把原来的 M 和系统调用绑在一起,再让新的 M 继续执行别的 goroutine。这种机制是理解“系统线程映射”的核心。
M 的总数也不是无限的,运行时默认允许最多 10000 个线程,超出之后会直接让程序崩溃。这个上限通常不会触到,但如果你的程序大量长时间阻塞在磁盘 IO 或者某些 cgo 调用里,线程数量会肉眼可见地膨胀,逼近上限时整个进程的内存占用会非常夸张。
1.3 一句话理解 G、M、P 的映射关系
P 是逻辑处理器,可以把它理解为“可以并行执行 Go 代码的许可证”。P 的个数默认等于 CPU 核心数,由GOMAXPROCS控制。G 是 goroutine,M 是系统线程。三者的映射关系大概是:
- 一个 G 最终必须被放到某个 M 上去执行;
- M 要执行 G,必须先拿到一个 P;
- P 的数量决定同时有多少个 M 在真正运行用户态 Go 代码;
- P 与 M 不是固定绑定的,M 没有 P 的时候,就算线程是活的,也不能执行 Go 代码,只能去自旋、偷任务或者休眠。
这是一个 M:N 调度的模型:M 个系统线程调度 N 个 goroutine,中间通过 P 做了一层隔离。P 这一层的意义在于限制同时执行 Go 代码的系统线程数量,避免线程过多导致操作系统上下文切换开销爆炸。回顾标题里的“映射”两个字,映射的核心其实就是这层 M:N + P 的动态关系。
2. 调度器怎么把 goroutine 映射到系统线程
2.1 本地队列、全局队列与工作窃取
Go 调度器为每个 P 维护一个本地运行队列,里面存放着等待运行的 goroutine。当用户代码执行go func()时,新创建的 goroutine 会被放进当前 P 的本地队列尾部。如果本地队列满了,就会把一半的 goroutine 转移到全局队列。
调度循环开始的时候,M 拿着 P 会先从本地队列取一个 G 来执行。本地队列空了,就会去全局队列取一批,再不行就尝试从其他 P 的本地队列里偷取一半任务。这个“偷”的动作叫 work stealing,是保证多核负载均衡的关键。还有一条路是从网络轮询器里唤醒因为网络 IO 而阻塞的 goroutine。
所以从“映射”的角度看,系统线程 M 并不是一开始就固定绑定到某个 goroutine 的。它更像一个流水线上的工人,不断地去任务池里领活,手里的活干完了就换下一个。M 从队列里取出 G 之后,G 才真正映射到 M 上。G 被阻塞或者主动让出 CPU 时,M 会继续取别的 G。
2.2 M 的创建与复用:线程真的会“自动扩缩容”吗
很多开发者在排查性能问题时喜欢用top -H看线程数,发现 Go 进程系统线程数量一直在涨,第一反应是线程泄漏。其实 Go 调度器对 M 有复用机制:一个 M 执行完所有任务后不会立刻销毁,而是会进入空闲列表等待被唤醒。相对空闲时线程数会降下来,突然有大量阻塞调用时线程数会快速上升。这个自动扩缩容的过程也解释了为什么你看到线程数高不代表出了 bug。
但要注意“复用”不等于“无限空闲”。如果程序持续有阻塞系统调用,旧的 M 被卡在系统调用里,调度器必须创建新的 M 来维持 P 上的任务执行。当系统调用返回后,被阻塞的 M 和对应的 G 会尝试重新绑定一个 P,如果暂时没有空闲 P,M 就会挂起,G 会被重新放回队列等待调度。所以线程数飙升通常意味着“阻塞”而不是“泄漏”,需要先排查具体是哪一种调用在阻塞。
关于 M 的创建条件,实际运行时比想象中的复杂。比如内核线程通过clone创建之后,Go 运行时还要完成 g0 栈的初始化、信号处理等设置。创建系统线程的代价远比创建 goroutine 高,这也是GOMAXPROCS不建议盲目调大的原因之一:如果 P 多了,调度器会倾向于创建更多的 M,操作系统上下文切换成本就会增加。
2.3 为什么并行度看 P,不看 M
P 的数量决定的是“同一个时刻最多有多少个 goroutine 在并行执行用户态代码”。如果程序是 CPU 密集型,把GOMAXPROCS从 2 调到 8,运行速度理论上最多提升 4 倍。如果程序是 IO 密集型,提升GOMAXPROCS对吞吐量的帮助并不一定明显,因为瓶颈在网络等待、锁竞争或磁盘 IO,而不是 CPU 核心不够用。
M 则是线程映射的底层资源,它可能比 P 多,也可能比 P 少。多出来的 M 往往处于阻塞、自选旋转或空闲状态。比如运行一个有 1000 个 goroutine 的 CPU 密集程序,P=4 时通常只有 4 个活跃的 M,线程总数最多在 4~6 左右上下波动。但如果这 1000 个 goroutine 里有 500 个在同时调用阻塞 sleep,M 的数量就可能远高于 P 的数量,因为每个阻塞的 M 都占着一个系统线程等在那里。
这里有一个常见误区:有人以为GOMAXPROCS决定线程数,然后把它设置成 100 想提高并发能力,结果程序反而变慢。原因在于操作系统看到的是 100 个可运行状态的高频切换线程,线程上下文切换会消耗大量 CPU,文件描述符和内存占用也会上升。牢记一件事:P 是并发模型里的“并行度闸门”,不是越大越好。
3. 系统调用是线程映射里最容易出问题的地方
3.1 进入 syscall 时发生了什么
当 goroutine 执行一个会阻塞的系统调用时,比如读取磁盘文件、time.Sleep的一部分实现、某些 cgo 调用,Go 运行时不能干等。它的做法是:先把当前 M 和 G 的状态标记为“正在系统调用”,然后把该 M 对应的 P 释放出来,让给其他空闲的 M 使用。被释放的 M 继续阻塞在系统调用里,等系统调用返回后,G 和 M 再尝试找一个空闲 P 回来继续执行。
用大白话说:一个员工排队去银行办事,扣在窗口前走不开;公司不会等他把事办完才安排别人干活,而是临时叫另一个员工顶上他的工位。等原来那个员工办完事回来,再看有没有空工位,有就坐下继续干活,没有就先去休息室待命。
这套机制保证了系统调用不会阻塞整个 P,其他 goroutine 还能继续跑。代价就是 M 的数量会动态增加。如果系统调用只是偶发的、快速的,比如查询一下 DNS 缓存,线程数不会明显变化。但如果是大量并发执行慢磁盘读或 cgo 调用,M 数量会一下子涨上去。
3.2 哪些场景会导致线程数量失控
我实际排障时遇到过几类会让 M 数量飙升的情况:
第一类是同步 HTTP 客户端配合高并发使用。如果http.Client的Transport没有配好连接池参数,每条连接在建立或等待响应时都可能触发系统调用阻塞,导致 M 数量与并发请求数几乎线性相关。
第二类是 CGo 调用。cgo 调用会直接通过系统线程去执行 C 代码,C 函数内部如果阻塞,Go 运行时不知道什么时候能回来,也没办法帮你把线程让出来。设计不当的 cgo 库是最容易触发线程增长到几千的场景之一。
第三类是磁盘 IO。本地文件读写、数据库驱动里的同步 socket 操作,都可能导致 M 被占住。尤其是数据库连接池设置不合理时,连接建立阶段大量阻塞,线程数会快速膨胀。
判断是不是线程失控,可以用runtime/debug包里的SetMaxThreads设置上线,也可以用GODEBUG环境变量输出调度器日志分析。不过更重要的是找到具体的阻塞点,把同步阻塞改成异步 IO、连接池复用、或者用 channel 限制并发度,线程数自然就会降下来。
3.3 用 GODEBUG 和 pprof 看清线程映射
Go 运行时内置了一个调度器调试开关,用法很简单:
GODEBUG=schedtrace=1000 ./your_program这个参数会让 Go 运行时每 1000 毫秒打印一行调度器状态,输出类似:
SCHED 1034ms: gomaxprocs=8 idleprocs=6 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]各字段含义如下:
gomaxprocs:当前 GOMAXPROCS 的值,即 P 数量;idleprocs:空闲 P 数量;threads:进程当前系统线程总数,这是观察 M 数量最直接的指标;spinningthreads:正在自旋寻找任务的 M 数量;idlethreads:空闲 M 数量;runqueue:全局运行队列中的 goroutine 数量;- 方括号里的数组是每个 P 的本地队列长度。
如果threads的数量远远大于gomaxprocs,说明有大量 M 被阻塞在系统调用里或者处于自旋状态。这时可以配合go tool pprof抓取 CPU profile 和 goroutine profile:
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 go tool pprof http://localhost:6060/debug/pprof/goroutinegoroutine profile 可以看到每个 goroutine 当前的堆栈,阻塞原因一目了然。还有一个更细的调度器日志参数:
GODEBUG=schedtrace=1000,scheddetail=1 ./your_programmode会打出每个 P、每个 M 的详细状态,比如是否在系统调用、是否空闲、G 列表是什么。这个输出非常啰嗦,生产环境别开着跑,本地压测时倒是很值得用一用。
4. 实操:用一个小程序观察 G/M/P 的动态变化
4.1 构造一个能被观察的负载
理论讲完,还是得动手看。我写了一个简单程序,分别测试 CPU 密集场景和阻塞场景下 M 的数量变化。
package main import ( "flag" "fmt" "runtime" "sync" "time" ) func cpuTask(wg *sync.WaitGroup) { defer wg.Done() sum := 0 for i := 0; i < 1e8; i++ { sum += i } _ = sum } func blockTask(wg *sync.WaitGroup) { defer wg.Done() time.Sleep(100 * time.Millisecond) } func main() { mode := flag.String("mode", "cpu", "cpu or block") flag.Parse() runtime.GOMAXPROCS(runtime.NumCPU()) var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) if *mode == "cpu" { go cpuTask(&wg) } else { go blockTask(&wg) } } wg.Wait() fmt.Println("done") }编译运行:
go build -o demo . GODEBUG=schedtrace=1000 ./demo -mode=cpu GODEBUG=schedtrace=1000 ./demo -mode=block代码里故意没有限制并发 goroutine 的数量,目的就是直观地看调度器在不同负载类型下的表现。
4.2 观察 G/M/P 的变化
-mode=cpu运行时,输出类似:
SCHED 1000ms: gomaxprocs=8 idleprocs=0 threads=9 spinningthreads=0 idlethreads=0 runqueue=900 [2 3 4 2 1 5 3 2]可以看到threads数量在 9 左右,只比gomaxprocs=8多一点点。这是因为 CPU 密集型任务没有系统调用阻塞,8 个 P 都刚好有对应的 M 在执行计算,多出来的 1 个 M 一般是为了处理定时器、GC 或事件而临时创建的。runqueue里积压了大量等待执行的 goroutine,说明调度器正在排队分配任务。
-mode=block运行时的输出会完全不一样:
SCHED 1000ms: gomaxprocs=8 idleprocs=0 threads=36 spinningthreads=0 idlethreads=0 runqueue=540 [0 0 0 0 0 0 0 0]threads一下子涨到了 36,而且每个 P 的本地队列都是 0,runqueue还有 540 个任务在排队。这里time.Sleep触发了相对较长的事件等待,运行时把正在 sleep 的 G 和 M 从 P 上拆下来,P 被其他 M 接手继续执行队列里的任务。由于有 1000 个 goroutine 同时 sleep,短时间内 M 要不断创建,所以线程数明显高于 P 数。
做个对照表格更直观:
| 负载类型 | GOMAXPROCS | 观察到的线程数 | 原因 |
|---|---|---|---|
| CPU 密集 | 8 | 9 左右 | 没有阻塞系统调用,M 数量约等于 P 数量 |
| Sleep/阻塞 | 8 | 36 左右 | 阻塞时 M 与 P 解绑,调度器创建新 M 接管 |
| 无任务空闲 | 8 | 2~3 | M 大部分休眠等待唤醒 |
看完这个实验,你就理解了为什么高并发 IO 服务即使GOMAXPROCS不高,系统线程数也可能不低。线程数的合理与否,取决于阻塞比例和阻塞时长,而不是简单地看线程数绝对值。
4.3 容器环境下的 CPU 限制与 GOMAXPROCS 设置
最近几年大家普遍把 Go 服务跑在容器里,这里有一个特别容易踩的坑:Go 在 1.16 之前的版本里runtime.NumCPU()读的是宿主机的 CPU 数量,不是容器限制的配额。比如容器限制 2 核,但宿主机是 64 核,GOMAXPROCS默认就是 64,导致调度器创建大量 P 和 M,线程切换成本成倍增加。
我建议要么手动在启动时设置环境变量:
GOMAXPROCS=2 ./your_service要么在代码里读 cgroup 限制做一次校准。社区目前有个比较成熟的方案是使用automaxprocs库,导入空包即可自动根据容器配额调整GOMAXPROCS:
import _ "go.uber.org/automaxprocs"启动时它会自动读取容器 CPU 配额并设置最合理的GOMAXPROCS。个人经验是,只要服务跑在容器里,就值得引入这个库,至少可以避免“明明给了 2 核却跑出几十个线程”的离谱场面。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决建议 |
|---|---|---|---|
| 线程数持续上涨到几百上千 | 大量同步阻塞系统调用、cgo 调用、连接池过小 | 打 goroutine profile,看阻塞堆栈;用 schedtrace 观察 | 改用异步 IO、复用连接、限制并发度 |
| GOMAXPROCS 调大后性能反而下降 | 启动过多 P/M,线程切换开销增大 | 对比不同 GOMAXPROCS 下的压测数据 | 按 CPU 核心数或容器配额设置,不要贪多 |
| goroutine 数量巨大但 CPU 占用很低 | goroutine 都在等锁、等 channel、等网络响应,处于挂起状态 | go tool pprof goroutine看等待点 | 分析锁粒度、channel 设计、外部依赖耗时 |
| 程序偶发 “thread exhaustion” 崩溃 | M 数量触达 10000 线程上限 | 设置debug.SetMaxThreads获取更多定位信息,配合 pprof | 优先修复线程阻塞,而不是盲目调大上限 |
| 容器中 Go 进程线程数异常偏高 | GOMAXPROCS 识别到宿主机核心数 | 检查运行时日志或代码里NumCPU() | 引入 automaxprocs 或手动设置 GOMAXPROCS |
| goroutine 执行顺序不符合预期 | 调度器本身是非确定性的,队列和抢占都会影响顺序 | 检查代码是否依赖执行顺序 | 用 channel、同步原语或显式协调,不要赌调度顺序 |
5.2 几个亲历的坑
第一个坑是关于runtime.LockOSThread。之前有个数据处理模块为了保证某些 cgo 库的线程局部状态,在 goroutine 里调用了runtime.LockOSThread(),这样该 goroutine 会和当前 M 强绑定。结果一旦这个 goroutine 阻塞在某次读取操作上,线程就被占住了,后续新任务只能创建新的 M。排查了半天,最后把所有LockOSThread调用收敛到独立的工作池,线程数才恢复正常。这个 API 不是不能用,但要非常克制。
第二个坑是 goroutine 泄漏。程序里某个组件从 channel 接收任务,任务生产方因为异常提前退出,接收方 goroutine 永远阻塞在 for-range 上。表面上看系统线程数没变化,但实际上堆积的 goroutine 会消耗内存。定位的时候用go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=1拉一次全量堆栈,看到成百上千个相同堆栈基本就是泄漏点。
第三个坑是别把GOMAXPROCS调成 1 来“保证输出顺序”。我见过有人为了让并发程序的日志顺序可控,把GOMAXPROCS=1,以为这样所有 goroutine 就会按启动顺序执行。实际上在单 P 下,goroutine 的执行顺序仍受抢占、睡眠、channel 操作影响,并不会变成简单的 FIFO。想让输出有序,唯一可靠的办法是程序里显式做同步。
最后分享一个我自己的排查习惯。遇到 Go 并发性能问题,我一般先跑一次GODEBUG=schedtrace=1000,把threads字段的变化趋势记录下来。如果线程数相对平稳,再去查 goroutine 堆栈;如果线程数波动很大,我会优先怀疑阻塞系统调用和 cgo。很多看起来复杂的调度问题,顺着线程数这条线索都能很快拆出真相。