Interview Lab / Go / 任务运行机制

Go GMP 调度器深度模型:G、M、P 如何协作

先解释标题:Go 运行时(runtime)是随 Go 程序一起工作的底层系统,负责管理内存、任务和执行线程。GMP 是运行时安排并发任务的三个核心角色;并发是多个任务在同一段时间里交替推进,并行则是多个任务在同一时刻真的一起执行。G 来自 goroutine,表示由运行时管理、等待或正在执行的轻量任务;M 来自 machine,表示真正被操作系统调度的线程;P 来自 processor,表示执行 Go 代码所需的权限和本地资源。调度器就是负责把 G、M、P 配对,让大量任务高效轮流执行的运行时组件。

这篇先从“同时生成报表和发送邮件”的日常代码出发,回答为什么大量 goroutine 不能简单等同于大量线程;再用“订单、厨师、工位”建立直觉,逐步进入队列(按候选顺序保存等待任务的结构)、阻塞(任务因等待条件而暂时不能继续)、抢占(让长时间运行的 G 先暂停让位)与源码入口。

G / M / P 三个角色任务创建与状态变化本地与全局队列阻塞后如何继续干活源码调用链
版本边界:本文以项目最低版本 Go 1.26 系列的运行时源码为实现基线。G、M、P 的核心职责长期稳定;队列顺序、字段、函数名和时间片(一个 G 连续占用执行机会的一段时间)属于实现细节,升级后应重新核对。GOMAXPROCS 来自“Go maximum processors”,意为 Go 可使用的最大处理单元数,负责设置允许多少个 P 同时执行普通 Go 代码。Go 1.25 起,当 go.mod(记录模块及其 Go 语言版本的文件)声明 Go 1.25 或更高版本,并且没有通过环境变量、函数调用或 GODEBUG(控制运行时诊断与兼容行为的环境变量)覆盖默认行为时,Linux 上的运行时会考虑 CPU(Central Processing Unit,中央处理器)限额,并可随资源变化自动更新。
一、为什么需要调度器:任务很多,线程不能无限增加

先看日常用法:两件互不依赖的事可以同时推进

用户注册后,系统既要生成欢迎资料,又要发送邮件。两件事互不依赖,可以各自放进一条 goroutine。下面的 make 是创建并初始化特定 Go 数据结构的内置函数,chan 是声明 Channel(通道)的关键字;results 是容量为 2 的“完成结果通道”,taskName 是主流程当前收到的任务名称。

results := make(chan string, 2)

go func() {
    generateWelcomeFile()
    results <- "欢迎资料"
}()

go func() {
    sendEmail()
    results <- "邮件"
}()

for i := 0; i < 2; i++ {
    taskName := <-results
    fmt.Println(taskName, "完成")
}

Channel(通道)是 goroutine 之间传递数据或完成信号的工具。两行输出的先后不保证,但两个任务最终都会完成;这正说明代码描述的是“可以同时推进”,不是规定固定执行顺序。

没有运行时调度会怎样

如果每创建一条 goroutine 就永久创建一条操作系统线程,十万个并发任务会带来大量线程内存、创建销毁和上下文切换成本。上下文切换是处理器暂停当前线程、保存现场,再恢复另一条线程的过程。

业务想要的

可以创建很多轻量任务;某个任务等待网络时,其他任务继续推进。

运行时必须解决的

用有限线程承载大量任务,并决定谁先运行、阻塞后谁顶上、长任务何时让位。

调度器的价值:它节省“一任务一线程”的资源成本,也让等待中的任务不白占执行机会;代价是执行顺序不确定,任务切换和队列维护本身也有开销。

再用厨房建立三个角色的画面

把一个 Go 程序想成餐厅厨房。客人可能瞬间提交几千张订单,但厨房不会为每张订单永久雇一个厨师,而是让有限的厨师和工位不断切换订单。

G:订单

记录“要做什么、做到哪里、当前是什么状态”。订单很多没有关系,因为它本身不是一个真实线程。

M:厨师

真正动手执行。对应操作系统线程,最终由操作系统安排到某个 CPU 上运行。

P:工位

带着待办订单和本地工具。厨师必须占到工位,才有资格执行普通 Go 代码。

G 是任务,M 是线程,P 是执行 Go 代码的许可证与本地资源。

并发不等于并行

并发(concurrency)是多个任务在同一时间段内交替推进;并行(parallelism)是多个任务在同一时刻真的一起执行。一个 P 也能并发处理很多 G,但同一时刻只能执行一个普通 Go 任务;多个 P 才可能让多个 G 并行。

1 个 P

G1 做一会儿,阻塞或让位后换 G2。大家都在推进,但普通 Go 代码通常没有同时执行。

4 个 P

最多允许 4 个线程同时执行普通 Go 代码,因此最多有 4 个 G 同时在 CPU 上推进。

先纠正一个常见误区:不是“一个 P 永久绑定一个 M”,也不是“一个 G 永久绑定一个 M”。运行中通常是 M 持有 P 执行 G;遇到阻塞系统调用时,P 可以离开原 M,去配合别的 M 继续执行其他 G。
二、正式模型:G、M、P 各自保存什么

