Go语言中GMP调度模型详解
如果说 Goroutine 是 Go 并发的"工人",Channel 是"传送带",那 GMP 就是整个工厂的调度指挥中心。
一、Go 语言中的 GMP 模型是什么?
1.1 三个字母的本质
GMP 是 Go Runtime 设计的三级调度抽象:
| 字母 | 全称 | 本质 | 生活比喻 |
|---|---|---|---|
| G | Goroutine | 用户任务,待执行的函数 | 工人(带着图纸和工具,等待派活) |
| M | Machine | OS 线程(内核线程),真正跑代码的执行引擎 | 机床/工位(有电、有马达,能干活) |
| P | Processor | 逻辑处理器,包含调度上下文和本地队列 | 车间主任(手里有本地任务清单、工具箱、材料柜) |
1.2 三者如何协作?(核心关系)
就是这个模型让Go能在少量线程上调度海量goroutine,是Go⾼并发的基础。

铁律:
- G 想执行,必须先找到 P。没有 P 的 G 只能去全局队列排队。
- P 想干活,必须先绑定 M。P 是逻辑概念,不能自己跑代码,必须寄生在 M 上。
- M 可以没有 P(比如执行系统调用时),但此时它不能执行用户 G。
1.3 为什么需要三层?不是两层就行了吗?
P 是性能关键。
如果没有 P,直接让 G 和 M 绑定:
- M1 绑定了 G1,G1 去 Channel 阻塞了 → M1 这个内核线程也跟着阻塞。
- 操作系统调度器会把 M1 切走,上下文切换开销巨大。
- 所有 G 竞争一个全局队列,锁争用惨烈。
有了 P 之后:
- G1 阻塞在 Channel,只是 G1 被挂起,M 可以立刻切换到 g0(后面讲),然后绑定 P 去执行别的 G。
- P 的本地队列让大部分调度无需加锁(无锁环形队列)。
二、什么是 Go Scheduler?
2.1 本质定义
Go Scheduler 是 Go Runtime 内部实现的一个用户态线程调度器(User-space Scheduler),运行在用户空间,与操作系统内核调度器(如 Linux CFS)是上下级关系。
它不是操作系统的某个组件,而是编译进你程序里的 runtime 包代码(主要在 runtime/proc.go、runtime/runtime2.go 中)。
2.2 它到底在管什么?
它只管理一件事:哪个 G(Goroutine)应该在哪个 M(OS 线程)上执行。
操作系统内核调度器管理的是进程/线程(M),它根本不知道 G 的存在。Go Scheduler 夹在操作系统和你的业务代码之间,扮演"中间商":
2.3 为什么需要它?直接用操作系统线程不行吗?
核心原因:OS 线程太重,Goroutine 太轻。
| 对比项 | OS 线程(Linux pthread) | Goroutine |
|---|---|---|
| 栈大小 | 默认 1~8 MB(固定或分页增长) | 默认 2 KB(可动态增长) |
| 上下文切换 | 1~2 微秒(进内核,保存/恢复大量寄存器,刷新 TLB) | ~200 纳秒(用户态,只改几个寄存器) |
| 创建销毁开销 | 大(需内核参与,分配大量资源) | 极小(用户态分配,约 ~2KB 内存) |
| 单机并发量 | 通常几千个就是极限 | 轻松几十万甚至上百万 |
如果没有 Go Scheduler,一个 Goroutine 绑定一个 OS 线程:
- 100 万个 Goroutine = 100 万个线程。
- 操作系统直接崩溃,内存不够,调度器本身也卡死。
Go Scheduler 通过 M:N 模型(少量 M 承载大量 G),把 Goroutine 的调度成本压到极低。
2.4 Scheduler 的核心入口函数
Go 程序里每个 M 都在执行一个永不退出的调度循环:
// runtime/proc.go 中的核心函数
func schedule() {
// 1. 找下一个可运行的 G
gp := findRunnable()
// 2. 执行它
execute(gp)
// 3. G 结束后,回到这里,继续循环
}这个 schedule() 函数就是 Go Scheduler 的心脏。你的程序里每个 M(除了极少数特殊情况)都在死循环地调用它。
三、Go 调度策略是什么?
3.1 大前提:M:N 调度 + 两级队列
Go 的调度器采用的是 M:N 模型:
- M 个 OS 线程,调度 N 个 Goroutine(N >> M)。
- 调度队列分两级:P 的本地无锁队列(Local Runq)+ 全局队列(Global Runq)。
3.2 抢占策略的演进
Go 的调度不是纯粹的协作式,也不是纯粹的操作系统抢占式,而是"以协作为主,强制抢为辅"的混合策略。=
① Go 1.14 之前:协作式抢占(Cooperative Preemption)
原理: 编译器在几乎所有函数调用的入口处(函数序言,Function Prologue),插入一段检查代码。这段代码会检查当前 G 是否被标记为需要让出 CPU。
源码层面的实现: 每个 G 结构体里有一个字段叫 stackguard0。当 sysmon 认为某个 G 运行太久了,就把它的 stackguard0 设置为一个特殊值 stackpreempt。
当 G 执行到下一个函数调用时,会检查栈空间:
// 伪代码,示意函数序言的检查逻辑
if sp < stackguard0 { // 正常是检查栈溢出
if stackguard0 == stackpreempt {
// 不是栈溢出,是被要求抢占了!
goschedImpl() // 主动让出 CPU
}
}致命缺陷: 如果某个 Goroutine 写了一个纯死循环,且循环体里没有任何函数调用:
for {}那么它永远不会走到函数序言的检查点,preempt 标志永远得不到检查。这个 G 会永久霸占所在的 M,同一个 P 本地队列里的其他 G 全部饿死。
② Go 1.14 之后:基于信号的异步抢占(Asynchronous Preemption)
Go 1.14 引入了新机制,解决了"无函数调用的死循环"问题。
完整流程:
Step 1:Sysmon 检测
sysmon是一个特殊的 M(由 Runtime 启动,不绑定 P),它死循环运行。- 它定期检查每个 P 的
sysmontick记录。 - 如果发现某个 G 在同一个 M 上运行超过 10ms,判定为"需要抢占"。
Step 2:发送信号
sysmon向目标 M 发送一个 Unix 信号SIGURG(紧急信号,用户程序通常不用)。- 注意:不是直接操作 G,而是给 M 发信号。
Step 3:M 的信号处理
- 每个 M 内部都有一个专门的信号处理 Goroutine,叫做
gsignal。 - M 收到
SIGURG后,由gsignal接手处理(在信号上下文执行)。 gsignal会设置目标 G 的抢占标志,并记录"异步抢占请求"。
Step 4:安全点抢占
- 信号处理程序不会直接打断正在运行的 G(那样太危险,可能正在修改共享状态)。
- 它会在 G 到达下一个安全点(Safe Point)时触发抢占。安全点包括:
- 函数调用入口
- 循环的 backedge(循环边界)
- 某些特定的指令序列
- 在安全点,Runtime 检查抢占标志,然后执行和之前类似的
goschedImpl(),把 G 放回队列。
关键结论:
- 1.14 之前:只有函数调用时才能被抢占。
- 1.14 之后:即使无函数调用,信号机制也能强制你在安全点停下。但注意,它仍然不是"随时打断",而是"在安全点检查并打断"。
四、发生调度的时机有哪些?
调度不是随时发生的,而是在特定时机触发。分为主动、被动、抢占三类:
4.1 主动调度(Goroutine 自己让出)
| 场景 | 函数 | 说明 |
|---|---|---|
| 显式让出 | runtime.Gosched() | G 主动说"我先歇会儿" |
| park | gopark() | Channel 阻塞、sleep、wait 等,G 挂起 |
| 结束 | G 的函数 return | G 执行完了,M 必须找新 G |
4.2 被动调度(Goroutine 被迫让出)
| 场景 | 说明 |
|---|---|
| 系统调用 | G 调用 read/write 等,M 进入内核态,G 和 M 解绑,P 找新 M |
| Channel 阻塞 | 无缓冲 Channel 没配对,G 挂到队列,M 切换 |
| 锁阻塞 | sync.Mutex 拿不到,G 进等待队列 |
| GC | 触发 GC 时,所有 G 必须暂停(STW),调度器介入 |
| 栈增长 | G 的栈不够用了,切换到 g0 分配新栈 |
4.3 抢占式调度(Go 1.14+ 的关键改进)
以前 Go 是协作式调度,如果 G 里写了个死循环且不调用任何函数,它会永远占着 CPU。
Go 1.14 引入了基于信号的抢占:
- 系统监控线程(Sysmon)每 10ms 检查一次。
- 发现某个 G 运行太久,就发信号(
SIGURG)给对应的 M。 - M 收到信号后,在函数调用序言(prologue)处检查抢占标志,主动让出。
五、M 寻找可运行 G 的过程是怎样的?
这是 Go Scheduler 最核心的算法,我必须严格按照 runtime/proc.go 中 findrunnable() 的源码逻辑,给你分步骤拆解。
总览
当 M 上的当前 G 执行完毕(或阻塞)后,M 会调用 schedule(),进而调用 findrunnable() 来找下一个活干。
Step 1:检查 P 的 runnext(最快路径)
if gp := pp.runnext; gp != nil {
pp.runnext = nil
return gp
}每个 P 有一个单缓冲槽位叫 runnext。如果某个 G 被标记为"下一个优先执行"(比如刚被唤醒的高优先级 G),直接拿走。无锁,最快。
Step 2:从 P 的本地队列取(runqget)
if gp, inheritTime := runqget(_p_); gp != nil {
return gp, inheritTime
}P 的 runq 是一个无锁环形数组队列(长度 256)。M 优先从这里取 G。这也是无锁操作,性能极高。
Step 3:从全局队列取(globrunqget)
if gp := globrunqget(_p_, max); gp != nil {
return gp
}本地队列空了。M 去全局队列 sched.runq 取一批 G。
- 取多少个?
n = min(len(globalRunq), max),通常一次取 1 个或一批(具体算法会取一半,但上限约 128 个)。 - 为什么取一批而不是一个?减少全局锁竞争。
- 这批 G 被放到 P 的本地队列,然后执行第一个。
Step 4:检查 Netpoller(网络轮询器)
if gp := netpoll(0); gp != nil {
return gp
}看看有没有因为网络 I/O 阻塞、但底层 fd 已经就绪的 G(比如 socket 收到数据了)。这是一个非阻塞调用。
Step 5:Work Stealing(工作窃取)
// 尝试 4 轮
for i := 0; i < 4; i++ {
// 随机选择其他 P
for _, p2 := range stealOrder {
if gp := runqsteal(_p_, p2); gp != nil {
return gp
}
}
}- M 随机挑选其他 P,尝试从它们的本地队列尾部偷一半任务过来。
- 偷取是批量的:把 victim P 队列里后半部分的 G 一次性搬到当前 P 的队列。
- 尝试 4 轮还偷不到,放弃。
Step 6:再次检查全局队列和 Netpoller
偷了一圈回来,可能全局队列有新任务了,或者网络包到了。再检查一次。
Step 7:GC 后台任务
if gp := gcBgMarkWorker(); gp != nil {
return gp
}实在没用户任务了,M 可以帮 GC 做后台标记工作(并发 GC 的一部分)。
Step 8:真的没有了,M 进入休眠(stopm)
stopm()
- M 和 P 解绑(P 被放回全局
pidle空闲列表)。 - M 自己被放入全局
midle空闲列表。 - 调用操作系统的线程休眠原语(如
futex/WaitForSingleObject),进入睡眠状态。 - 等待将来有任务时,被
startm()或wakep()叫醒。

