四,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.goruntime/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.gofindrunnable() 的源码逻辑,给你分步骤拆解

总览

当 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 申请内存都直接走全局 mcentralmheap

  • 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 核心数
}

具体流程:

  1. Go 程序启动,执行 runtime.rt0_go

  2. 调用 schedinit(),其中读取环境变量 $GOMAXPROCS 或调用 runtime.GOMAXPROCS(n)

  3. 调用 procresize(n),创建 n 个 P

  4. 这些 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() 等系统调用时:

  1. 当前 M 进入内核态,可能长时间阻塞。

  2. P 和 M 解绑(P 不能跟着 M 一起睡)。

  3. P 需要找新的 M 来继续执行其他 G。

  4. 如果 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() 时:

  1. 切换到 g0 栈。

  2. g0 调用 entersyscall(),保存状态,把 G 标记为 _Gsyscall

  3. g0 执行真正的系统调用。

  4. 系统调用返回后,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:

  1. Panic 时无法打印堆栈runtime.Callersruntime.Stack 依赖 BP 链表回溯调用链。

  2. GC 无法准确扫描栈:GC 需要知道栈上哪些位置是指针。通过 BP 和编译器生成的栈地图(stack map),GC 才能正确识别存活对象。

  3. Defer 和 Recover 无法定位defer 链表挂在栈帧上,没有 BP 就找不到它们。

Go 在 AMD64 架构 上默认启用帧指针(-fno-omit-frame-pointer),就是为了保证调试和 GC 的正确性。

相关推荐
Tisfy1 小时前
LeetCode 2553.分割数组中数字的数位:模拟(maybe+翻转)——java也O(1)
java·数学·算法·leetcode·题解·模拟·取模
怪祝浙1 小时前
从简单项目入手Java(学生系统)V6(Web版本 Spring Boot3 MySQL Vue3 MyBatis)
java·spring boot·mysql
YOU OU1 小时前
HTML+CSS+JavaScript
前端·javascript·css·html
吴声子夜歌1 小时前
Java——Integer与二进制算法
java·算法
风味蘑菇干1 小时前
继承 + static + final 综合应用
java·开发语言
Rkgua1 小时前
路径传参和查询传参和请求体传参区以及Vue和React的用法区分
前端·面试
JarvanMo1 小时前
Flutter + Supabase 集成 Apple Sign-In 完整指南
前端
li星野2 小时前
二分查找六题通关:从标准模板到旋转数组(Python + C++)
java·c++·python
无所事事O_o2 小时前
IntelliJ IDEA 无法识别 Maven SNAPSHOT 依赖,但 Maven 编译正常
java