G:goroutine 的运行档案

goroutine 是由 Go 运行时管理的轻量并发任务,代码里用 go f() 创建。运行时用内部的 g 结构记录它的栈范围、状态、下一条要执行的指令位置、当前关联的 M、阻塞原因等信息。

栈(stack)是函数调用时保存局部变量、参数和返回位置的内存区域。goroutine 的栈可以按需增长,所以创建大量 G 通常比创建同等数量的操作系统线程轻得多。

M:承载执行的操作系统线程

M 对应运行时的 m 结构,背后通常是一条操作系统线程。M 保存当前执行的 G、持有的 P,以及用于调度的 g0。g0 是每个 M 自带的系统 goroutine,专门在系统栈上执行调度、栈管理等运行时代码,不承载普通业务函数。

P:执行权与每处理器资源

P 对应运行时的 p 结构。它不是真实 CPU,也不是一条线程;它保存本地可运行队列、内存分配缓存、计时器(记录未来何时唤醒任务的运行时对象)等“执行 Go 代码需要且适合按并行单元拆分”的资源。P 的数量等于当前 GOMAXPROCS。

角色它是什么数量特点没有它会怎样
G一项 Go 并发任务及其执行现场可以很多,按业务动态创建没有待执行的业务代码
M操作系统线程的运行时表示按需要创建;阻塞在系统调用中的线程不受 P 数量直接限制没有真实执行载体
P执行普通 Go 代码的资格与本地资源恰好是 GOMAXPROCS 个M 即使存在,也不能执行普通 Go 代码
两个 P 允许两组 M + P + G 同时运行 M1 线程P1 工位M2 线程P2 工位 正在执行 G1正在执行 G2 P1 队列共 256 槽,已占逻辑位置:队头 head → [0] G3 · [1] G4 · [2] G5 → tail 队尾P2 队列共 256 槽,已占逻辑位置:队头 head → [0] G6 · [1] G7 → tail 队尾 操作系统最终把 M1、M2 安排到真实 CPU 上;Go 调度器不直接调度 CPU。
最应记住:图里是 G1、G2 已经过队列选择并进入运行后的某一瞬间,下一轮还会继续从标明下标的队列取 G;常见误读是把 M、P、G 画在一起就认为它们永久绑定。
三、一条 goroutine 的一生:创建、排队、运行、等待、结束

运行时给 G 维护状态。理解状态比死记函数名更重要,因为调度本质就是“让 G 在不同状态之间安全转换”。下面只列面试最常用的主状态;源码中还有扫描标记等组合状态。

_Gsyscall 是另一条重要分支,表示 G 正在进行系统调用。系统调用(system call)是程序请求操作系统执行文件读写、线程休眠等内核能力的入口;内核是操作系统直接管理处理器、内存和设备的核心部分,系统调用可能让承载 G 的线程在内核里阻塞。图中的 Channel 是 goroutine 之间传递数据和同步状态的通道。

G 的主线不是直线:每个离开与返回条件都不同 新建_Gdead / 初始化准备栈与入口 可运行_Grunnable在队列等 P 运行中_GrunningM + P 正在执行 结束_Gdead可回收到空闲池 入队获得 M + P函数返回 等待中_Gwaiting等 Channel、锁或网络 系统调用_GsyscallG + M 可能在内核等待 Channel 无数据 / 锁未获得 / 网络未就绪进入系统调用 条件满足 → goready 调用返回时拿到 P?原 P 或其他空闲 P 是:直接恢复运行 否:G 进入全局可运行队列,M 停下 代价:普通等待至少多一次切换与重新排队;系统调用拿不到 P 时还会访问全局队列。
最应记住:等待结束与系统调用返回不是同一个出口;常见误读是把“G 被唤醒”说成“G 已运行”,普通等待结束后它通常只是重新进入可运行队列。

go work() 背后发生什么

编译器是把 Go 源代码转换成机器可执行程序的工具;空闲 G 池是运行时保存已结束、可重新利用的 G 对象的地方。沿着下面五步,可以看到一条新任务怎样从代码进入可运行位置。

  1. 编译器把 go work() 转换为对运行时 newproc 的调用。
  2. newproc1 优先从 P 的空闲 G 池复用对象;没有可用对象时再分配 G 和初始栈。
  3. 设置函数入口、参数、父 G 等信息,把新 G 标成 _Grunnable。
  4. runqput 通常把新 G 放进当前 P 的 runnext(优先运行槽),或放进这个 P 自己维护的本地可运行队列。
  5. wakep 在有空闲 P 且需要更多执行者时尝试唤醒 M。