六、GMP 能不能去掉 P 层?去掉会怎么样?
6.1 结论:绝对不能去掉
如果你把 P 层抽掉,只剩下 G 和 M 两层,Go 的 Runtime 会在调度性能和内存分配性能上同时崩塌。P 层存在的意义,远不止"一个中间层"这么简单。
6.2 原因一:本地无锁队列(Local Runq)
这是 P 层最核心的功能。
有 P 时的场景:
- 每个 P 有一个
runq[256]的环形数组。 - M 从 P 的本地队列取 G 时,不需要任何锁(无锁环形队列,通过原子操作实现)。
- 4 个 P 就有 4 个独立队列,4 个 M 可以同时取 G,互不干扰。
去掉 P 后的场景:
- 所有 G 只能挤在一个全局队列里。
- 任何 M 想取 G,都必须先抢一把全局大锁。
- 4 个 M 同时取 G → 4 个线程抢同一把锁 → 缓存一致性流量爆炸 → CPU 大量时间花在自旋等待上。
- 实测:高并发下,调度器本身会吃掉 30%~50% 的 CPU 时间。
6.3 原因二:mcache(内存分配无锁)
这是很多人忽略的一点。P 层不仅管调度,还管内存分配。
Go 的内存分配器采用 TCMalloc 模型,分为三级:
G ──► mcache(P 本地)──► mcentral(全局,按 span class 分)──► mheap(全局,向 OS 申请)
mcache 是什么?
- 每个 P 自带一个
mcache,里面缓存了各种规格(size class)的内存块(span)。 - 当 G 申请小对象(
< 32KB)时,直接从 P 的mcache拿,不需要加锁。 - 只有当
mcache空了,才会去全局的mcentral补充(这时才需要锁)。
去掉 P 后的场景:
- 所有 G 申请内存都直接走全局
mcentral或mheap。 - 100 万个 G 同时抢内存分配锁 → 程序直接卡死。
- 有 P 时,99% 的内存分配是无锁的。
6.4 原因三:调度上下文缓存
P 保存了一个 G 运行所需的完整上下文环境:
mcache:内存分配缓存。pctrace:性能分析采样状态。defer pool:defer 结构体缓存池,减少重复分配。sudog cache:Channel 操作时sudog对象的缓存池。writebuf:GC 写屏障缓冲区。
这意味着: 当 G1 在 P 上阻塞后,P 可以立刻把 G1 的上下文冻结,然后执行 G2。G1 恢复时,P 上的缓存还在,无需重建。
去掉 P 后: 每次切换 G,都要重新初始化内存分配环境、重新分配 defer/sudog 缓存,开销巨大。
七、P 和 M 在什么时候会被创建?
7.1 P 的创建时机
P 是程序启动时一次性创建的。
func main() {
runtime.GOMAXPROCS(4) // 默认等于 CPU 核心数
}具体流程:
- Go 程序启动,执行
runtime.rt0_go。 - 调用
schedinit(),其中读取环境变量$GOMAXPROCS或调用runtime.GOMAXPROCS(n)。 - 调用
procresize(n),创建 n 个 P。 - 这些 P 被放入全局
allp数组,同时大部分被放入 空闲池pidle。
P 的数量是固定的吗?
- 运行时可以动态调整(
runtime.GOMAXPROCS(newN)),但极少这么做。 - 调整时,多余的 P 会被剥离(上面的 G 转移到其他 P),然后放入
pidle;不足的 P 从pidle取出或新建。
pidle 是什么?
pidle是全局的 P 空闲链表(singly-linked list)。- 当 M 进入休眠(
stopm)时,它绑定的 P 会被解绑,放入pidle。 - 当有新任务时,
wakep()或startm()从pidle取一个 P,绑定到 M 上。
7.2 M 的创建时机
M 是 OS 线程,创建成本较高,Go 会尽量复用。M 是动态创建的。
创建 M 的入口是 newm(),触发场景:
场景 1:程序启动时
创建第一个 M,即 m0(主线程)。
场景 2:有 G 要执行,但没有空闲 M
// 当需要执行 G 时,调用 startm()
func startm(_p_ *p, spinning bool) {
// 1. 先尝试从 midle(M 空闲列表)拿一个休眠的 M
mp := mget()
if mp == nil {
// 2. 没有空闲 M,新建一个!
newm(fn, _p_, spinning)
return
}
// ...绑定 P,唤醒 M
}场景 3:G 进入系统调用,当前 M 阻塞
当 G 执行 read() / write() 等系统调用时:
- 当前 M 进入内核态,可能长时间阻塞。
- P 和 M 解绑(P 不能跟着 M 一起睡)。
- P 需要找新的 M 来继续执行其他 G。
- 如果
midle里没有空闲 M,就调用newm()新建一个。
场景 4:GC 并行标记需要
GC 的并发标记阶段可能需要额外的 M 并行工作。
midle 是什么?
midle是全局的 M 空闲链表。- M 没活干时(
stopm),不会被操作系统销毁,而是放入midle睡觉。 - 下次需要 M 时,优先从
midle唤醒,避免频繁创建/销毁 OS 线程。
八、m0 是什么?有什么用?
8.1 m0 的定义
m0 是 Go 程序启动时,由操作系统创建的第一个 OS 线程(主线程)。 它是 Go Runtime 的"盘古",一切从这里开始。
8.2 m0 的特殊身世
普通 M 是 Go Runtime 自己调用操作系统 API(如 clone / CreateThread)创建的,但 m0 不是——它是操作系统启动你的程序时,就已经存在的那条线程。
操作系统加载可执行文件 │ ▼ 创建进程 + 主线程 │ ▼ 进入 _rt0_amd64_linux(或对应平台的入口) │ ▼ 这条主线程,就是 m0
8.3 m0 的不可替代职责
| 阶段 | m0 做什么 |
|---|---|
| Runtime 初始化 | 执行 runtime.rt0_go,设置栈、初始化内存分配器、初始化调度器 |
| 创建 G0 和 main goroutine | 创建第一个 G(不是 g0,是执行 main.main 的那个 G) |
| 进入调度循环 | m0 也调用 mstart() → schedule(),和普通 M 一样干活 |
| 兜底 | 如果所有其他 M 都阻塞在系统调用里,至少 m0 还在运行,保证程序不死 |
8.4 m0 与普通 M 的区别
| 特性 | m0 | 普通 M |
|---|---|---|
| 创建者 | 操作系统 | Go Runtime (newm) |
| g0 栈 | 使用操作系统分配的初始栈 | 使用 Runtime 在堆上分配的栈 |
| 生命周期 | 与进程同生共死 | 可以休眠复用,理论上可销毁(实际很少) |
| 数量 | 全局只有 1 个 | 可以有多个 |

