Theory · Golang · Runtime

GMP 调度模型

G(goroutine)· M(OS 线程)· P(逻辑处理器)——Go 如何用少量线程调度海量协程(M:N)

本地队列 + work stealing

无锁 runq[256],空闲 P 随机偷一半

阻塞不占线程

channel 只挂 G;syscall 解绑 P;网络走 netpoller

1.14 异步抢占

SIGURG 信号打断死循环,时间片 10ms

GMP 是 Go 后端面试出现频率最高的 runtime 题目。这份 deck 按"全景 → 角色 → 调度循环 → 阻塞与抢占 → 数量配置 → 实战 → QA"组织,所有数字都对照 Go 源码验证过。

Why · 先看一道具体的算术题

10 万个连接,8 个核,怎么排

场景:一台 8 核机器要同时服务 10 万个长连接,每个连接都要"收包 → 查库 → 回包"。在 Go 之前,只有两条路可走。

路 A · 一连接一线程

代码最好写(顺序读写,像单机脚本),但算术上就不成立:Linux 上一个线程默认栈 8MB,10 万线程 = 约 800GB 虚拟内存;就算把栈调小,10 万个线程让内核调度器来轮转,一次切换微秒级、还要陷入内核——CPU 全花在切换上。这就是当年的 C10K 问题

路 B · 少量线程 + 事件循环

线程数降到核数级别(epoll 一次等一批 fd),性能上去了,代价全压在代码上:一次请求被切成"读到一半→回调→再读→回调",业务上下文得自己存(回调地狱 / 手写状态机)。谁写谁知道。

Go 的选择 · A 的写法 + B 的成本

仍然一连接一"执行单元"(一个 goroutine),代码照样顺序写;但这个单元是用户态的:初始栈 2KB(10 万个 ≈ 200MB 起步)、创建与切换在纳秒~百纳秒级、阻塞时由运行时把它从线程上摘下来。事件循环没消失,只是被搬进了 runtime。

但"摘下来"这件事得有人干,而且要同时满足三条:
阻塞的 G 不能白占线程——否则 10 万个连接里只要有几千个在等 IO,线程就被占光;
多核要能真并行,又不能都去抢同一把锁——否则核越多越慢;
不肯让出的 G(死循环)必须能被强行打断——否则一个 for {} 就能饿死同一批任务。
这三条正是本 deck 的三条主线:阻塞三分法 · P + 本地队列 + 偷取 · 抢占。

它换掉了什么

换掉了你自己写的那套调度:线程池 + 任务队列 + epoll 状态机 + "这个任务卡住了要不要再开线程"的手工判断。Go 把这套东西做进运行时,并给了它一个名字:GMP

一句话预告三个角色

G = 要干的活(goroutine);M = 真正干活的手(OS 线程);P = 干活许可证 + 一张自己的任务清单(逻辑处理器)。拿到许可证的手,才能从清单上取活干——下一页整张图讲的就是这句话。

动机页:用"8 核接 10 万连接"的算术把问题具体化——线程栈 8MB × 10 万 = 800GB 直接否掉路 A,回调地狱否掉路 B,goroutine 2KB + 用户态切换是第三条路。落点是"摘下来这件事需要满足三条约束",正好预告后面三条主线(阻塞三分法、P 与偷取、抢占),并用"活 / 手 / 许可证"给出 G M 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 最大的使用方

最小心智模型(记住这一个动作):调度器永远在重复同一件事——一只手(M)先拿到一张许可证(P),然后从这张证自带的清单(本地队列)上取一件活(G)来干;活干不动了(等 IO、等锁)就把它放一边、马上取下一件;清单空了就去别人那儿搬一半;一件活干太久(>10ms)就被强行打断换人。

后面所有页——调度循环、findrunnable 五级阶梯、阻塞三分法、抢占、sysmon——都是这句话某一步的放大
前置页:十个术语一句话白话定义,其中 P 用"许可证 + 任务清单"这个比喻,比"逻辑处理器"更容易落地。三条 OS 前置链接给零背景读者兜底。右下最小心智模型把整个 deck 压成一句话(拿证→取活→干不动就换→清单空了去偷→干太久被打断),后面每一页都能挂回其中一步。

Big Picture

一张图看懂 GMP

把上一页那句话画出来:清单(各 P 的本地队列 + 全局队列)、(M)、许可证(P),外加两个不占许可证的帮手(sysmon / netpoller)。