新 G 不是“创建后立刻运行”,而是先得到身份,再选择排队位置 ① go work()业务代码 ② newproc编译后的入口 空闲池有旧 G?本例:有 ③ 复用 G8少一次新分配 否则新分配G + 初始栈 是,本例否 ④ 写入 work 入口与父任务信息:G8 从 _Gdead → _Grunnable此刻只是“可以运行”,还没有获得 M + P P1.runnext 为空?本例:是 是:G8 放入 runnext本例走这里,优先候选 本地 256 槽已满?下标 0…255 否:写入 tail(队尾)槽队尾下标循环前进 1 格 是:runqputslow 批量进入全局128 个旧队列 G + 当前待入队 G代价:复制 129 项 + 获取全局锁 是,本例 否 否 是 ⑤ 入队后 wakep 判断“有空闲 P 且缺执行线程?”:是则唤醒 M;否则到此结束,不承诺 G8 立即运行。
最应记住:创建 G、把 G 变成可运行、让 G 真正运行是三个不同阶段;常见误读是看到 go work() 就认为 work 会抢在下一行之前执行。
注意:go f() 只保证 f 会被安排成一条 goroutine,不保证它立刻执行,更不保证先于下一行代码执行。不要把当前调度顺序当作程序正确性的前提。
四、P 去哪里找任务:快速路径、本地队列与全局队列

可运行的 G 不只放在一个队列里。分层队列让大多数操作在 P 本地完成,减少所有 P 争抢同一把全局锁;全局锁是保证同一时刻只有一个执行者修改共享队列的互斥机制。运行时同时保留全局队列处理溢出和跨 P 工作。

runnext:下一位

P 上一个特殊槽位。当前 G 刚唤醒的 G 可优先放这里,并继承当前剩余时间片,降低通信双方来回切换的延迟。

本地可运行队列

每个 P 都有一份环形队列,也就是用固定数组首尾循环复用槽位的等待队列;Go 1.26 源码中的容量为 256 个 G。绝大多数新任务和被唤醒任务先在本地流转。

全局可运行队列

由整个调度器共享。P 的本地队列满时会批量转移一部分任务到这里;从系统调用返回但拿不到 P 的 G 也可能进入这里。

本地队列满了为什么不是只挪一个

当前 runqputslow 会把本地队列中约一半任务连同新 G 批量放进全局队列。批量移动能减少频繁获取全局调度锁,也给其他 P 获得工作的机会。

schedule → findRunnable → execute

schedule 是一轮调度的主入口;findRunnable 负责寻找一个可以运行的 G;execute 把 G 切到运行态并真正恢复它。查找前还可能处理“停止世界”(stop the world,简称 STW,暂时停下业务 G 以完成全局协调)和垃圾回收(garbage collection,简称 GC,自动找出并回收不再使用的内存)等特殊工作。当前查找逻辑很复杂,下面是便于理解的主干,不应背成一条永远固定的严格顺序:

  1. 先处理 STW 协调、执行追踪和 GC 工作者等运行时特殊任务。
  2. 为了公平,每隔一定调度轮次检查全局队列;schedtick 是 P 已完成调度的轮次计数,Go 1.26.0 源码使用 schedtick % 61 == 0 判断这条检查路径。
  3. 优先从当前 P 的 runnext 和本地队列取 G。
  4. 检查全局队列、网络轮询结果和已经到期的计时器。
  5. 随机选择其他 P,尝试窃取可运行 G 和计时器工作。
  6. 确实没有工作时释放 P,让 M 自旋、休眠或阻塞等待网络事件。

下面固定一个例子走完整条线:P2 的 schedtick=62,所以 62 % 61 = 1,不满足公平检查条件;本地队列和全局队列也都为空,但网络轮询器刚返回 G9。蓝线是本例实际走的路径,绿色框是可能命中的出口,虚线是本例没有进入的出口。

findRunnable:每一次检查都有“命中返回 / 未命中继续” 本例:schedtick=62,62%61=1;P2 本地 0 个 G,全局 0 个 G,网络轮询器已有 G9。 schedule 请求下一条可运行 G 特殊工作现在必须运行?STW / GC / 执行追踪 62÷61 余数是 0且全局队列非空?本例:否 P2 的 runnext或本地 head 有 G? 普通检查:全局队列现在有 G? 网络事件或到期计时器返回可运行 G?本例:是,G9 否,本例继续否,本例继续否,本例继续否,本例继续 出口 A:运行特殊工作本例不走 出口 B:从全局取 G代价:访问共享队列与锁 出口 C:返回本地 G最快路径,本例为空 出口 D:从全局取 G代价:访问共享队列与锁 出口 E:返回 G9本例在这里结束查找 若这里仍未命中再随机选 P 窃取 窃取仍失败 → 再检查是否出现新工作 → 释放 P → M 停止自旋并休眠或等待网络;代价是跨 P 扫描与线程唤醒延迟。
最应记住:查找不是“本地空就立刻窃取”,每个候选来源都有命中出口;常见误读是把这张主干图背成语言规范,实际顺序和特殊分支会随运行时版本调整。
面试不要说成“每 61 次必然从全局拿一个”。这是当前源码为全局队列公平性设置的一条检查路径,前面还有垃圾回收、追踪等特殊分支;而且 61 是实现常量,不是 Go 语言规范。
五、自己的队列空了:任务窃取与自旋线程