九、g0 是一个怎样的协程?有什么用?
9.1 g0 的定义
每个 M 都有一个专属的 g0,它是一个"系统 Goroutine",不执行用户代码,只执行 Runtime 代码。
9.2 g0 与普通 G 的本质区别
| 特性 | 普通 G | g0 |
|---|---|---|
| 栈 | 2KB 起步,动态增长(通常几 KB 到几 MB) | 较大(通常 8KB+),固定大小,系统栈 |
| 执行内容 | 用户写的函数 | Runtime 内部函数(schedule、GC、syscall 封装) |
| 调度行为 | 被调度(被动等待 M 执行) | 主动调度别人(执行 schedule() 决定下一个跑谁) |
| 数量 | 用户创建,无数多个 | 每个 M 严格只有 1 个 |
9.3 g0 的三大核心作用
作用 1:调度中转站(最重要的作用)
当 M 上的普通 G1 需要让出 CPU 时,必须切换到 g0,由 g0 执行调度逻辑。
G1 正在执行用户代码 │ ▼ G1 阻塞(Channel / Sleep / 系统调用) │ ▼ 保存 G1 的上下文(SP, PC, BP)到 G1.gobuf │ ▼ 【切换到 g0 栈】← 关键! │ ▼ g0 执行 schedule() → findrunnable() → 找到 G2 │ ▼ 恢复 G2 的上下文 │ ▼ 【从 g0 切换回 G2】 │ ▼ G2 开始执行
为什么必须切到 g0? 因为 schedule() 本身是 Runtime 函数,它如果跑在普通 G 的栈上,可能会污染用户栈(比如占用太多栈空间、修改栈布局)。g0 有独立的、足够大的系统栈,可以安全地执行复杂的调度逻辑。
作用 2:系统调用的代理
普通 G 执行 read() / write() 时:
- 切换到 g0 栈。
- g0 调用
entersyscall(),保存状态,把 G 标记为_Gsyscall。 - g0 执行真正的系统调用。
- 系统调用返回后,g0 调用
exitsyscall(),尝试重新获取 P,恢复用户 G。
作用 3:栈增长管理
当普通 G 的栈不够用时,会触发 morestack。这个函数运行在 g0 栈上,负责分配更大的栈空间,拷贝旧栈数据,然后切回用户 G。
十、Go 栈和用户栈是如何进行切换的?
10.1 两种栈的明确区分
| 栈 | 归属 | 用途 | 大小 |
|---|---|---|---|
| 用户栈(G 栈) | 每个普通 G 独有 | 执行用户函数、局部变量、defer、panic recover | 2KB 起步,可动态增长 |
| 系统栈(g0 栈) | 每个 M 的 g0 独有 | 执行 Runtime 代码、调度、系统调用 | 较大,固定 |
10.2 切换的核心数据结构:gobuf
Go 在 G 结构体里保存了切换所需的全部寄存器状态:
type gobuf struct {
sp uintptr // Stack Pointer:栈顶指针
pc uintptr // Program Counter:下一条要执行的指令地址
g uintptr // 指向当前 G 的指针(恢复时知道切到哪个 G)
bp uintptr // Frame Pointer:帧指针/基址指针
ret uintptr // 返回值(某些架构用)
lr uintptr // Link Register:返回地址(ARM 等架构用)
}10.3 完整切换过程(以 G1 阻塞、切换到 G2 为例)
阶段 A:保存 G1 的现场(G1 → g0)
发生时机: G1 执行到 Channel 发送/接收、Sleep、锁阻塞等。
; 伪汇编 ; 1. 把 G1 的寄存器保存到 G1.sched(gobuf) MOVQ SP, G1.sched.sp ; 保存栈指针 MOVQ PC, G1.sched.pc ; 保存程序计数器(当前指令地址) MOVQ BP, G1.sched.bp ; 保存帧指针 MOVQ $G1, G1.sched.g ; 保存 G 指针 ; 2. 修改 G1 状态 MOVQ $_Gwaiting, G1.status ; G1 变成等待状态 ; 3. 切换到 g0 栈 MOVQ g0.sched.sp, SP ; SP 切到 g0 的栈顶 MOVQ g0, g ; 当前 G 寄存器指向 g0 ; 4. 调用 runtime.schedule() CALL runtime.schedule(SB)
阶段 B:g0 执行调度
func schedule() {
gp := findRunnable() // 找到 G2
execute(gp) // 准备执行 G2
}阶段 C:恢复 G2 的现场(g0 → G2)
; 伪汇编 ; 1. 修改 G2 状态 MOVQ $_Grunning, G2.status ; 2. 从 G2.sched 恢复寄存器 MOVQ G2.sched.sp, SP ; 恢复栈指针 MOVQ G2.sched.pc, PC ; 恢复程序计数器(下一条指令) MOVQ G2.sched.bp, BP ; 恢复帧指针 MOVQ G2.sched.g, g ; 当前 G 寄存器指向 G2 ; 3. 跳转到 G2 的 PC 继续执行 JMP G2.sched.pc
关键点: 这不是操作系统级别的线程切换(不需要进内核),只是修改了几个 CPU 寄存器(SP、PC、BP、g),所以速度极快(约 200 纳秒)。
10.4 系统调用时的特殊切换
当 G1 执行 read() 时,切换更复杂:
G1 用户栈 │ ▼ 调用 syscall.Read │ ▼ 【切换到 g0 栈】 │ ▼ g0 调用 entersyscall() ├── 保存 G1 上下文 ├── G1.status = _Gsyscall ├── P 和 M 解绑(P 可以去找别的 M) └── 释放 P 的锁 │ ▼ g0 执行真正的 syscall(进入内核态) │ ▼ 内核完成 read,返回用户态 │ ▼ g0 调用 exitsyscall() ├── 尝试重新绑定原来的 P ├── 如果 P 被抢走了 → G1 进全局队列 └── 如果绑定成功 → 恢复 G1 上下文 │ ▼ 【切回 G1 用户栈】 │ ▼ G1 继续执行
十一:GOMAXPROCS=4,100 万个 Goroutine,P 只有 4 个,为什么不会成为瓶颈?
答案分三层:
① P 是"逻辑处理器",不是"执行单元" 真正消耗 CPU 的是 M(OS 线程)。P 只是一个调度上下文,本身不消耗 CPU 时间。4 个 P 意味着最多 4 个 M 同时执行用户代码,这与你的 4 核 CPU 完全匹配。
② 100 万个 G 大部分时间在睡觉 在真实的高并发程序中(如 Web 服务器),绝大多数 G 处于:
- 等待网络 I/O(
netpoll管理) - 等待 Channel
- 等待锁
- 等待定时器
这些 G 不需要占用 P。只有就绪状态(_Grunnable)的 G 才需要排队等 P。通常就绪的 G 数量远小于 100 万。
③ G 的本地队列分散了压力
- 4 个 P,每个 P 的本地队列可容纳 256 个 G。
- 4 个 P 合计能缓存 1024 个就绪 G,无需加锁。
- 全局队列作为兜底,Work Stealing 保证负载均衡。
④ G 本身极轻量
- 每个 G 初始栈 2KB,100 万个 G 约 2GB 内存。
- 现代服务器轻松承受。且栈是按需增长的,很多 G 实际只占用几 KB。
十二:纯死循环for {},Go 1.14 前后分别发生什么?
Go 1.14 之前:
- 协作式抢占,依赖函数调用序言检查
preempt标志。 for {}循环体里没有任何函数调用,永远不会走到检查点。sysmon虽然能检测到 G 运行超 10ms,也设置了preempt标志,但 G 永远不去检查。- 结果:这个 G 永久霸占一个 M 和 P,同 P 本地队列里的其他 G 全部饿死。程序表现为"卡死"(部分逻辑不执行)。
Go 1.14 之后:
sysmon检测到 G 运行超 10ms,向 M 发送SIGURG信号。- M 的信号处理程序(
gsignal)在循环的安全点(backedge)设置抢占标记。 - 即使
for {}没有函数调用,循环跳转指令(JMP)本身被编译器标记为安全点。 - 下一次循环迭代时,检查到抢占标记,G 主动让出 CPU。
- 结果:G 运行约 10ms 后被强制切走,其他 G 得到执行机会。
十三:栈切换时除了 SP/PC,还需要保存什么?为什么 BP 很重要?
gobuf 中保存的关键信息:
- SP(栈指针):知道栈顶在哪,恢复后才能正确读写局部变量。
- PC(程序计数器):知道下一条该执行哪条指令,恢复后才能继续执行。
- G(Goroutine 指针):知道当前是哪个 G,恢复后 M 的
curg才能正确指向它。 - BP(帧指针/基址指针):用于栈帧回溯。
为什么 BP 极其重要?
BP 指向当前栈帧的底部。通过 BP 链表,可以回溯整个调用链:
当前函数的 BP ──► 调用者的 BP ──► 调用者的调用者的 BP ──► ...
如果没有 BP:
- Panic 时无法打印堆栈:
runtime.Callers和runtime.Stack依赖 BP 链表回溯调用链。 - GC 无法准确扫描栈:GC 需要知道栈上哪些位置是指针。通过 BP 和编译器生成的栈地图(stack map),GC 才能正确识别存活对象。
- Defer 和 Recover 无法定位:
defer链表挂在栈帧上,没有 BP 就找不到它们。
Go 在 AMD64 架构上默认启用帧指针(-fno-omit-frame-pointer),就是为了保证调试和 GC 的正确性。
到此这篇关于Go语言中GMP调度模型详解的文章就介绍到这了,更多相关go gmp调度模型内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!


最新评论