GMP 调度模型全景图 新建 goroutine 进入 P1 的本地队列;全局运行队列做兜底;空闲的 P 从其他 P 偷取任务;sysmon 监控线程负责抢占与回收;netpoller 唤醒网络就绪的 G;M 绑定 P 执行 Go 代码。 go func() 满则溢出 61 周期兜底 STEAL 抢占 > 10ms 就绪唤醒 p.m ↔ m.p p.m ↔ m.p 全局运行队列 sched.runq · 互斥锁保护 G7 G8 P1 · 逻辑处理器 本地调度器 · 数量 = GOMAXPROCS runnext G0 本地运行队列 runq [256] G1 G2 G3 G4 mcache · timers P2 · 逻辑处理器 空闲时可去偷其他 P 的 G 本地运行队列 runq [256] G9 G10 G11 mcache · timers M1 · OS 线程 g0 调度栈 · curg M2 · OS 线程 g0 调度栈 · curg sysmon 不占 P · 抢占/回收 P netpoller epoll / kqueue / IOCP 网络 G 不占线程 LEGEND 实线 = 绑定 / 流转 虚线 = 监控 / 异步 蓝色 = 新建 G 入队 M 必须持有 P 才能执行 Go 代码
先建立整体图景:G 是任务,P 是带本地队列的调度上下文,M 是干活的线程。后面每一页都是这张图的一个局部放大。

Motivation

为什么需要 P:从 GM 模型的四个痛点说起

开场三条约束里的第 条(多核要真并行、又不能都去抢同一把锁),就是 P 这个角色被造出来的原因。

维度OS 线程goroutine
初始栈~8MB(Linux 默认)2KB,按需增长
创建/切换陷入内核,微秒级用户态,纳秒~百纳秒级
可行数量数千数十万+(受内存限制)
调度方内核调度器Go runtime 用户态调度

goroutine 这么轻,核心问题是:怎么把 N(百万级)个 G 复用到 M(几十)个线程上,还不引入全局锁瓶颈?

GO 1.0 的 GM 模型:所有 G 挂一个全局队列 + 一把全局锁

1  全局锁竞争

核数越多,抢全局队列的锁越激烈,扩展性差

2  缓存局部性差

G 在线程间任意转移,刚跑热的栈/缓存说没就没

3  内存开销

每个 M 都要维护自己的内存缓存(mcache)

4  syscall 阻塞

线程阻塞在系统调用时,G 无法及时转移给别的线程

解法(Go 1.1):在 M 和 G 之间插入 P(Scalable Go Scheduler Design Doc)——每个 P 自带无锁本地队列,M 必须持有 P 才能执行 Go 代码,负载均衡靠偷任务而不是抢锁。

讲动机:GM 模型的四个痛点是面试标准答案。引入 P 后,"锁竞争"变成"无锁本地队列 + 空闲时偷取","局部性差"变成"G 优先留在自己的 P 上"。

Roles

G / M / P 各自持有什么

G · goroutine

待执行的任务单元

  • stack 栈(2KB 起步)与 atomicstatus 状态
  • stackguard0:栈增长检查点,兼作抢占标记
  • defer / panic 链、goid

M · Machine

OS 内核线程,真正执行代码的实体

  • g0:M 自带的调度栈(stub G),schedule() 跑在它上面
  • curg:当前正在运行的 G
  • 不执行 Go 代码时可自旋找活或休眠

P · Processor

逻辑处理器 = M 上的"本地调度器"

  • runq [256] 环形本地队列(runtime2.go)
  • runnext:下一个优先执行的 G 槽位
  • 计时器堆、mcache 等运行时状态
无上限G 数量:只受内存约束,几十万很常见
≤ 10000M 数量:按需创建;超出直接 fatal "exceeds 10000-thread limit"
= GOMAXPROCSP 数量:默认逻辑核数(Go 1.25 起容器感知),启动时基本固定

绑定关系:M 必须持有 P 才能执行 Go 代码(p.m ↔ m.p 双向引用);同一时刻并行度 ≤ GOMAXPROCS。

三个数量关系是必考填空:G 无上限、M 上限 10000、P 等于 GOMAXPROCS。P 集中了执行 Go 代码所需的运行时状态,这就是 M 必须绑 P 的原因。

Schedule Loop

调度循环:schedule() 怎么挑下一个 G

1  公平性检查