任务窃取(work stealing)是空闲 P 从繁忙 P 的本地队列拿走一部分 G 的负载均衡方法。当前实现通常尝试拿走对方队列约一半任务,而不是每次只拿一个。图中的原子比较并交换表示一次不可被其他线程看到中间状态的“先确认旧值、再写入新值”操作,用来避免两个 P 重复拿走同一条 G。

从“4 个候选”算到“哪 2 个被拿走” 本例只画队列的逻辑区间;h 是从 0 开始循环使用的头下标,t 是下一个可写位置。 步骤 1|窃取前:P1 有 4 个候选,P2 为空 逻辑下标 h+0h+1h+2h+3 G1 G2 G3 G4 P1 头 h →← 尾 t=h+4 P2 队列0 个 G 步骤 2|计算并提交 候选数 n = t − h = 4 → 窃取数 = n − n÷2 = 4 − 2 = 2 选择头部完整集合 {h+0:G1, h+1:G2} → 原子比较并交换:P1 的 head 从 h 改为 h+2 代价:复制 2 个队列项+ 1 次原子竞争点 步骤 3|窃取后:结果能从图上数出来 P1 剩余:head=h+2,tail=h+4h+2:G3 h+3:G4 P2 本地队列G1(等待) 本次 findRunnable 返回G2(立即执行) 拥有 P1 的线程从 tail 入队,但从 head 出队;安全来自原子更新 head,不是“双方永远操作相反两端”。
最应记住:窃取“一半”要先从完整候选集合算出数量,再从被窃取 P 的 head(队头)一端提交;常见误读是以为两个 G 都进入 P2 队列,实际本例中 G2 会直接返回执行,G1 才留在队列。

为什么还要有“自旋”的 M

自旋(spinning)是 M 暂时不休眠,主动寻找工作。新 G 可能马上出现,如果所有空闲 M 都立刻睡死,频繁唤醒线程会增加延迟;但自旋 M 太多又会白白消耗 CPU,所以运行时保守控制自旋线程数量,并遵守“有工作时唤醒、没工作时逐步停下”的策略。

完全不自旋

省 CPU,但短任务突然到来时总要唤醒线程,响应变慢。

所有线程都自旋

任务到来时很快,但空闲期也会持续占用 CPU。运行时需要在延迟和资源之间折中。

六、G 阻塞了怎么办:三条完全不同的分流路径

阻塞表示当前任务暂时无法继续,例如在等 Channel 数据、等互斥锁、等网络响应或等操作系统完成调用。I/O 来自 Input/Output(输入/输出),这里主要指网络或文件读写。文件描述符是操作系统分配给已打开连接或文件的整数编号;cgo 是 Go 调用 C 语言代码的桥接机制。关键问题不是“G 是否阻塞”,而是“承载它的 M 是否也必须一起阻塞”。

阻塞类型G 怎么变M / P 怎么变典型例子
运行时可管理的同步等待G 进入 _GwaitingM 继续拿 P 执行别的 GChannel、sync.Mutex(互斥锁)、time.Sleep(定时休眠)
可被网络轮询器管理的 I/OG 等待文件描述符就绪M 和 P 通常不跟着阻塞网络连接读写、网页请求等待网络
可能阻塞线程的系统调用G 进入 _GsyscallM 可能卡在内核;P 可被交给其他 M某些文件操作、不可轮询设备调用、调用 C 代码
同样是“G 停下”,M 和 P 的去向可能完全不同 G1 正在 M1 + P1 上运行现在遇到等待 Channel / 锁 / 定时器G1 进入 waitingM1 和 P1 都不必停M1 + P1 执行 G2 可轮询的网络 I/OG1 交给网络轮询器等待M1 + P1 继续执行就绪后把 G1 唤醒 阻塞系统调用G1 + M1 可能卡在内核P1 与 M1 脱离M2 + P1 执行 G2 判断关键:运行时能否把“等待”从操作系统线程上拆开。
最应记住:Channel、锁和可轮询网络等待通常只停 G,阻塞系统调用却可能连 M 一起卡住;常见误读是把所有“I/O 等待”都说成绝不占线程,只有可接入网络轮询器的连接才走中间路径。

1. Channel 或锁等待:只停 G

当 Channel 没有可接收数据,运行时会把等待信息挂入队列,再通过 gopark 把当前 G 从运行状态切走。M 切到自己的 g0 执行调度,仍然拿着 P 去运行另一个 G。条件满足时,goready 把原 G 改回可运行状态并放回队列。

G1 在 M1 + P1 上执行
  ↓ 等 Channel
gopark(G1) → G1: waiting
  ↓
M1 + P1 转去执行 G2
  ↓ 发送方送来数据
goready(G1) → G1: runnable → 某次再次运行

2. 网络 I/O:把“等网卡”交给网络轮询器

