Theory · Golang · Runtime
G(goroutine)· M(OS 线程)· P(逻辑处理器)——Go 如何用少量线程调度海量协程(M:N)
无锁 runq[256],空闲 P 随机偷一半
channel 只挂 G;syscall 解绑 P;网络走 netpoller
SIGURG 信号打断死循环,时间片 10ms
Why · 先看一道具体的算术题
场景:一台 8 核机器要同时服务 10 万个长连接,每个连接都要"收包 → 查库 → 回包"。在 Go 之前,只有两条路可走。
代码最好写(顺序读写,像单机脚本),但算术上就不成立:Linux 上一个线程默认栈 8MB,10 万线程 = 约 800GB 虚拟内存;就算把栈调小,10 万个线程让内核调度器来轮转,一次切换微秒级、还要陷入内核——CPU 全花在切换上。这就是当年的 C10K 问题。
线程数降到核数级别(epoll 一次等一批 fd),性能上去了,代价全压在代码上:一次请求被切成"读到一半→回调→再读→回调",业务上下文得自己存(回调地狱 / 手写状态机)。谁写谁知道。
仍然一连接一"执行单元"(一个 goroutine),代码照样顺序写;但这个单元是用户态的:初始栈 2KB(10 万个 ≈ 200MB 起步)、创建与切换在纳秒~百纳秒级、阻塞时由运行时把它从线程上摘下来。事件循环没消失,只是被搬进了 runtime。
for {} 就能饿死同一批任务。换掉了你自己写的那套调度:线程池 + 任务队列 + epoll 状态机 + "这个任务卡住了要不要再开线程"的手工判断。Go 把这套东西做进运行时,并给了它一个名字:GMP。
G = 要干的活(goroutine);M = 真正干活的手(OS 线程);P = 干活许可证 + 一张自己的任务清单(逻辑处理器)。拿到许可证的手,才能从清单上取活干——下一页整张图讲的就是这句话。
Prerequisites & Glossary
下面每一页都会用到它们。不用背——忘了就翻回这一页。
| G · goroutine | 一件"要干的活":Go 运行时管理的执行单元,初始栈 2KB,可以有几十万个 |
| M · Machine | 一只"干活的手":真正的操作系统线程,由内核调度,是唯一能占 CPU 的东西 |
| P · Processor | "许可证 + 任务清单":执行 Go 代码所需的一套本地资源(本地队列、内存缓存、计时器堆)。M 必须持有 P 才能跑 Go 代码 |
| M:N 调度 | M 个内核线程上跑 N 个协程(N ≫ M),映射关系由用户态调度器决定 |
| 运行队列 runq | 排队等着被执行的 G。每个 P 一条本地队列(无锁、容量 256)+ 全局一条兜底队列 |
| g0 | 每个 M 自带的一个特殊 G,专门用来跑调度器自己的代码(所以叫"调度栈") |
| 系统调用 syscall | 陷入内核的调用(读文件、DNS 等)。内核不知道 goroutine 是什么,只会把整个线程按住 |
| netpoller | 运行时封装的 epoll / kqueue / IOCP:让"等网络"这件事不占用任何线程 |
| 抢占 preemption | 运行时强行打断一个跑太久的 G,把 CPU 让给别人(对比:主动让出 Gosched) |
| work stealing | 任务偷取:闲下来的 P 去别的 P 队列里搬一半任务过来干 |
OS · 进程与线程 → 线程是什么、为什么它贵(栈 + 内核态切换)
OS · CPU 调度 → 内核侧的镜像:时间片抢占、就绪队列
OS · IO 模型 → 阻塞 / 非阻塞 / 多路复用:netpoller 的地基
channel 底层 → gopark / goready 最大的使用方
Big Picture
把上一页那句话画出来:清单(各 P 的本地队列 + 全局队列)、手(M)、许可证(P),外加两个不占许可证的帮手(sysmon / netpoller)。
Motivation
开场三条约束里的第 ② 条(多核要真并行、又不能都去抢同一把锁),就是 P 这个角色被造出来的原因。
| 维度 | OS 线程 | goroutine |
|---|---|---|
| 初始栈 | ~8MB(Linux 默认) | 2KB,按需增长 |
| 创建/切换 | 陷入内核,微秒级 | 用户态,纳秒~百纳秒级 |
| 可行数量 | 数千 | 数十万+(受内存限制) |
| 调度方 | 内核调度器 | Go runtime 用户态调度 |
goroutine 这么轻,核心问题是:怎么把 N(百万级)个 G 复用到 M(几十)个线程上,还不引入全局锁瓶颈?
GO 1.0 的 GM 模型:所有 G 挂一个全局队列 + 一把全局锁
核数越多,抢全局队列的锁越激烈,扩展性差
G 在线程间任意转移,刚跑热的栈/缓存说没就没
每个 M 都要维护自己的内存缓存(mcache)
线程阻塞在系统调用时,G 无法及时转移给别的线程
解法(Go 1.1):在 M 和 G 之间插入 P(Scalable Go Scheduler Design Doc)——每个 P 自带无锁本地队列,M 必须持有 P 才能执行 Go 代码,负载均衡靠偷任务而不是抢锁。
Roles
待执行的任务单元
stack 栈(2KB 起步)与 atomicstatus 状态stackguard0:栈增长检查点,兼作抢占标记OS 内核线程,真正执行代码的实体
g0:M 自带的调度栈(stub G),schedule() 跑在它上面curg:当前正在运行的 G逻辑处理器 = M 上的"本地调度器"
runq [256] 环形本地队列(runtime2.go)runnext:下一个优先执行的 G 槽位绑定关系:M 必须持有 P 才能执行 Go 代码(p.m ↔ m.p 双向引用);同一时刻并行度 ≤ GOMAXPROCS。
Schedule Loop
每 61 个调度周期(schedtick%61==0)优先查一次全局队列——防止各 P 的本地队列互相"喂饱"、全局队列饿死
先取 runnext 槽位,再取环形队列队头——全程无锁,只有原子操作
gogo 从 g0 栈切换到 G 的栈,开始跑用户代码
G 执行完经 goexit 进入 _Gdead,G 对象与栈回收复用(不物理释放)
// 入队:newproc → runqput(伪码) if next { runnext = gp } // 新 G 优先占 runnext else if !runq.full() { runq.push(gp) } else { globrunqput(gp) } // 满 → 溢出到全局队列
// 取用:schedule()(伪码) if schedtick%61==0 && !gRunq.empty() { gp = globrunqget() // 公平性:查全局 } else { gp = runqget() // runnext → 队头 } execute(gp) // gogo 切栈执行
为什么是 61?质数避免与其他周期性节拍共振,纯属工程选择。
Find Work
stealOrder 以随机起点遍历所有 P——不总盯同一个受害者stealTries = 4,proc.go)runnext 是最后一轮才偷的(源码注释:runnext 是"last resort")——不是"先偷 runnext"设计意图:本地队列无锁取用(低竞争)→ 偷取做负载均衡 → netpoll 压低 IO 延迟。三者配合,调度器在核数变多时依然高效。
来源:src/runtime/proc.go · runtime2.go(runq [256]guintptr)
Preemption
stackguard0 抢占标记for {} 这种无函数调用的死循环永远不触发检查forcePreemptNS)→ 向所在 M 发 SIGURG_Gpreempted,重新入队windows/arm darwin/arm js/wasm plan9/*
这些平台回退为协作式抢占。
Unix 下进程收到的信号变多;慢系统调用(磁盘 IO 等)更容易被信号打断返回 EINTR——Go 自身会重试,混用 C 代码时需要自己处理。
另外 runtime.Gosched() 是主动让出:G 回到可运行队列,与被动抢占不同。
System Monitor
runtime 启动时创建的特殊 M,不需要绑定 P——即使所有 P 都被占满,它也能周期性巡检(间隔约 20µs~10ms 自适应)。
G 运行超过 10ms → 打异步抢占标记(SIGURG 路径的触发源)
P 被阻塞 syscall 占用过久 → 强制 handoffp,把 P 交给其他 M 或新建 M
长时间没有调度发生时,代为执行网络轮询,防止就绪 G 被延迟唤醒
触发强制 GC;fatal error: all goroutines are asleep - deadlock! 就来自这条链路
面试要点:讲异步抢占、讲 syscall 交还 P,都要带上一句"由 sysmon 驱动"——它是这两大机制能生效的发动机。
Blocking
| 维度 | 用户态阻塞(channel / mutex) | 阻塞系统调用(文件 IO / cgo) | 网络 IO(netpoller) |
|---|---|---|---|
| G 的状态 | _Gwaiting,移出运行队列 | _Gsyscall,随 M 停在内核 | _Gwaiting,挂到 netpoller |
| M / P 会怎样 | M 完全不阻塞,立刻调度下一个 G | M 阻塞在内核;P 通过 handoffp 解绑,交给其他 M(没有就新建) | 不占任何线程,M·P 继续跑别的 G |
| 恢复方式 | 条件满足 goready 放回队列 | syscall 返回后优先拿回原 P;拿不回 → G 进全局队列 | fd 就绪时 netpoll 返回 G,重新入队 |
| 对线程数影响 | 无 | 多占一个 M(高并发阻塞 syscall 是 M 暴涨首因) | 无 |
一句话结论:绝大多数"阻塞"只阻塞 G,不阻塞 M/P——这就是少量线程能支撑海量 goroutine 的根本原因。
State Machine
Configuration
import _ "go.uber.org/automaxprocs" 按 cgroup 校准runtime.GOMAXPROCS → 自动关闭新行为GODEBUG=containermaxprocs=0 / updatemaxprocs=0 分别关闭注意:GOMAXPROCS 只限制并行度(同时执行 Go 代码的线程数),不限制并发度(goroutine 可以远多于 P)。新项目在 Go 1.25+ 上一般不再需要 automaxprocs。
Practice
# 每秒打印一行调度器状态(G/M/P 数量与各 P 忙闲) GODEBUG=schedtrace=1000 ./app # 更细:逐个打印每个 G/M/P 的状态 GODEBUG=scheddetail=1,schedtrace=1000 ./app // 运行时 API runtime.GOMAXPROCS(n) // 查看/设置 P 数量 runtime.NumGoroutine() // 当前协程数(泄漏监控指标) runtime.Gosched() // 主动让出 CPU
// 经典演示:Go 1.14 之前这段死循环会饿死 main go func() { for {} // 无函数调用 → 不触发协作式抢占检查 }() time.Sleep(time.Second) println("1.14 之前大概率永远打不出来")
协程泄漏排查:pprof 的 goroutine 视图看 _Gwaiting 聚集点;threadcreate profile 看谁在创建线程。
os/execruntime.LockOSThread 滥用:G 锁定线程后 M 被独占排除项:网络 IO 走 netpoller;channel / mutex 阻塞只挂 G——这两类不会让 M 增长。短暂峰值后 M 会保留复用,只增不减才是异常。
触发条件:有可运行 G + 有空闲 P + 没有空闲 M → runtime 新建 M(newm)救火。
Cheat Sheet
回看只需这一页:数字表 + 三张判定表,全是被追问时要张口就答的东西。
| 2KB | goroutine 初始栈(stackMin),按需增长,64 位上限 1GB |
| 256 | 每个 P 的本地队列容量(runq [256]),另有 1 个 runnext 槽 |
| 61 | 调度周期数:schedtick%61==0 时先查全局队列(防全局饿死) |
| 4 轮 / 一半 | work stealing 最多 4 轮、每次偷目标队列的一半;runnext 最后一轮才偷 |
| 10ms | 抢占阈值(forcePreemptNS):超时由 sysmon 发 SIGURG 打断 |
| 10000 | M 上限(maxmcount),超出 fatal "exceeds 10000-thread limit" |
| GOMAXPROCS | P 数量 = 同时执行 Go 代码的线程数上限;只管并行度,不管并发度 |
| Go 1.14 / 1.25 | 1.14 起异步抢占(信号式);1.25 起 GOMAXPROCS 默认 = min(逻辑核, cgroup limit) 且自动更新 |
本地 runq(runnext → 队头,无锁)→ 全局队列(加锁 + 批量搬)→ netpoll(非阻塞取网络就绪 G)→ work stealing(随机起点 · 4 轮 · 偷一半)→ 兜底(过期 timer / 协助 GC)→ 全空则 stopm 休眠。
| 阻塞类型 | G / M / P 各自怎样 |
|---|---|
| channel · mutex | G→_Gwaiting 移出队列;M 完全不阻塞,立刻跑下一个 G;M 数不变 |
| 阻塞 syscall 文件 IO / cgo / DNS | G→_Gsyscall 随 M 卡在内核;P 被 handoffp 解绑交给别的 M(没有就新建);这是 M 暴涨的唯一常见来源 |
| 网络 IO | G 挂到 netpoller,不占任何线程;fd 就绪后重新入队 |
1.14 前=协作式:只在函数调用等安全点查 stackguard0 标记 → for {} 永不检查,饿死同 P 的 G 并拖延 GC。1.14 起=异步抢占:sysmon 发 SIGURG 在任意点打断,G 置 _Gpreempted 重新入队(windows/arm、darwin/arm、js/wasm、plan9 回退协作式)。副作用:慢 syscall 更易返回 EINTR。
LockOSThread / 泄漏 G 卡在 syscall(网络 IO 与锁等待不会)。runtime.NumGoroutine() 做指标 + pprof 看 _Gwaiting 聚集点。GODEBUG=schedtrace=1000(加 scheddetail=1 逐个打印 G/M/P)。Interview QA · 1/2
先自答再对照:每题先在心里讲一遍,卡住的那题回去翻对应页。
GM 模型把所有 G 挂在一个全局队列,一把互斥锁保护:核数越多锁竞争越烈;G 随意跨线程转移导致缓存局部性差;每个 M 维护内存缓存有额外开销;线程阻塞在 syscall 时 G 无法转移。引入 P 后,运行队列拆分到各 P 本地(无锁 runq[256] + runnext),M 必须持 P 执行 Go 代码,负载均衡靠 work stealing,全局队列只做兜底。
go 语句触发 newproc:从空闲 G 链表复用或新建(栈 2KB),置 _Grunnable;入队优先 runnext,本地满则溢出到全局;某个 M 的 g0 执行 schedule() 取 G(先过 61 周期公平检查);gogo 切栈执行;跑完经 goexit 进 _Gdead,对象与栈回收复用。
四个入口:① 本地 runq 满(runqput 溢出);② schedule() 每 61 周期从全局取(是"取",同时全局也接收溢出与注入);③ exitsyscall 后没抢回 P 的 G 进全局;④ netpoll 就绪的 G 批量注入全局(有 P 空闲时优先进本地)。
findrunnable 走到偷取阶段:stealOrder 随机起点遍历所有 P,最多 4 轮;每轮从目标 runq 批量偷走一半的 G;顺带捡目标 P 已到期 timer;runnext 只在第 4 轮(最后一轮)才偷——源码注释称其为 last resort。全空才 park M。
Interview QA · 2/2
同样先自答再对照:这一组全是排查题——答案要落到"看什么指标、动什么开关"。
channel/mutex:gopark 把 G 置 _Gwaiting 移出队列,M 立即调度下一个 G,完全不阻塞;同步文件 IO:G 进 _Gsyscall,M 阻塞内核,但 P 被 handoffp 解绑给其他 M,处理器不闲着;网络 IO:G 挂在 netpoller 上,不占任何线程。只有阻塞 syscall 会临时多占一个 M。
之前协作式抢占只在函数调用安全点检查,for{} 死循环会饿死同 P 的其他 G 并显著延迟 GC。1.14 起 sysmon 对运行超 10ms 的 G 发 SIGURG 在任意点打断(windows/arm、darwin/arm、js/wasm、plan9 除外)。副作用:信号变多,慢 syscall 更易返回 EINTR,Go 内部已重试,cgo 场景需自己处理。
M 按需创建:有可运行 G + 有空闲 P + 没有空闲 M 时 newm 救火。暴涨根因是 M 被占住回不来:阻塞 syscall(同步文件 IO/DNS)、cgo 调用、LockOSThread 独占、泄漏 G 阻塞在 syscall。上限 10000(maxmcount),超出直接 fatal "exceeds 10000-thread limit"。网络 IO 与锁等待不会推高 M。
GOMAXPROCS = P 数量 = 同时执行 Go 代码的线程数上限,只管并行度不管并发度。Go 1.25 前 默认 NumCPU(宿主机核数),容器里会虚高,需要 automaxprocs 按 cgroup 校准;Go 1.25 起默认取 min(逻辑 CPU, cgroup CPU limit) 且随变化自动更新,手动设置即关闭该行为。多数程序保持默认即可,压测异常再调。
References & Related
键盘操作:←→ 翻页 · T 换主题 · S 演讲者模式 · O 总览。