61 个调度周期(schedtick%61==0)优先查一次全局队列——防止各 P 的本地队列互相"喂饱"、全局队列饿死

2  本地队列 runqget

先取 runnext 槽位,再取环形队列队头——全程无锁,只有原子操作

3  执行 execute()

gogo 从 g0 栈切换到 G 的栈,开始跑用户代码

4  退出与回收

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?质数避免与其他周期性节拍共振,纯属工程选择。

两个高频数字:61 周期的公平性检查、本地队列容量 256。入队优先级是 runnext > 本地 > 全局,取用顺序一致。

Find Work

本地没活了?findrunnable() 五级阶梯

findrunnable 查找阶梯 M 找不到可运行 G 时按顺序尝试:本地队列、全局队列、网络轮询器、work stealing、兜底休眠,逐级降级。 schedule() 调度循环 跑在 M 的 g0 栈上 · 每 61 周期先查全局 ① 本地 runqget runnext 优先 → 环形队列头 · 无锁 ② 全局队列 sched.runq 互斥锁保护 · 同时批量转移(均衡负载) ③ netpoll 网络轮询 非阻塞 epoll_wait · 取回 fd 就绪的 G ④ work stealing 随机起点遍历其他 P · 最多 4 轮 ⑤ 兜底:计时器 / GC 协助 捡起过期 timer、参与 GC 标记 全空 → M park 休眠 stopm · 等新 G 入队时被唤醒

work stealing 规则(stealWork / runqgrab)

  • stealOrder随机起点遍历所有 P——不总盯同一个受害者
  • 最多 4 轮stealTries = 4,proc.go)
  • 一次偷走目标 runq 中一半的 G(批量搬运,两边都有活干)
  • 顺带偷走目标 P 上已到期的计时器
  • ⚠️ 反直觉:目标的 runnext最后一轮才偷的(源码注释:runnext 是"last resort")——不是"先偷 runnext"

设计意图:本地队列无锁取用(低竞争)→ 偷取做负载均衡 → netpoll 压低 IO 延迟。三者配合,调度器在核数变多时依然高效。

来源:src/runtime/proc.go · runtime2.go(runq [256]guintptr)

findrunnable 顺序是经典考题:本地 → 全局 → netpoll → steal → park。偷取细节里最容易答错的是 runnext:源码注释明确说偷 runnext 是最后一轮的 last resort。

Preemption

抢占:Go 1.14 是一道分水岭

1.14 之前 · 协作式

  • 只在函数调用等安全点检查 stackguard0 抢占标记
  • for {} 这种无函数调用的死循环永远不触发检查
  • 后果:饿死同 P 的其他 G;GC 的 STW 等不到它让出 → 显著延迟 GC

1.14 起 · 异步抢占(信号式)

  • sysmon 发现 G 运行超过 10msforcePreemptNS)→ 向所在 M 发 SIGURG
  • 信号处理里把 G 打断置为 _Gpreempted,重新入队
  • 官方原话:无函数调用的循环"no longer potentially deadlock the scheduler or significantly delay garbage collection"

例外平台(不支持异步抢占)