网络轮询器(netpoller)是运行时对操作系统 I/O 事件机制的统一封装;Linux 常用 epoll,macOS 与 BSD 系统常用 kqueue,两者都是让一个线程同时等待许多连接状态变化的事件接口;Windows 使用作用相近的 I/O 完成端口。它负责等待“这个连接现在可以读或写了”,避免一条网络连接长期占住一条 M。

  1. G 发起网络读写,当前没有数据可处理。
  2. 运行时登记关注的读写事件,并把 G 停在 I/O 等待状态。
  3. M + P 继续执行其他 G。
  4. 操作系统报告文件描述符已就绪,网络轮询器取回对应 G。
  5. G 被标为可运行,回到调度队列等待执行。
不要过度概括:“I/O 阻塞绝不占线程”不准确。只有能接入运行时网络轮询器的文件描述符才走这条高效路径;普通阻塞文件调用、cgo 或特殊设备操作仍可能阻塞 M。

3. 阻塞系统调用:M 可以等,P 必须尽量继续工作

G 进入系统调用后与当前 M 一起进入内核,P 先进入“系统调用中”的状态。如果调用很快返回,M 可能继续拿回原 P;如果等待持续、同时还有其他可运行 G,sysmon 或其他调度路径可以接管这个 P,再寻找或创建另一条 M 执行工作。

系统调用返回后有 3 个出口,不是必然“原地继续” 步骤 1|G1 正在 M1 + P1 上运行执行 entersyscall 步骤 2|G1 + M1 在内核;P1 标为 _Psyscall此刻 P1 仍记录原 M,但可在需要时被接管 调用返回前,P1 已被接管?取决于等待时长与是否有工作 否:快速路径M1 重新取得原 P1出口 A:G1 直接恢复运行 否 是:P1 已被接管例如 M2 + P1 正在执行 G2代价:一次 P/M 交接 是 M1 取得其他空闲 P?先原 P,再尝试空闲 P 是:换一份 P出口 B:M1 + 新 P 执行 G1无需进入全局队列 否:所有 P 都忙出口 C:G1 进入全局可运行队列M1 停下;多一次全局锁与排队 是 否 本例走长等待路径:P1 被 M2 接管;若 M1 返回时没有空闲 P,则 G1 从出口 C 重新排队。
最应记住:阻塞系统调用会绑定 G 与 M,但不必长期占住 P;常见误读是认为 P 一进入系统调用就立刻转手,快速返回时原 M 仍可能直接取回原 P。

M1 返回用户态时会先尝试拿回原 P,再尝试空闲 P。拿不到时,G1 变回可运行并进入全局队列,M1 则停下等待;它不能绕过 P 直接执行普通 Go 代码。

七、一个 G 一直计算:sysmon 与抢占怎样让它让位

sysmon 来自 system monitor,是运行时的系统监控线程。它不需要 P 就能运行,会监控长时间执行的 G、停在系统调用中的 P、网络事件和计时器等。抢占(preemption)是调度器要求正在运行的 G 暂停,把执行机会让给其他任务。

当前源码用约 10 毫秒的 forcePreemptNS 作为检测长时间占用的实现阈值。它不是承诺“每个 G 精确运行 10 毫秒”,因为检测、信号送达、安全点和操作系统调度都会带来偏差。

同步安全点与异步安全点

安全点(safe point)是运行时能够可靠暂停 G,并正确识别栈和寄存器中指针的位置;寄存器是 CPU 当前保存少量计算数据和指令状态的高速存储位置。

同步安全点

运行中的 G 主动执行检查。运行时可修改栈边界值,让 G 在下一次函数入口的栈检查中转入抢占处理。

异步安全点

Go 1.14 引入基于操作系统信号的异步抢占;信号是操作系统通知线程处理紧急事件的一种机制。运行时暂停线程、确认当前位置安全,再把执行现场改造成进入 asyncPreempt 的样子。

asyncPreempt 保存寄存器后进入调度逻辑,原 G 从运行态回到可运行队列,以后从暂停位置继续。这改善了没有函数调用的长循环长期霸占 P 的问题。

异步抢占不是“到 10ms 强制切断”,而是请求、判断、再切换 ① sysmon 检测G1 持续运行约 10ms+阈值,不是精确时间片 ② 标记抢占请求preempt=true同步检查也能看到 ③ 通知 M1发送一次操作系统信号代价:信号处理 当前位置可安全抢占?栈、锁、运行时代码均允许 是|构造 asyncPreempt 调用现场保存寄存器 → 切回 g0 → G1 变为 runnable代价:保存/恢复现场 + 一次调度切换 是,本例 否|本次信号不切走 G1返回原指令继续执行,保留抢占请求等后续同步安全点或下一次安全机会 否,本例不走 ④ G1 回到可运行队列M1 + P1 可先执行等待中的 G2 以后再次选中 G1 → 恢复寄存器与栈 → 从暂停位置继续本例另一出口没有消失:若当时不安全,抢占请求会留到后续机会。
最应记住:约 10ms 只触发“该请求抢占了”的判断,能否立刻切走还要经过安全性检查;常见误读是把它说成每个 G 都拥有精确 10ms 时间片。
抢占不是任意指令处粗暴切断。运行时代码、汇编(接近机器指令的底层代码)、持有关键锁或栈空间不足等位置可能不适合异步抢占;运行时必须先判断当前位置是否安全。
八、GOMAXPROCS:限制的是并行执行,不是 goroutine 总数

GOMAXPROCS 的名字来自“Go maximum processors”,它告诉运行时允许多少个 P,也就是最多允许多少条线程同时执行普通 Go 代码。它不限制 G 的总数,也不限制因系统调用而阻塞的 M 数量。

不是 G 的上限

即使 GOMAXPROCS=2,也可以创建十万条 G;它们只是在等待、排队和运行之间切换。

不是 M 的硬上限

如果许多线程阻塞在系统调用里,运行时仍可能创建更多 M,保证有线程拿 P 执行 Go 代码。

是 Go 代码并行度

GOMAXPROCS=2 时,最多两条线程同时执行普通 Go 代码。

Go 1.25 的容器默认值变化

语言版本为 Go 1.24 及之前时,默认值主要是进程可见的逻辑 CPU 数。模块在 go.mod 中声明 Go 1.25 或更高版本后,如果程序没有用 GOMAXPROCS 环境变量、runtime.GOMAXPROCS 函数或相关 GODEBUG 开关覆盖默认行为,Linux 运行时会综合逻辑 CPU 数、CPU 亲和性和容器限额选择默认值,并周期性检查变化。CPU 亲和性是操作系统限制进程可以在哪些 CPU 上运行的规则;cgroup 是 Linux 用来限制和统计一组进程资源的内核机制,这里负责表达容器 CPU 配额。

工程判断:不要机械地把 GOMAXPROCS 调到 goroutine 数,也不要默认“越大越快”。CPU 密集任务超过有效 CPU 并行度后,通常只会增加线程竞争和切换;I/O 密集服务可以有很多 G,但同时执行 Go 代码的 P 仍应贴近实际 CPU 资源。
九、源码主线:从 go 语句一路跟到真正执行

第一次读调度器源码不要从几千行的 proc.go 顶部顺序往下读。按一条 G 的生命周期追调用链,更容易建立完整地图。

要追的问题关键函数 / 结构看懂什么就可以停
G 怎样创建newproc → newproc1复用或分配 G、准备栈和入口、转为可运行
G 放到哪里runqput → runqputslowrunnext、本地 256 槽队列、满时批量进全局
下一条 G 怎么找schedule → findRunnable本地、全局、网络、计时器、窃取等来源
G 怎样真正运行execute → gogo关联 G/M、切换状态、恢复保存的执行现场
G 怎样主动停下gopark → park_m从 running 变 waiting,切回 g0 调度
G 怎样被唤醒goready → ready → runqput从 waiting 变 runnable,重新排队
系统调用怎样交接 Pentersyscall / exitsyscall进入调用、P 被接手、返回后重新申请 P
长任务怎样让位sysmon → retake → preemptone发现长时间运行或系统调用,发起抢占或回收 P

最小伪代码:把主循环压缩成十行

func schedule() {
    for {
        gp := findRunnable() // 可能阻塞,直到找到工作
        execute(gp)       // 切到 gp;以后从 g0 回到这里
    }
}

func findRunnable() *g {
    // 伪代码:特殊任务 → 公平检查全局 → 本地 → 全局
    //       → 网络/计时器 → 从其他 P 窃取 → 休眠等待
}
源码阅读抓手:每看到一个函数,先问三件事:当前运行在业务 G 还是 g0?当前 M 有没有 P?目标 G 的状态从什么变成什么?这三问比背函数细节更不容易迷路。
十、常见面试追问:回答到机制,不背口号
1. GMP 分别是什么?
G 是 goroutine 的任务与执行现场;M 是操作系统线程;P 是执行 Go 代码所需的资格和本地资源。调度器的目标是用有限的 P 和可复用的 M 执行大量 G。
2. 为什么有 M 还要 P?
P 把本地运行队列、分配缓存等高频资源按并行单元拆开,减少全局竞争;同时用 GOMAXPROCS 个 P 明确限制普通 Go 代码的并行度。阻塞系统调用时,M 可以留下等待,P 转交给其他 M。
3. goroutine 比线程轻在哪里?
G 由用户态运行时管理;用户态表示工作主要在普通进程内部完成,不必每次进入操作系统内核。它不是一 G 一线程,栈可以按需增长,大量 G 能复用较少 M;G 之间切换通常由 Go 运行时完成,不必每次都让操作系统在线程之间切换。
4. 新建 G 一定放本地队列尾部吗?
不一定。当前 newproc 会以 next=true 调用 runqput,优先尝试当前 P 的 runnext;runnext 已占用时,原来的或新的 G 会落入本地队列。队列满时再批量进入全局队列。
5. P 的本地队列空了怎么办?
调度器还会查全局队列、网络轮询结果和计时器,并随机选择其他 P 做任务窃取;确实没有工作后才释放 P,让 M 停止自旋并休眠。
6. 为什么本地队列能提高性能?
大多数取放只由当前 P 操作,不必让所有线程频繁竞争一把全局锁;任务和相关数据也更可能继续留在同一处理器缓存附近。
7. Channel 阻塞和系统调用阻塞有什么不同?
Channel 阻塞通常只 park 当前 G,M + P 立刻执行其他 G;阻塞系统调用可能把 M 一起卡在内核,因此运行时要把 P 交给其他 M。网络 I/O 如果能接入 netpoller,通常也只停 G。
8. Go 如何避免一个死循环饿死其他 G?
sysmon 发现 G 长时间占用 P 后请求抢占;现代 Go 支持信号辅助的异步抢占,在安全点保存现场,把 G 放回可运行队列。当前 10ms 是检测时间片的实现值,不是精确执行承诺。
9. GOMAXPROCS=1 还有并发吗?
有并发,但没有普通 Go 代码的多 P 并行。多个 G 仍会因阻塞、唤醒和抢占轮流推进;阻塞在系统调用里的线程也不计入这个并行度上限。
10. goroutine 越多吞吐越高吗?
吞吐是单位时间内完成的任务数量;goroutine 越多不代表吞吐越高。G 仍占栈、调度记录和业务资源;过多 G 会放大队列等待、内存、垃圾回收、锁竞争与下游压力。生产系统通常还需要并发上限、背压(上游在下游处理不过来时主动减速)和超时。
十一、代码里最容易写错的调度假设