windows/arm darwin/arm js/wasm plan9/*

这些平台回退为协作式抢占。

副作用

Unix 下进程收到的信号变多;慢系统调用(磁盘 IO 等)更容易被信号打断返回 EINTR——Go 自身会重试,混用 C 代码时需要自己处理。

另外 runtime.Gosched()主动让出:G 回到可运行队列,与被动抢占不同。

必背组合:10ms 时间片 + SIGURG + sysmon。死循环饿死 main 的例子在 1.14 之前是真实可复现的 bug,1.14 之后被异步抢占修复。

System Monitor

sysmon:不占 P 的后台监控线程

runtime 启动时创建的特殊 M,不需要绑定 P——即使所有 P 都被占满,它也能周期性巡检(间隔约 20µs~10ms 自适应)。

1  发起抢占

G 运行超过 10ms → 打异步抢占标记(SIGURG 路径的触发源)

2  回收 P(retake)

P 被阻塞 syscall 占用过久 → 强制 handoffp,把 P 交给其他 M 或新建 M

3  兜底 netpoll

长时间没有调度发生时,代为执行网络轮询,防止就绪 G 被延迟唤醒

4  GC 与死锁检测

触发强制 GC;fatal error: all goroutines are asleep - deadlock! 就来自这条链路

面试要点:讲异步抢占、讲 syscall 交还 P,都要带上一句"由 sysmon 驱动"——它是这两大机制能生效的发动机。

sysmon 四件事:抢占、retake 回收 P、兜底 netpoll、GC/死锁检测。关键词"不占 P",所以它永远能跑。

Blocking

三种"阻塞",三种命运

维度用户态阻塞(channel / mutex)阻塞系统调用(文件 IO / cgo)网络 IO(netpoller)
G 的状态_Gwaiting,移出运行队列_Gsyscall,随 M 停在内核_Gwaiting,挂到 netpoller
M / P 会怎样M 完全不阻塞,立刻调度下一个 GM 阻塞在内核;P 通过 handoffp 解绑,交给其他 M(没有就新建)不占任何线程,M·P 继续跑别的 G
恢复方式条件满足 goready 放回队列syscall 返回后优先拿回原 P;拿不回 → G 进全局队列fd 就绪时 netpoll 返回 G,重新入队
对线程数影响多占一个 M(高并发阻塞 syscall 是 M 暴涨首因)

一句话结论:绝大多数"阻塞"只阻塞 G,不阻塞 M/P——这就是少量线程能支撑海量 goroutine 的根本原因。

三分法:用户态阻塞只挂 G;阻塞 syscall 占住 M 但 P 会被 handoffp 救走;网络 IO 走 netpoller 完全不占线程。只有阻塞 syscall 会推高 M 数量。

State Machine

goroutine 状态机

goroutine 状态机 goroutine 在空闲、可运行、运行中、等待、系统调用、被抢占、已退出七个状态之间流转,箭头标注触发函数。 newproc 复用/新建 schedule() 重新入队 goready Gosched gopark entersyscall exitsyscall·拿回P 异步抢占 goexit 空闲 _Gidle 可运行 _Grunnable 运行中 _Grunning · 绑定 M+P 被抢占 _Gpreempted 等待 _Gwaiting · channel/锁 系统调用 _Gsyscall · M 不占 P 已退出 _Gdead · 栈回收复用 _Gdead 的 G 不物理释放:对象与栈进空闲链表复用,避免频繁分配与 GC 写屏障开销
状态机六状态加 _Gdead。重点箭头:gopark/goready 是等待与唤醒;entersyscall 后 M 不占 P;exitsyscall 拿不回 P 就进全局队列;异步抢占经 _Gpreempted 重新入队。

Configuration

GOMAXPROCS:容器里的经典坑,1.25 终于修了

Go ≤ 1.24:默认 = runtime.NumCPU()

  • 读的是宿主机逻辑核数,不看 cgroup 配额
  • 容器里 64 核宿主机 + 2 核 limit → P=64,线程频繁切换、 throttling 加剧
  • 传统解法:import _ "go.uber.org/automaxprocs" 按 cgroup 校准

Go 1.25+:容器感知默认值

  • Linux 上默认 = min(逻辑 CPU 数, cgroup CPU limit)
  • 两者变化时 runtime 周期性自动更新 GOMAXPROCS
  • 手动设 env / 调 runtime.GOMAXPROCS → 自动关闭新行为
  • 可用 GODEBUG=containermaxprocs=0 / updatemaxprocs=0 分别关闭
2KBgoroutine 初始栈(stackMin = 2048)
1GB64 位栈上限(maxstacksize);32 位 250MB
10000M 上限(maxmcount),超出 fatal
无硬上限现行版本 P 数量上限已移除(早期曾有 256/1024)

注意:GOMAXPROCS 只限制并行度(同时执行 Go 代码的线程数),不限制并发度(goroutine 可以远多于 P)。新项目在 Go 1.25+ 上一般不再需要 automaxprocs。

版本相关结论:Go 1.25 容器感知 GOMAXPROCS 是 2025 年的大变化,答容器问题时先问 Go 版本再答 automaxprocs。四个常量都是源码验证过的。

Practice

实战:观察调度器 & 排查 M 暴涨

# 每秒打印一行调度器状态(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 看谁在创建线程。

M 数量暴涨的常见原因

  • 阻塞系统调用(首因):高并发同步文件 IO、同步 DNS、os/exec
  • cgo 调用:C 代码执行期间独占一个 M
  • runtime.LockOSThread 滥用:G 锁定线程后 M 被独占
  • goroutine 泄漏且阻塞在 syscall 上(只增不减的典型信号)

排除项:网络 IO 走 netpoller;channel / mutex 阻塞只挂 G——这两类不会让 M 增长。短暂峰值后 M 会保留复用,只增不减才是异常。

触发条件:有可运行 G + 有空闲 P + 没有空闲 M → runtime 新建 M(newm)救火。

排查题答法:先说机制(什么情况下会新建 M),再列原因(syscall/cgo/LockOSThread/泄漏),最后给排查工具(schedtrace + pprof)。

Cheat Sheet

速查:八个数字 · 五级阶梯 · 三种阻塞

回看只需这一页:数字表 + 三张判定表,全是被追问时要张口就答的东西。

① 八个必背数字

2KBgoroutine 初始栈(stackMin),按需增长,64 位上限 1GB
256每个 P 的本地队列容量(runq [256]),另有 1 个 runnext 槽
61调度周期数:schedtick%61==0 时先查全局队列(防全局饿死)
4 轮 / 一半work stealing 最多 4 轮、每次偷目标队列的一半;runnext 最后一轮才偷
10ms抢占阈值(forcePreemptNS):超时由 sysmon 发 SIGURG 打断
10000M 上限(maxmcount),超出 fatal "exceeds 10000-thread limit"
GOMAXPROCSP 数量 = 同时执行 Go 代码的线程数上限;只管并行度,不管并发度
Go 1.14 / 1.251.14 起异步抢占(信号式);1.25 起 GOMAXPROCS 默认 = min(逻辑核, cgroup limit) 且自动更新

② findrunnable 五级阶梯 —— 顺序不能错

本地 runq(runnext → 队头,无锁) 全局队列(加锁 + 批量搬) netpoll(非阻塞取网络就绪 G) work stealing(随机起点 · 4 轮 · 偷一半) 兜底(过期 timer / 协助 GC) 全空则 stopm 休眠。

③ 三种阻塞,三种命运(最常考)

阻塞类型G / M / P 各自怎样
channel · mutexG→_Gwaiting 移出队列;M 完全不阻塞,立刻跑下一个 G;M 数不变
阻塞 syscall
文件 IO / cgo / DNS
G→_Gsyscall 随 M 卡在内核;P 被 handoffp 解绑交给别的 M(没有就新建);这是 M 暴涨的唯一常见来源
网络 IOG 挂到 netpoller,不占任何线程;fd 就绪后重新入队

④ 抢占:1.14 是分水岭

1.14 前=协作式:只在函数调用等安全点查 stackguard0 标记 → for {} 永不检查,饿死同 P 的 G 并拖延 GC。1.14 起=异步抢占:sysmon 发 SIGURG 在任意点打断,G 置 _Gpreempted 重新入队(windows/arm、darwin/arm、js/wasm、plan9 回退协作式)。副作用:慢 syscall 更易返回 EINTR。

⑤ 排查口诀

M 暴涨 → 阻塞 syscall / cgo / LockOSThread / 泄漏 G 卡在 syscall(网络 IO 与锁等待不会)。
G 暴涨runtime.NumGoroutine() 做指标 + pprof 看 _Gwaiting 聚集点。
看调度器本身GODEBUG=schedtrace=1000(加 scheddetail=1 逐个打印 G/M/P)。
容器里 CPU 抖 → 先问 Go 版本:<1.25 用 automaxprocs,≥1.25 保持默认。
速查页:把全 deck 压成"八个数字 + 五级阶梯 + 三分法 + 抢占分水岭 + 排查口诀"。数字表是填空题题库;阻塞三分法一栏点明"只有阻塞 syscall 会推高 M",这是排查题的判据;排查口诀按现象(M 涨 / G 涨 / 看调度器 / 容器)组织,便于现场对号入座。

Interview QA · 1/2

高频 QA(上)

先自答再对照:每题先在心里讲一遍,卡住的那题回去翻对应页。

Q1 · 什么是 GMP?为什么 Go 1.1 要引入 P?

GM 全局锁局部性无锁本地队列

GM 模型把所有 G 挂在一个全局队列,一把互斥锁保护:核数越多锁竞争越烈;G 随意跨线程转移导致缓存局部性差;每个 M 维护内存缓存有额外开销;线程阻塞在 syscall 时 G 无法转移。引入 P 后,运行队列拆分到各 P 本地(无锁 runq[256] + runnext),M 必须持 P 执行 Go 代码,负载均衡靠 work stealing,全局队列只做兜底。

Q2 · 一个 G 从创建到执行完的完整流程?

newprocrunqputschedulegoexit

go 语句触发 newproc:从空闲 G 链表复用或新建(栈 2KB),置 _Grunnable;入队优先 runnext,本地满则溢出到全局;某个 M 的 g0 执行 schedule() 取 G(先过 61 周期公平检查);gogo 切栈执行;跑完经 goexit 进 _Gdead,对象与栈回收复用。

Q3 · 什么情况下 G 会进全局队列?

本地满61 周期syscall 未拿回 Pnetpoll 注入

四个入口:① 本地 runq 满(runqput 溢出);② schedule() 每 61 周期从全局取(是"取",同时全局也接收溢出与注入);③ exitsyscall 后没抢回 P 的 G 进全局;④ netpoll 就绪的 G 批量注入全局(有 P 空闲时优先进本地)。

Q4 · work stealing 的完整规则?

随机起点4 轮偷一半runnext 最后

findrunnable 走到偷取阶段:stealOrder 随机起点遍历所有 P,最多 4 轮;每轮从目标 runq 批量偷走一半的 G;顺带捡目标 P 已到期 timer;runnext 只在第 4 轮(最后一轮)才偷——源码注释称其为 last resort。全空才 park M。

QA 按"概念 → 流程 → 边界 → 细节"排序。答题套路:先给一句话结论,再展开机制,最后补一个数字细节(256、61、4 轮)。

Interview QA · 2/2

高频 QA(下)

同样先自答再对照:这一组全是排查题——答案要落到"看什么指标、动什么开关"。

Q5 · G 阻塞在 channel 上,M 会阻塞吗?阻塞在文件 IO 呢?

gopark 只挂 Gsyscall handoffpnetpoll 不占线程

channel/mutex:gopark 把 G 置 _Gwaiting 移出队列,M 立即调度下一个 G,完全不阻塞;同步文件 IO:G 进 _Gsyscall,M 阻塞内核,但 P 被 handoffp 解绑给其他 M,处理器不闲着;网络 IO:G 挂在 netpoller 上,不占任何线程。只有阻塞 syscall 会临时多占一个 M。

Q6 · Go 1.14 异步抢占解决了什么?有什么副作用?

SIGURG10ms 时间片EINTR

之前协作式抢占只在函数调用安全点检查,for{} 死循环会饿死同 P 的其他 G 并显著延迟 GC。1.14 起 sysmon 对运行超 10ms 的 G 发 SIGURG 在任意点打断(windows/arm、darwin/arm、js/wasm、plan9 除外)。副作用:信号变多,慢 syscall 更易返回 EINTR,Go 内部已重试,cgo 场景需自己处理。

Q7 · M 的数量为什么会暴涨?有上限吗?

阻塞 syscallcgoLockOSThread≤10000 fatal

M 按需创建:有可运行 G + 有空闲 P + 没有空闲 M 时 newm 救火。暴涨根因是 M 被占住回不来:阻塞 syscall(同步文件 IO/DNS)、cgo 调用、LockOSThread 独占、泄漏 G 阻塞在 syscall。上限 10000(maxmcount),超出直接 fatal "exceeds 10000-thread limit"。网络 IO 与锁等待不会推高 M。

Q8 · GOMAXPROCS 该怎么设?容器里要注意什么?

P 数量min(核数, cgroup)Go 1.25

GOMAXPROCS = P 数量 = 同时执行 Go 代码的线程数上限,只管并行度不管并发度。Go 1.25 前 默认 NumCPU(宿主机核数),容器里会虚高,需要 automaxprocs 按 cgroup 校准;Go 1.25 起默认取 min(逻辑 CPU, cgroup CPU limit) 且随变化自动更新,手动设置即关闭该行为。多数程序保持默认即可,压测异常再调。

Q5/Q7 是排查题的两大变体,Q6 考版本演进,Q8 考生产经验——记得先反问对方 Go 版本(1.25 前后答案不同)。

References & Related

参考来源 & 相关知识点

参考来源(已逐条核对)

相关知识点(点击跳转 · 待沉淀项以虚线标注)

键盘操作: 翻页 · T 换主题 · S 演讲者模式 · O 总览。

所有结论都对照 Go master 源码与官方 release notes 验证过,带版本敏感的结论(1.14 抢占、1.25 GOMAXPROCS)都标了版本。