依赖 goroutine 启动顺序

go f() 后立刻读取 f 要写的数据会产生数据竞态,也就是多个任务在没有可靠同步的情况下同时读写同一份数据。应该用 Channel、锁或 WaitGroup(等待一组任务全部完成的同步工具)建立先后关系。

用 Sleep 等任务完成

机器快慢和调度时机都会变化。睡 100ms 不是完成协议;测试偶尔通过并不代表正确。

无限制创建 G

每个请求再开很多 G 可能把数据库连接、内存或下游服务压垮。可用 Worker Pool(固定数量工作者反复取任务的工作池)、信号量(用有限令牌约束同时进入任务数的工具)或批处理控制并发。

把并发问题归咎于调度器

数据竞争、死锁和 goroutine 泄漏首先是同步与生命周期设计问题;死锁表示一组任务互相等待,谁也不能继续,goroutine 泄漏则表示任务永久卡住或失去退出路径,长期占用内存等资源。调度顺序不确定只会让错误更容易暴露。

随意调大 GOMAXPROCS

CPU 资源不变时,更多 P 可能增加线程切换、共享锁竞争和容器节流。先用指标和压测证明瓶颈。

看到 G 多就判断泄漏

数量高不等于泄漏。要结合是否持续单调增长、等待原因、业务负载和 goroutine profile(记录所有 G 及其等待位置的诊断快照)中的创建栈判断。

十二、可运行实验:亲眼看见并发、并行与调度事件

普通计数器无法证明“同一时刻有几个 G 真正在执行”:G 被抢占后虽然不占 CPU,函数却还没有结束,计数很容易把“尚未完成”误当成“正在并行”。下面改用 Go 自带的执行追踪记录真实时间线。

实验 1:生成可以查看的调度时间线
package main

import (
    "fmt"
    "os"
    "runtime"
    "runtime/trace"
    "sync"
)

func cpuWork(seed uint64) uint64 {
    x := seed
    for i := 0; i < 12_000_000; i++ {
        x = x*1_664_525 + 1_013_904_223
    }
    return x
}

func main() {
    runtime.GOMAXPROCS(2)
    f, err := os.Create("trace.out")
    if err != nil { panic(err) }
    if err := trace.Start(f); err != nil { panic(err) }

    var wg sync.WaitGroup
    results := make(chan uint64, 8)

    for i := 0; i < 8; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            results <- cpuWork(uint64(id + 1))
        }(i)
    }
    wg.Wait()
    close(results)
    var checksum uint64 // 合并结果,保证计算结果确实被使用
    for result := range results { checksum ^= result }

    trace.Stop()
    f.Close()
    fmt.Println("计算校验值:", checksum)
}

执行追踪(execution trace)是运行时记录 G 何时运行、等待和被唤醒的时间线。代码中的 checksum 是把八次计算结果合并成一个校验值,确保这批计算的结果真的被程序使用。先用 GOMAXPROCS=1 运行,再改成 2;在追踪页面的处理器时间线中,可以直接观察同一时刻运行普通 Go 代码的数量,而不是用函数开始和结束之间的计数猜测并行度。操作系统负载会影响总耗时,但不会改变 GOMAXPROCS 的并行度上限语义。

实验 2:打开追踪并查看调度摘要
go run main.go
go tool trace trace.out

# 每 1000ms 打印一次调度器摘要
GODEBUG=schedtrace=1000,scheddetail=1 go run main.go

go tool trace 是 Go 自带的执行追踪查看器;GODEBUG 是控制运行时诊断行为的环境变量,schedtrace 会把 P、M、运行队列等摘要打印到标准错误输出。

排查顺序:先确认业务现象,再选工具。CPU profile(处理器采样报告,用来定位最耗计算时间的代码)适合查计算热点;请求延迟里大量等待、并行度不足或调度切换异常再看 trace;怀疑 goroutine 泄漏则看 goroutine profile(记录所有 G 及其等待位置的快照)和等待栈。
十三、最后一分钟速记:面试时这样完整回答
大量 G 进入队列,M 必须拿到 P 才能执行 Go 代码;阻塞时尽量只停 G,空闲 P 还能从别处找工作。
  1. 角色:G 是 goroutine,M 是操作系统线程,P 是执行权和本地调度资源。
  2. 并行度:P 数量等于 GOMAXPROCS,限制同时执行普通 Go 代码的线程数。
  3. 队列:每个 P 有 runnext 和本地队列,另有共享全局队列;本地队列当前是 256 槽。
  4. 找工作:兼顾本地、全局、网络、计时器和其他 P;空闲 P 会做 work stealing。
  5. 普通等待:Channel、锁等通常只 park G,M + P 继续运行其他 G。
  6. 网络等待:可轮询连接交给 netpoller,事件就绪后再把 G 变回 runnable。
  7. 系统调用:M 可能阻塞,但 P 可脱离并交给另一条 M;返回的 G 重新申请 P。
  8. 公平性:sysmon 监控长时间运行的 G 和 syscall 中的 P,现代 Go 还支持异步抢占。
可直接背的 40 秒回答:Go 用 GMP 做用户态调度。G 保存 goroutine 的任务和现场,M 对应操作系统线程,P 保存本地运行队列、分配缓存等执行资源;M 必须拿到 P 才能执行普通 Go 代码。新 G 优先进入当前 P 的 runnext 或本地队列,本地满会批量转全局,空闲 P 还能从其他 P 窃取任务。Channel、锁和可轮询网络 I/O 通常只阻塞 G;阻塞系统调用会卡住 M,但 P 可以转交给其他 M。P 的数量由 GOMAXPROCS 决定,sysmon 和抢占机制负责避免长任务长期霸占执行权。
十四、应用场景:理解调度器后,工程决策怎样改变

GMP 不是业务代码要直接调用的框架,它的价值是帮助你判断并发上限、阻塞类型、性能工具和故障原因。下面只保留最常见的落地场景。

高并发网络服务

什么时候用:每个请求包含网络等待,例如 API(Application Programming Interface,应用程序接口)网关、爬虫或微服务聚合。

为什么适合:大量 G 可以用直观的同步写法表达请求流程;等待可轮询网络时只 park(暂停并移出运行状态)G,M + P 继续处理别的请求。

注意:调度器解决“谁来运行”,不解决下游容量。仍要设置超时、连接池、限流和取消。

CPU 密集并行计算

什么时候用:图片处理、压缩、解析、大批量纯计算。

为什么适合:把任务拆成有限 worker,让多个 P 并行执行。

注意:worker 数通常围绕有效 CPU 并行度压测,不要为每个数据项无限开 G;任务过细时调度和通信成本会吞掉收益。

定位延迟与泄漏

什么时候用:吞吐下降、尾延迟(最慢那一小部分请求的耗时)变高、G 数持续上涨或 CPU 没吃满。

为什么适合:trace 能区分运行、网络等待、同步阻塞和系统调用;goroutine profile 能看到 G 停在哪里。

注意:先建立指标基线,诊断工具本身也有开销。

容器 CPU 配置

什么时候用:服务运行在 Kubernetes(自动部署和管理容器的编排平台)等环境。

为什么适合:理解 P 与容器 CPU 限额的关系,能解释过高并行度触发容器节流,也就是进程用完当前周期的 CPU 配额后被暂时停下,从而抬高尾延迟。

注意:模块语言版本达到 Go 1.25+ 时默认会感知限额;显式设置 GOMAXPROCS 会关闭自动更新,GODEBUG 也可以改变行为。

什么时候不要“调度器式优化”

  • 数据竞争应先补同步关系,不是调用 Gosched(让当前 G 主动交出执行机会)碰运气。
  • 下游数据库扛不住时应限制并发,不是增加 goroutine。
  • 锁竞争严重时应缩短临界区(持锁访问共享数据的代码范围)或重构共享状态,不是只调大 P。

面试中的业务化表达

“在聚合多个下游接口时,我会为独立请求创建 goroutine,因为等待网络时运行时可通过 netpoller 只挂起 G;但我仍会用并发上限保护连接池和下游。若是 CPU 密集任务,我会按容器有效 CPU 和 GOMAXPROCS 设计有限 worker,再用压测确认最佳并行度。”

十五、资料依据与版本说明

本文把稳定概念与当前实现细节分开表述。G/M/P 的职责来自官方运行时文档;256 槽本地队列、每 61 轮公平检查、约 10ms 抢占检测、函数与字段名按 Go 1.26.0 源码核对,后续补丁版或大版本仍可能调整。