编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

    • 入门教程

    • 综合案例

    • 专栏博客

      • Go 专栏博客
      • 内存模型与栈堆布局
      • 指针与逃逸分析
      • 结构体内存布局对齐
      • 字符串与切片底层
      • 接口与类型系统
      • map哈希表底层实现
      • 零值初始化设计哲学
      • GMP协程调度器机制
      • 通道channel源码剖析
      • sync同步原语剖析
      • map并发安全与哈希
      • Go内存模型一致性
      • 加权信号量与限流
      • errgroup并行控制
      • 协程泄漏排查与修复
      • 并发设计模式详解
      • GC三色标记与屏障
      • 内存分配器深挖
      • defer延迟执行机制
      • 定时器四叉堆实现
      • 抢占式调度器原理
      • 协程栈扩容与缩容
        • 1. 案例引入
          • 1.1 一段崩在哪
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 栈生命周期全景
          • 2.2 为什么不用固定栈
        • 3. 栈分裂检测机制
          • 3.1 每个函数的栈序言
          • 3.2 stackguard0 双阈值
          • 3.3 NOSPLIT 函数约束
        • 4. morestack 到 newstack
          • 4.1 morestack 上下文保存
          • 4.2 newstack 的扩容控制流
          • 4.3 扩容尺寸的计算策略
          • 4.4 栈溢出边界判定
        • 5. copystack 指针修复
          • 5.1 指针扫描的数据来源
          • 5.2 adjustpointer 精确修复
          • 5.3 defer 与 panic 链调整
          • 5.4 当前 G 的特殊处理
        • 6. 缩容与栈缓存回收
          • 6.1 shrinkstack 触发条件
          • 6.2 GC 扫描联动缩容
          • 6.3 栈缓存的分配复用
        • 7. 栈溢出与守护页保护
          • 7.1 守护页的物理隔离
          • 7.2 栈溢出的三种检测
          • 7.3 stackOverflow panic 路径
        • 8. 栈拷贝的汇编视角
          • 8.1 编译期栈帧信息嵌入
          • 8.2 函数序言的汇编模板
          • 8.3 栈扩容中的寄存器上下文
        • 9. 诊断与排查实战
          • 9.1 GODEBUG 栈事件追踪
          • 9.2 pprof goroutine 栈分析
          • 9.3 栈规模异常的识别模式
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次栈扩容的完整路径
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • 上下文取消与传播
      • 泛型与类型约束
      • 反射机制与unsafe
      • 迭代器与rangefunc
      • 错误处理与panic
      • 网络轮询器netpoller
      • HTTP服务端源码分析
      • JSON序列化与编解码
      • 数据库SQL连接池
      • 文件IO与零拷贝
      • 结构化日志与配置
      • 单元测试与基准
      • cgo与系统调用切换
      • 编译链接与PGO优化
      • 写作模板
    • 开发技巧

  • JavaScript入门

  • CodeX
  • Go入门到精通
  • 专栏博客
杨充
2025-06-07
目录

协程栈扩容与缩容

# 22.协程栈扩容与缩容

卷三第 22 篇——聚焦 goroutine 栈的单一命题:它怎么从 2KB 起步?morestack 如何被触发?copystack 拷贝旧栈时怎么保证所有指针不悬空?缩容在什么条件下发生?本文是第 01 篇"连续栈"概念的显微镜:把每一次扩容/缩容拆到汇编指令级,回答"栈操作为什么不会让 Go 程序 crash"。关键词:morestack 序言、stackguard0 切换、newstack 扩容控制流、copystack 指针遍历、shrinkstack GC 联动、栈缓存复用。

# 目录介绍

  • 1. 案例引入
    • 1.1 一段崩在哪
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 栈生命周期全景
    • 2.2 为什么不用固定栈
  • 3. 栈分裂检测机制
    • 3.1 每个函数的栈序言
    • 3.2 stackguard0 双阈值
    • 3.3 NOSPLIT 函数约束
  • 4. morestack 到 newstack
    • 4.1 morestack 上下文保存
    • 4.2 newstack 的扩容控制流
    • 4.3 扩容尺寸的计算策略
    • 4.4 栈溢出边界判定
  • 5. copystack 指针修复
    • 5.1 指针扫描的数据来源
    • 5.2 adjustpointer 精确修复
    • 5.3 defer 与 panic 链调整
    • 5.4 当前 G 的特殊处理
  • 6. 缩容与栈缓存回收
    • 6.1 shrinkstack 触发条件
    • 6.2 GC 扫描联动缩容
    • 6.3 栈缓存的分配复用
  • 7. 栈溢出与守护页保护
    • 7.1 守护页的物理隔离
    • 7.2 栈溢出的三种检测
    • 7.3 stackOverflow panic 路径
  • 8. 栈拷贝的汇编视角
    • 8.1 编译期栈帧信息嵌入
    • 8.2 函数序言的汇编模板
    • 8.3 栈扩容中的寄存器上下文
  • 9. 诊断与排查实战
    • 9.1 GODEBUG 栈事件追踪
    • 9.2 pprof goroutine 栈分析
    • 9.3 栈规模异常的识别模式
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次栈扩容的完整路径
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 1. 案例引入

# 1.1 一段崩在哪

某金融风控系统在每日 9:30 开盘后 3 分钟内,P99 延迟从 50ms 飙到 2.3s,同时 CPU 全部打满。运维观察到 goroutine 数从 500 涨到 28000,RSS 从 800MB 跳到 11GB——K8s 的 12GB limit 触碰导致 OOM Kill。重启后循环往复。

根因定位时的关键代码:

// risk_engine.go —— 组合资产递归估值
package main

import (
    "sync"
)

type Portfolio struct {
    Name       string
    Holdings   []*Holding       // 直接持仓
    SubPorts   []*Portfolio     // 子组合(嵌套)
}

type Holding struct {
    Symbol   string
    Quantity float64
    Price    float64
}

// 递归估值:最大深度取决于组合嵌套层数
func (p *Portfolio) Value(depth int) float64 {
    if depth > 2000 {
        return 0  // 理论上不应该这么深
    }
    total := 0.0
    for _, h := range p.Holdings {
        total += h.Quantity * h.Price
    }
    for _, sub := range p.SubPorts {
        // 子组合递归估值——每一层都在消耗栈
        total += sub.Value(depth + 1)
    }
    return total
}

// 每个请求开启 50 个 goroutine 并行估值子组合
func handleRiskRequest(port *Portfolio) float64 {
    var wg sync.WaitGroup
    results := make([]float64, len(port.SubPorts))
    for i, sub := range port.SubPorts {
        wg.Add(1)
        go func(idx int, sp *Portfolio) {
            defer wg.Done()
            results[idx] = sp.Value(0)  // ← 每个 goroutine 递归估值
        }(i, sub)
    }
    wg.Wait()
    // 汇总...
    return sum(results)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

现象:

  • 正常时段:单次请求的 Portfolio 嵌套深度 ≤ 3 层,每个 goroutine 的 Value() 调用深约 10 帧
  • 开盘后:新对接的某家机构数据中,SubPorts 被展平成深度约 200 层的链式嵌套(Portfolio A → Portfolio B → ... → Portfolio ZZ)
  • 50 个 goroutine 各自递归 200 层 → 每个 goroutine 的栈从 2KB 涨到约 256KB
  • 500 个并发请求 → 500 × 50 = 25000 个 goroutine,每个栈 256KB → 约 6.4GB 纯栈开销
  • GC 触发后扫描 25000 个 goroutine 栈 → 发现 80% 使用量不到 1/4 → 触发 shrinkstack 大规模缩容
  • 缩容 = 为每个大栈 copystack(分配新栈 → 拷贝数据 → 调整指针 → 释放旧栈)
  • 20000 次栈拷贝同时发生 → CPU 全部被打满 → P99 延迟爆炸 → OOM Kill

# 1.2 顺藤摸到根因

追查链条:

  • 假设 1:是不是 goroutine 泄漏?—— pprof goroutine 显示 25000 个 goroutine 都处于 runnable 或 running,不是阻塞泄漏,是活的。
$ curl http://localhost:6060/debug/pprof/goroutine?debug=2 | grep -c "runnable"
24872
1
2
  • 假设 2:是不是递归太深导致栈过大?——看 GODEBUG=schedtrace=1000 输出:
SCHED 1000ms: gomaxprocs=16 idleprocs=0 threads=32 spinningthreads=8 ...
  idlethreads=0 runqueue=0 [0 0 0 ...]
  G 4872: status=3(stack growth)   ← 异常!栈扩容状态
  G 9123: status=3(stack growth)
  ...
1
2
3
4
5

大量 goroutine 同时进入 stack growth 状态——这些 G 正在 runtime.newstack 中执行栈扩容。

  • 假设 3:栈扩容本身耗时不长(~1μs 量级),为什么会 P99 到 2.3s?——看 CPU profile:
$ go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
(pprof) top10
      flat  flat%   sum%        cum   cum%
    12.50s 21.74% 21.74%     28.90s 50.26%  runtime.copystack
     8.30s 14.43% 36.17%      8.30s 14.43%  runtime.adjustpointer
     5.20s  9.04% 45.22%      5.20s  9.04%  runtime.memmove
     4.80s  8.35% 53.57%      4.80s  8.35%  runtime.scanstack
1
2
3
4
5
6
7

copystack + adjustpointer 占了 36% 的 CPU 时间。真相浮现:不是扩容本身慢——而是 GC 触发的缩容风暴让 20000 个 goroutine 同时做 copystack。每次 copystack 拷贝 256KB 栈 + 遍历所有栈帧指针 = 约 8μs。20000 × 8μs = 160ms——但这是在 16 核上串行排队的(每个 P 同时只能有一个 G 在 copystack),总耗时被放大到秒级。

  • 假设 4:为什么栈缩了又涨?——业务上每次请求都要递归 200 层。缩容后的 2KB 栈马上又要扩容回 256KB。形成 "扩容→用完→缩容→下次请求又扩容" 的循环。

  • 根本原因:栈的"用后即缩"策略在高并发深递归场景下变成 CPU 陷阱。缩容省了 6GB 栈内存,但付出的 CPU 代价让 P99 延迟翻了 46 倍。

这个事故藏着至少 7 个原理点:

① 栈分裂检查的汇编长什么样?stackguard0 到底怎么设?         → 第 3 章
② morestack 怎么保存上下文并跳转到 newstack?                → 第 4 章
③ newstack 如何决定扩多大?碰到 1GB 上限怎么办?             → 第 4.3-4.4
④ copystack 怎么调整旧栈上的所有指针?                       → 第 5 章
⑤ 缩容的条件是什么?GC 怎么触发 shrinkstack?                → 第 6 章
⑥ 守护页怎么防栈溢出?栈溢出 panic 走什么路径?               → 第 7 章
⑦ GODEBUG schedtrace 怎么看栈操作?                          → 第 9 章
1
2
3
4
5
6
7

# 1.3 我们要回答什么

这个案例就是本篇的主线案例。我们从每个函数序言中的栈检查指令出发,跟踪 morestack → newstack → copystack 的完整控制流;然后看 GC 怎么介入缩容、栈缓存怎么复用旧栈;最后回到风控系统,用 GODEBUG + pprof 给出精确的诊断和优化方案。

本篇路线:

栈分裂检测 (第 3 章) ── 每个函数序言的真面目
   ↓
扩容控制流 (第 4 章) ── morestack → newstack 的完整路径
   ↓
指针修复 (第 5 章) ── copystack 如何保证"搬了家指针不悬空"
   ↓
缩容与缓存 (第 6 章) ── shrinkstack 触发条件 + stackcache 复用
   ↓
溢出保护 (第 7 章) ── 守护页 + 三重检测 + panic 路径
   ↓
汇编视角 (第 8 章) ── 编译期栈帧信息嵌入 + Plan9 汇编
   ↓
诊断实战 (第 9 章) ── GODEBUG schedtrace + pprof goroutine
   ↓
综合案例 (第 10 章) ── 回到风控系统,彻底剖析 + 优化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

📌 本篇定位:第 01 篇讲"Go 有连续栈"——本篇讲"连续栈的每一次扩容/缩容到底发生了什么"。读完本文,我们能回答"一个 goroutine 的栈从 2KB 变成 256KB 的过程中,执行了多少条汇编指令、访问了哪些 runtime 结构、指针是怎么一根根被修正的"。

# 2. 架构概览

# 2.1 栈生命周期全景

一个 goroutine 栈的完整生命周期可用下图概括:

G 被创建
  │
  ├─ newproc1 → stackalloc(2KB)
  │     │
  │     ├─ stackcache[order] 有缓存?→ 复用旧栈 → O(1)
  │     └─ 无缓存 → mheap 分配 → 初始化守护页
  │
  ▼
G 开始执行 → 每调用一个函数 → 序言检查
  │
  │   CMP  SP, G.stackguard0
  │   ├─ SP > guard → 栈够用 → 继续执行
  │   └─ SP ≤ guard → 触发 morestack ──────────────────────┐
  │                                                          │
  │   ┌──────────────────────────────────────────────────────┘
  │   ▼
  │  morestack → 保存上下文到 G.sched
  │   │
  │   ▼
  │  newstack → 计算 newSize = oldSize × 2
  │   │          ├─ newSize > maxstacksize(1GB) → stackOverflow panic
  │   │          └─ 通过
  │   │
  │   ├─ stackalloc(newSize) → 拿新栈
  │   ├─ copystack → 拷贝数据 + adjustpointer 修正所有指针
  │   ├─ stackfree(oldStack) → 放回 stackcache 或归还 mheap
  │   └─ gogo(&G.sched) → 跳回中断点继续执行
  │
  ▼
GC STW 阶段
  │
  ├─ scanstack(G) → 扫描 G 的栈,标记指针指向的堆对象
  │     │
  │     └─ 计算 used = G.stack.hi - G.sched.sp
  │
  ├─ shrinkstack(G)
  │     ├─ used < capacity/4 且 capacity > 2KB?
  │     ├─ 是 → newSize = capacity/2 → copystack → stackfree
  │     └─ 否 → 不缩
  │
  ▼
G 退出 → stackfree → 放回 stackcache 或归还 mheap
                        │
                        └─ 5 分钟后 scavenger 通过 madvise 归还 OS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

核心状态转换:

事件 G 的 stack 状态 stackguard0 G 的状态
G 创建 2KB lo + StackGuard _Gidle → _Grunnable
函数调用触发扩容 4KB → 8KB → ... 新 lo + StackGuard _Grunning → _Gcopystack
GC 扫描触发缩容 一半(或回到 2KB) 新 lo + StackGuard _Gwaiting (STW)
G 退出 stackfree 释放 无效 _Gdead

# 2.2 为什么不用固定栈

疑惑:OS 线程栈固定 8MB,简单稳定——Go 为什么要设计一套"扩容/缩容 + 拷贝"的动态栈?

论证:

  1. goroutine 数量远大于 OS 线程——一个 Go 进程轻松创建 10 万个 goroutine。如果每个 goroutine 配 8MB 固定栈,光是栈就需要 800GB 虚拟地址空间——超出 64 位 Linux 用户态默认的 128TB 不成问题,但物理内存占用会让 RSS 失控(即使有 lazy allocation,大量被访问过的栈页也会常驻物理内存)。

  2. goroutine 的栈深度差异极大——一个 HTTP handler 可能只需 4KB(几层 middleware),一个递归 JSON 解析器可能需要 512KB。固定 8MB 的话,前者浪费了 99.95% 的空间。

  3. 固定栈不能缩——OS 线程的 8MB 栈一旦分配就一直占用,即使线程大部分时间在等 channel。goroutine 的栈在 GC 时自动缩容,把物理内存释放出来。

  4. C 语言不能做连续栈不是因为"不想"——C 编译器不生成栈帧类型元数据,运行时无法区分 [rbp-8] 是指针还是整数,复制栈时会破坏数据。Go 编译器在每个函数的 funcdata 中嵌入了"栈帧指针位图"——这是连续栈能做的前提。

结论:动态栈不是额外功能——它是 goroutine 高并发模型的基础设施。没有 2KB 起步的连续栈,10 万 goroutine 的高并发在物理上不可行。

# 3. 栈分裂检测机制

# 3.1 每个函数的栈序言

Go 编译器在每个非 NOSPLIT 函数的入口插入栈检查序言。以 amd64 为例,编译产物:

// stack_probe.go
package main

func compute(n int) int {
    buf := make([]byte, 1024)  // 1KB 栈帧
    _ = buf
    if n <= 0 {
        return 0
    }
    return n + compute(n-1)
}
1
2
3
4
5
6
7
8
9
10
11
$ go tool compile -S stack_probe.go
1
TEXT main.compute(SB), $1064-8       ; 栈帧 1064 字节(含 1024 buf + 返回地址等)
    ; === 栈分裂检查序言 ===
    MOVQ    (TLS), CX                ; CX = 当前 G 结构体指针
    CMPQ    SP, 16(CX)               ; SP 和 G.stackguard0 比较
    JHI     ok                       ; SP > stackguard0 → 栈够,正常执行
    CALL    runtime.morestack_noctxt(SB)  ; SP ≤ stackguard0 → 需要扩容
ok:
    SUBQ    $1064, SP                ; 分配栈帧
    ; === 函数体 ===
    MOVQ    AX, n(SP)               ; 参数 n
    CMPQ    AX, $0
    JGT     recurse
    XORQ    AX, AX
    ADDQ    $1064, SP
    RET
recurse:
    ; ... 递归调用 compute(n-1) ...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

每条指令的含义:

  1. MOVQ (TLS), CX:TLS(Thread-Local Storage)中存着当前 goroutine 的 g 指针。amd64 上 FS 段寄存器指向 TLS。
  2. CMPQ SP, 16(CX):g.stackguard0 位于 g 结构体偏移 16 字节处(runtime/runtime2.go 中 stackguard0 uintptr 字段)。比较当前栈指针与警戒线。
  3. JHI:无符号大于则跳转——SP 在 guard 之上说明"栈空间还够"。
  4. CALL runtime.morestack_noctxt(SB):栈不够了,进入扩容流程。

疑惑:为什么偏移是 16?为什么不是 0 或 8?

论证:g 结构体在 runtime/runtime2.go 中的定义:

// runtime/runtime2.go
type g struct {
    stack       stack     // 偏移 0:  16 字节 (lo + hi)
    stackguard0 uintptr   // 偏移 16: 栈警戒线
    stackguard1 uintptr   // 偏移 24: 系统栈警戒线
    // ...
}
1
2
3
4
5
6
7

偏移 0-15 是 stack 结构体(两个 uintptr = 16 字节),所以 stackguard0 在偏移 16。硬编码 16 避免了运行时计算偏移——这是一条汇编常量的极致优化。

结论:每个函数调用都伴随着 ~3 条汇编指令的栈检查——MOVQ + CMPQ + JHI/CALL。在 CPU 预测正确(栈够用)的情况下,这个开销 ≈ 0.3ns。这就是 Go 在"几乎不付代价"的前提下获得了栈溢出保护。

# 3.2 stackguard0 双阈值

stackguard0 并非简单的 stack.lo + StackGuard。它有两层含义,通过一个关键技巧实现:

// runtime/runtime2.go (简化)
type g struct {
    // ...
    stackguard0 uintptr  // 正常执行时的栈警戒线
    stackguard1 uintptr  // 系统调用后/G 被抢占后的备用警戒线
    // ...
}
1
2
3
4
5
6
7

正常情况:stackguard0 = stack.lo + StackGuard(约 stack.lo + 928 字节)

高地址
  ┌───────────────────┐ ← stack.hi
  │    有效栈空间       │
  │    ...              │
  │    ← SP 当前        │
  ├───────────────────┤ ← stackguard0 = stack.lo + 928
  │   预留区 (928B)     │   栈检查发生在 SP 触及这条线之前
  ├───────────────────┤ ← stack.lo + StackSmall (128)
  │   StackSmall       │
  ├───────────────────┤ ← stack.lo
  │   守护页 (---p)    │
  └───────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12

抢占/系统调用后:当 G 被调度器抢占或从系统调用返回时,调度器会把 stackguard0 设为 stackPreempt(一个特殊值 ~0xffffffffffff),这会让栈检查永远失败,强制 G 进入 morestack → 在 newstack 中检测到抢占标志,触发 gopreempt_m 让出执行权。

// runtime/preempt.go (简化)
func preemptone(p *p) bool {
    // ...
    gp.stackguard0 = stackPreempt  // 强制下一次栈检查失败
    // ...
}
1
2
3
4
5
6

这是 Go 1.14 引入的异步抢占机制——利用已有的栈检查基础设施,以零额外指令实现抢占。

# 3.3 NOSPLIT 函数约束

某些 runtime 函数不能做栈检查——因为它们本身就是栈扩容路径上的执行者。这些函数用 NOSPLIT 标记:

// runtime/stack.go
//go:nosplit
func morestack() {
    // 不能在栈分裂检查中再分裂——否则无限递归
}

//go:nosplit
func systemstack(fn func()) {
    // 切换到 g0 的系统栈,不能在 goroutine 栈上做检查
}
1
2
3
4
5
6
7
8
9
10

NOSPLIT 函数有严格的栈帧大小验证:

// runtime/stack.go
//go:nosplit
func smallFunc() {
    var buf [64]byte  // 栈帧很小——NOSPLIT 允许
    _ = buf
}
1
2
3
4
5
6

编译器会在链接阶段检查:每个 NOSPLIT 函数的栈帧 ≤ _StackLimit(约为 _StackGuard - _StackSmall = 800 字节)。超过这个值,链接器报错。这是静态保证——确保 NOSPLIT 函数永远不会撑爆栈。

关联到第 01 篇:第 01 篇 3.1 节提到 _StackSmall = 128——这 128 字节的"余量"正是给 NOSPLIT 函数留的。普通函数有 _StackGuard(928 字节)作为缓冲,NOSPLIT 函数只能用 _StackSmall(128 字节)。因此 NOSPLIT 函数的栈帧必须比普通函数小得多。

# 4. morestack 到 newstack

# 4.1 morestack 上下文保存

morestack 的第一件事:把当前 goroutine 的"断点"完整存入 G.sched,这样扩容完成后可以原样恢复。

// runtime/asm_amd64.s (简化)
TEXT runtime·morestack(SB), NOSPLIT, $0-0
    // 1. 保存调用方的返回地址
    MOVQ    0(SP), AX           ; AX = 调用 morestack 的函数返回地址(即原函数的 ok 标签之后)
    MOVQ    AX, (g_sched+gobuf_pc)(g)  ; G.sched.pc = 返回地址

    // 2. 保存栈指针
    LEAQ    8(SP), AX           ; AX = morestack 被调用前的 SP(+8 因为 CALL 压入了返回地址)
    MOVQ    AX, (g_sched+gobuf_sp)(g)  ; G.sched.sp = 旧 SP

    // 3. 保存其他寄存器(BP 等)
    MOVQ    BP, (g_sched+gobuf_bp)(g)

    // 4. 切换到 g0 的栈执行
    MOVQ    (g_sched+gobuf_g)(g), BX  ; BX = g0
    MOVQ    (g_sched+gobuf_sp)(BX), SP  ; SP = g0.sched.sp(切换到系统栈)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

关键数据结构(runtime/runtime2.go):

type gobuf struct {
    sp   uintptr   // 栈指针
    pc   uintptr   // 程序计数器(下一条要执行的指令地址)
    bp   uintptr   // 基址指针
    ret  uintptr   // 系统调用返回值
    // ...
    ctxt unsafe.Pointer  // 闭包上下文
}

type g struct {
    // ...
    sched     gobuf    // 当 G 不在运行时,它的执行上下文保存在这里
    // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

morestack 把当前 G 的上下文写进 G.sched,然后把执行权交给 g0(每个 M 的系统 goroutine)的栈——因为接下来的 newstack 操作(分配新栈、复制)不能在即将被释放的旧栈上执行。

# 4.2 newstack 的扩容控制流

newstack 是栈扩容的中央调度器,完整控制流(runtime/stack.go):

newstack()
│
├── 1. 检查 stackguard0 是否为 stackPreempt
│      ├── 是 → 这是抢占信号,不是扩容
│      │      → 检查当前是否安全点(safepoint)
│      │      → 是安全点 → gopreempt_m(G) → 调度到其他 G
│      │      → 不是安全点 → 恢复 stackguard0,继续执行
│      └── 否 → 真正的栈扩容需求 → 继续
│
├── 2. 检查栈是否已经被其他 M 扩容
│      ├── 如果 stackguard0 已经改变(被其他 M 扩容完毕)
│      │   → 说明扩容已完成,直接返回
│      └── 否则 → 继续
│
├── 3. 判断触发原因
│      ├── stackguard0 < stack.lo → 需要扩容
│      ├── C 调用 Go → 需要移动到更大的栈
│      └── G 需要更多栈(如 gcAssistAlloc 中的分配)
│
├── 4. 计算新栈大小(见 4.3)
│
├── 5. 检查是否超过 maxstacksize(1GB)
│      ├── 超过 → fatal("stack overflow")
│      └── 通过 → 继续
│
├── 6. 分配新栈
│      oldstack = G.stack
│      newstack = stackalloc(newsize)
│
├── 7. 拷贝栈内容 + 调整指针
│      copystack(G, newsize)
│
└── 8. 恢复执行
       G.sched.sp = 新 SP(换算到新栈对应位置)
       G.sched.pc = runtime.gogo 的地址
       → gogo(&G.sched)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

关键: 步骤 7 完成后,旧栈已经被释放(stackfree),当前执行的代码已经在新栈上了。gogo 不是返回到 morestack 的调用者——而是通过 G.sched 中保存的 PC/SP 直接恢复原来被中断的函数执行。

# 4.3 扩容尺寸的计算策略

// runtime/stack.go (简化逻辑)
func newstack() {
    // ...
    oldsize := gp.stack.hi - gp.stack.lo
    newsize := oldsize * 2

    // 但有上限和保底
    if newsize > maxstacksize {
        newsize = maxstacksize  // 1GB
    }
    if newsize < _FixedStack {
        newsize = _FixedStack   // 最小 2KB
    }

    copystack(gp, newsize)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

2× 倍增策略的设计考量:

考量 说明
为什么不是固定增 4KB? 深递归可能需要 512KB——固定增量需要 128 次扩容,远多于 2× 的 ~8 次
为什么不是 10×? 浪费——大部分 goroutine 的栈最终落在 8KB~64KB,10× 会让中间的 80KB/800KB 永远用不到
为什么有 1GB 上限? 64 位系统的虚拟地址空间(128TB)虽大,但 single goroutine 的栈用 1GB 足够覆盖所有合法场景。超过 1GB 说明无限递归 bug

扩容序列:2KB → 4KB → 8KB → 16KB → 32KB → 64KB → 128KB → 256KB → 512KB → 1MB → 2MB → 4MB → 8MB → 16MB → 32MB → 64MB → 128MB → 256MB → 512MB → 1GB

从 2KB 到 1GB 只需要 约 19 次扩容。

# 4.4 栈溢出边界判定

当 newsize > maxstacksize(1GB)时,触发 fatal("stack overflow"):

// runtime/stack.go
if newsize > maxstacksize {
    // 输出 goroutine 栈信息后终止进程
    print("runtime: goroutine stack exceeds 1000000000-byte limit\n")
    print("runtime: sp=", hex(gp.sched.sp), " stack=[", ...)
    throw("stack overflow")
}
1
2
3
4
5
6
7

与 C 不同的关键:C 的栈溢出通常导致 SIGSEGV(踩到守护页),不会打印有用信息。Go 的栈溢出是逻辑判定——runtime 在尝试扩容时发现超限,主动抛出 fatal 并打印当前 goroutine 的完整调用栈,远比 C 的 segfault 更易诊断。

# 5. copystack 指针修复

# 5.1 指针扫描的数据来源

copystack(runtime/stack.go)是连续栈最核心的函数——它必须保证"搬了家之后所有指针还能用"。扫描指针的数据来源有两个:

来源一:运行时类型信息(funcdata)

Go 编译器在编译每个函数时,把它的"栈帧内哪些偏移是指针"编码为位图,存进 funcdata 区域:

// test_stack.go
func mixedFrame() {
    var a int        // SP+0: 不是指针
    var b *int       // SP+8: 是指针!
    var c [4]byte    // SP+16: 不是指针
    var d *string    // SP+24: 是指针!
}
1
2
3
4
5
6
7

对应 funcdata 中的指针位图(stackmap):

偏移:  0  8  16 24 32 ...
位图:  0  1  0  1  0  ...   (1 = 是指针, 0 = 不是)
1
2

来源二:类型系统(_type.gcdata)

对于栈上分配的结构体或数组,Go 编译器同样记录了其内部指针布局:

type FrameData struct {
    id    int64     // 偏移 0:  不是指针
    next  *Node     // 偏移 8:  是指针
    buf   [8]byte   // 偏移 16: 不是指针
    name  string    // 偏移 24: 内部包含指针 (Data 字段)
}
1
2
3
4
5
6

*FrameData 的 _type.gcdata 位图会指出偏移 8 和偏移 24 是指针。

# 5.2 adjustpointer 精确修复

// runtime/stack.go (核心逻辑简化)
func copystack(gp *g, newsize uintptr) {
    old := gp.stack
    used := old.hi - gp.sched.sp  // 实际用到的栈空间

    // 1. 分配新栈
    new := stackalloc(uint32(newsize))

    // 2. 计算偏移量
    delta := new.hi - old.hi  // 新栈基址 - 旧栈基址

    // 3. 拷贝数据
    memmove(unsafe.Pointer(new.hi-used), unsafe.Pointer(old.hi-used), used)

    // 4. 调整所有指向旧栈的指针
    adjustinfo := adjustinfo{
        old:   old,
        delta: delta,  // 需要加的偏移量
    }
    gentraceback(...,
        func(frame *stkframe, ...) bool {
            // 回调函数:对每个栈帧,调整帧内的所有指针
            adjustframe(frame, &adjustinfo)
            return true
        },
    )

    // 5. 调整 G 的栈描述
    gp.stack = new
    gp.stackguard0 = new.lo + _StackGuard
    gp.stackguard1 = gp.stackguard0

    // 6. 释放旧栈
    stackfree(old)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

adjustframe 的指针修复(runtime/stack.go 中 adjustpointer):

对栈帧中的每一根指针 p:
    if old.lo ≤ p < old.hi {    // 这根指针指向旧栈区域
        p = p + delta             // 加上新旧栈的地址偏移
    }
    // 如果 p 不指向旧栈(指堆或其他全局区域),不加 delta
1
2
3
4
5

关键保证:Go runtime 不会调整"不知道是否是指针"的值——因为 funcdata 精确标注了每一根指针的位置。这就是 C 为什么做不了连续栈——C 没有这个信息,一旦栈搬家,[rbp-8] 里的整数 0xc00019a000 恰好落在新栈范围内,被当成指针加了 delta——数值就被"修复"坏了。Go 不会犯这个错误。

# 5.3 defer 与 panic 链调整

goroutine 的 defer 链表和 panic 链表中的指针也指向栈上的 defer/panic 结构——copystack 必须调整它们:

// runtime/stack.go (简化)
func copystack(gp *g, newsize uintptr) {
    // ...
    // 调整 defer 链
    for d := gp._defer; d != nil; d = d.link {
        adjustpointer(unsafe.Pointer(&d.fn), &adjinfo)
        adjustpointer(unsafe.Pointer(&d.sp), &adjinfo)
        // d.sp 指向栈上的调用位置——必须调整
    }

    // 调整 panic 链
    for p := gp._panic; p != nil; p = p.link {
        adjustpointer(unsafe.Pointer(&p.arg), &adjinfo)
        if p.recovered {
            adjustpointer(unsafe.Pointer(&p.sp), &adjinfo)
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

如果 defer 的指针没调整会怎样——d.sp 指向旧栈上的恢复点。栈搬家后,恢复点地址在新栈的不同位置。不调整的话,deferreturn 会跳转到旧地址——那个地址现在可能是另一个 goroutine 的栈(已经被复用)。

# 5.4 当前 G 的特殊处理

copystack 被调用时,当前正在执行的 goroutine 本身就是被拷贝的对象——这是最微妙的场景。

// copystack 中的关键操作
gp.sched.sp = new.hi - (old.hi - gp.sched.sp)  // SP 换算到新栈
gp.sched.pc = 函数返回地址                        // 指向 copystack 的调用者
1
2
3

copystack 执行完毕后,调用栈看起来像:

新栈:  某个函数
        → copystack (已经在新栈上完成)
        → 返回 → 调用者继续执行
1
2
3

但 copystack 的调用者本来在旧栈上——当 copystack 返回时,调用者的代码和栈帧已经被拷贝到了新栈的对应位置,执行自然地在新栈上继续。这是从旧栈切换到新栈的"无缝衔接"点——不需要任何特殊跳转指令,一个普通的 RET 就完成了。

# 6. 缩容与栈缓存回收

# 6.1 shrinkstack 触发条件

缩容发生在 GC 期间,条件精确:

// runtime/mgcmark.go
func shrinkstack(gp *g) {
    gstatus := readgstatus(gp)
    if gstatus&_Gscan == 0 {
        throw("shrinkstack: bad status")
    }

    // 条件 1: 可以在安全点缩容
    if !isShrinkStackSafe(gp) {
        return
    }

    used := gp.stack.hi - gp.sched.sp   // 实际使用的栈空间
    total := gp.stack.hi - gp.stack.lo  // 栈总容量

    // 条件 2: 使用了不到 1/4
    if used >= total/4 {
        return
    }
    // 条件 3: 大于最小栈
    if total <= _FixedStack {
        return
    }

    // 新大小 = 容量的一半,但保底 2KB
    newsize := total / 2
    if newsize < _FixedStack {
        newsize = _FixedStack
    }

    copystack(gp, newsize)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

缩容条件表格化:

条件 判定 意图
在安全点 isShrinkStackSafe 返回 true 不能在 G 正在执行 malloc 时缩容
使用量 < 1/4 used < total/4 避免"用完就缩、缩完又涨"的抖动
> 最小栈 total > _FixedStack(2KB) 2KB 已经是最小,不能再缩
非系统 G gp != g0 && gp != gsignal 系统栈不参与缩容

为什么阈值是 1/4 而不是 1/2——防止抖动。假设阈值是 1/2:栈是 256KB,用了 130KB(刚好超过一半)→ 不缩 → 释放了几个大局部变量 → 用了 120KB(不到一半)→ 缩到 128KB → 下次调用又需要 130KB → 扩到 256KB...形成"缩→扩→缩→扩"的循环。1/4 阈值给了足够缓冲——栈要"非常空"才缩,缩完后"不容易马上又要扩"。

# 6.2 GC 扫描联动缩容

缩容和 GC 扫描是耦合的——GC 在扫描每个 goroutine 栈的同时,顺便检查是否需要缩容:

// runtime/mgcmark.go
func markroot(gcw *gcWork, i uint32) {
    // ...
    case i == baseData ... baseBSS:
        // 扫描全局数据
    case i >= baseStacks:
        gp := allgs[i-baseStacks]
        // 扫描 goroutine 栈
        scanstack(gp, gcw)
        // 扫描完成后检查是否需要缩容
        shrinkstack(gp)  // ← 在 STW 中,gp 已经暂停
    // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13

为什么在 GC 时缩容:

  1. 此时 goroutine 已暂停(STW 或处于安全点),不会在栈上执行
  2. 刚扫描完栈,已知精确的使用量(used)
  3. 缩容本身的 copystack 触发少量堆分配(stackalloc),这些分配在 GC 的标记终止阶段是安全的

缩容的成本:每次缩容 ≈ 一次 copystack——分配新栈 + 拷贝数据 + 调整指针 + 释放旧栈。对于 256KB 的栈,拷贝约需 1~2μs。如果 20000 个 goroutine 同时缩容,总 CPU 成本约 20~40ms——但如果这些 goroutine 马上又需要扩容(回到原大小),那么"缩容 + 扩容"的双重拷贝就是纯粹的浪费。

# 6.3 栈缓存的分配复用

stackalloc 和 stackfree 有自己的缓存——不经过 mcache/mcentral 的通用分配路径:

// runtime/mcache.go
type mcache struct {
    // ... 通用分配缓存 ...

    // 栈缓存:按 2^n 大小分桶(2KB, 4KB, 8KB, 16KB, 32KB...)
    stackcache [_NumStackOrders]stackfreelist
}

type stackfreelist struct {
    list gclinkptr  // 空闲栈链表
    size uintptr    // 缓存了多少字节
}
1
2
3
4
5
6
7
8
9
10
11
12

stackalloc 的分配路径:

stackalloc(n)
  → order = log2(n / _FixedStack)  // 2KB=order 0, 4KB=1, 8KB=2...
  → mcache.stackcache[order] 有空闲?
     ├─ 有 → 从链表拿 → O(1) 返回
     └─ 无 → mheap 分配一个新的 n 字节栈空间
             → 自动在栈底设置守护页
1
2
3
4
5
6

stackfree 的释放路径:

stackfree(stack)
  → order = log2(size / _FixedStack)
  → mcache.stackcache[order] 还有空间?
     ├─ 有 → 放回链表 → O(1)
     └─ 无(缓存满了)→ 归还 mheap
1
2
3
4
5

复用效果:goroutine 创建/销毁、栈扩容/缩容都非常高频。stackcache 让"刚释放的 32KB 栈"立即被"下一个需要 32KB 栈的 goroutine"复用——避免了频繁的 mmap/munmap。在第 1 章的风控案例中,25000 个 goroutine 需要的 6.4GB 栈空间——如果有 stackcache,已释放的栈可以立即被新建的 goroutine 复用(而不是重新 mmap),大幅降低物理内存压力。

# 7. 栈溢出与守护页保护

# 7.1 守护页的物理隔离

每个 goroutine 栈的最低位(低地址端)有一个守护页(guard page)——权限为 ---(不可读写不可执行):

高地址
  ┌─────────────────────────────────┐
  │                                 │
  │     goroutine 栈(rw-)          │
  │     从 stack.lo 到 stack.hi     │
  │     ← SP 在栈顶向栈底生长        │
  │                                 │
  ├─────────────────────────────────┤ ← stack.lo
  │                                 │
  │     守护页 4KB(---p)           │  ← 不可访问!
  │                                 │
  ├─────────────────────────────────┤
  │     下一个 mmap 区域(可能是     │
  │     堆 / 其他 goroutine 的栈)    │
  └─────────────────────────────────┘
低地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

守护页设置(runtime/stack.go):

func stackalloc(n uint32) stack {
    v := mheap_.allocManual(n, spanAllocStack)
    // v 是 n 页的内存
    // 最低地址的那一页设为守护页
    sysFault(unsafe.Pointer(v), _PageSize)  // 4KB ---p
    // 栈从 v + _PageSize 开始
    return stack{lo: uintptr(v) + _PageSize, hi: uintptr(v) + n*_PageSize}
}
1
2
3
4
5
6
7
8

sysFault 在 Linux 上调用 mprotect(page, _PageSize, _PROT_NONE)——内核在页表项中去掉 PTE 的 present/rw 位。任何对该页的访问都会触发 SIGSEGV。

# 7.2 栈溢出的三种检测

Go 有三种机制防止栈溢出,按"从软到硬"排列:

第一层:序言检查(最常用)

每个函数入口的 CMP SP, stackguard0——在 SP 触及 StackGuard 线之前触发扩容。这是"软保护":栈还没真溢出,只是"快不够了"。

第二层:扩容上限检测(逻辑保护)

newstack 中检查 newsize > maxstacksize(1GB)——如果倍增后超过 1GB,说明栈已经异常大(无限递归)。此时不是操作系统杀进程——是 Go runtime 主动 throw:

// runtime/stack.go
if newsize > maxstacksize {
    print("runtime: goroutine stack exceeds 1000000000-byte limit\n")
    print("runtime: sp=", hex(gp.sched.sp), " stack=[", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
    throw("stack overflow")
}
1
2
3
4
5
6

第三层:守护页 SIGSEGV(硬件保护)

如果前两层都失效了(极端 bug,比如 NOSPLIT 函数栈帧太大逃过编译检查),栈指针会一路向下踩到守护页。SIGSEGV → Go runtime 的信号处理函数捕获:

// runtime/os_linux.go
func sigsegv(pc uintptr, addr uintptr, ...) {
    if gp != nil && gp.stack.lo-_PageSize <= addr && addr < gp.stack.lo {
        // 访问地址在守护页范围内 → 栈溢出
        print("fatal: morestack on g0\n")
        throw("stack overflow")
    }
    // 否则 → 普通的 nil 解引用或其他
    panicmem()
}
1
2
3
4
5
6
7
8
9
10

三层防护保证了诊断质量:大部分溢出在第二层被捕获(打印调用栈),极少情况落到第三层(但 Go runtime 仍能在 SIGSEGV 处理中识别出栈溢出并打印信息)。

# 7.3 stackOverflow panic 路径

与"栈上扩容超过 1GB 上限"不同,还有一种更优雅的溢出检测——某些场景可以通过 runtime.StackOverflow 类型发出 panic(而非直接 fatal):

// 用户代码可以设置栈溢出回调
debug.SetMaxStack(64 * 1024 * 1024)  // 64MB 上限(低于 1GB)
1
2

当 goroutine 的栈即将超过 64MB 时,runtime 会 panic runtime: stack overflow——这让用户有机会通过 recover 捕获并优雅处理,而不是直接 crash。

# 8. 栈拷贝的汇编视角

# 8.1 编译期栈帧信息嵌入

Go 编译器在编译每个函数时,除了生成机器码,还在二进制中嵌入栈帧元数据(funcdata 区域)。go tool compile -S 可以看到但看不到 funcdata——用 objdump 或 readelf 查看:

$ go build -o app stack_probe.go
$ readelf -p .gopclntab app | head -20
# 或者
$ go tool objdump -s main.compute app
1
2
3
4

funcdata 包含的关键信息:

数据类型 用途 大小
pcsp PC→SP 映射(给定 PC,对应栈帧多大) 每函数一条
pcfile PC→源文件行号映射 每指令一条
pcln PC→函数名映射 每函数一条
stackmap 栈帧指针位图(哪些偏移是指针) 每安全点一份
argmap 参数/返回值指针位图 每函数一条

stackmap 就是 copystack 中 adjustframe 遍历的数据来源。

# 8.2 函数序言的汇编模板

以 amd64 为例,编译器为函数生成的序言有三种情况:

情况 A:普通函数(栈帧 ≤ 栈检查阈值)

TEXT main·normalFunc(SB), $40-16   ; 栈帧 40 字节
    MOVQ    (TLS), CX
    CMPQ    SP, 16(CX)             ; 检查 guard
    JHI     ok
    CALL    runtime·morestack_noctxt(SB)
ok:
    SUBQ    $40, SP
    ; 函数体 ...
    ADDQ    $40, SP
    RET
1
2
3
4
5
6
7
8
9
10

情况 B:NOSPLIT 函数

TEXT runtime·morestack(SB), NOSPLIT, $0-0
    ; ← 没有栈检查!
    ; 直接使用当前栈空间——因为在栈溢出处理中不能再触发溢出检查
    ...
1
2
3
4

情况 C:大栈帧函数(栈帧 > 栈检查阈值)

TEXT main·bigFrame(SB), $10000-0   ; 栈帧 10KB
    MOVQ    (TLS), CX
    CMPQ    SP, 16(CX)             ; 栈检查
    JHI     ok
    CALL    runtime·morestack_noctxt(SB)
ok:
    SUBQ    $10000, SP
    ; ...
1
2
3
4
5
6
7
8

注意:即使是 10KB 栈帧的大函数,序言检查代码完全一样——CMP SP, 16(CX) 不关心函数栈帧有多大,只关心"SP 最低能降到哪"。

# 8.3 栈扩容中的寄存器上下文

morestack 保存的寄存器在不同架构上有差异。以 amd64 为例:

// 保存的寄存器(caller-saved)
AX, CX, DX, BX, SI, DI, R8-R15  → 全部保存到 G.sched 中或压入栈

// 不需要保存的寄存器(callee-saved + 特殊寄存器)
// BP → 通过 G.sched.bp 保存
// SP → 通过 G.sched.sp 保存
// PC → 通过 G.sched.pc 保存
1
2
3
4
5
6
7

在 copystack 完成后,gogo(&G.sched) 恢复所有这些寄存器——"就像什么都没发生过一样"。

# 9. 诊断与排查实战

# 9.1 GODEBUG 栈事件追踪

GODEBUG=schedtrace=1000 可以观测到 goroutine 的栈扩容和缩容事件:

$ GODEBUG=schedtrace=1000 ./risk_engine 2>&1 | grep "stack"
1

输出示例:

SCHED 1000ms: gomaxprocs=16 ...
  G 4872: status=3(stack growth)     ← G 正在栈扩容
  G 9123: status=3(stack growth)
  G 10456: status=4(stack shrink)    ← G 正在栈缩容
1
2
3
4

G 状态码(runtime/runtime2.go):

状态 值 含义
_Gidle 0 G 刚创建,还没初始化
_Grunnable 1 在运行队列中等待 P
_Grunning 2 正在执行
_Gsyscall 3 在执行系统调用
_Gwaiting 4 阻塞在 channel/锁/网络 IO
_Gdead 6 已退出,可复用
_Gcopystack 8 正在栈拷贝(扩容或缩容)

更详细的栈信息——GODEBUG=stacktrace=1(需要在源码中插入的调试选项,部分 Go 版本支持):

另一种方式——用 runtime.ReadMemStats 的 StackInuse 和 StackSys 字段在程序中统计:

package main

import (
    "runtime"
    "time"
)

func monitorStack() {
    ticker := time.NewTicker(5 * time.Second)
    for range ticker.C {
        var m runtime.MemStats
        runtime.ReadMemStats(&m)
        fmt.Printf("StackInuse: %d MB, StackSys: %d MB\n",
            m.StackInuse>>20, m.StackSys>>20)
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 9.2 pprof goroutine 栈分析

pprof goroutine profile 可以揭示每个 goroutine 的栈大小:

$ go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
Showing nodes accounting for 24872, 99.5% of 25000 total
      flat  flat%   sum%        cum   cum%
      12350 49.40% 49.40%      12350 49.40%  main.(*Portfolio).Value
       6200 24.80% 74.20%       6200 24.80%  runtime.gopark
       3800 15.20% 89.40%       3800 15.20%  runtime.morestack
1
2
3
4
5
6
7

runtime.morestack 占 15%——说明大量 goroutine 正在或刚经历过栈扩容。

查看单个 goroutine 的调用深度:

$ curl http://localhost:6060/debug/pprof/goroutine?debug=2 | head -60
goroutine 4872 [running]:
main.(*Portfolio).Value(0xc0001a4000, 0xc0)
    /app/risk_engine.go:32 +0x8f
main.(*Portfolio).Value(0xc0001a4100, 0xbf)
    /app/risk_engine.go:35 +0xcb
main.(*Portfolio).Value(0xc0001a4200, 0xbe)
    /app/risk_engine.go:35 +0xcb
... (200 层递归) ...
1
2
3
4
5
6
7
8
9

200 层递归清晰地呈现在栈帧链中。

# 9.3 栈规模异常的识别模式

模式 1:深递归导致大栈

# GODEBUG=schedtrace=1000 输出中大量 G 处于 stack growth 状态
# goroutine profile 中单个函数的 cum >= 50%
# → 可能是深递归导致频繁扩容
1
2
3

模式 2:缩容风暴

$ go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
(pprof) top
      flat  flat%   sum%        cum   cum%
    12.50s 21.74% 21.74%     28.90s 50.26%  runtime.copystack
# → copystack 占比异常高:GC 触发了大量缩容
1
2
3
4
5

模式 3:栈缓存的"伪泄漏"

// 观测:StackSys 持续增长,但 goroutine 数稳定
// 原因:stackcache 缓存了大量释放的栈,没有归还 mheap
// 验证:手动触发 GC + FreeOSMemory,看 StackSys 是否下降
runtime.GC()
debug.FreeOSMemory()
1
2
3
4
5

模式 4:NOSPLIT 溢出

# 链接时报错:
# main.bigNOSPLITFunction: nosplit stack over 792 byte limit
# main.bigNOSPLITFunction<1> grows non-linear, 808 bytes
# → NOSPLIT 函数栈帧太大,需要去掉 NOSPLIT 或减小帧
1
2
3
4

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章风控系统的七个疑问,逐条作答:

疑问 答案
① 栈分裂检查的汇编长什么样? 第 3.1:MOVQ (TLS),CX; CMPQ SP,16(CX); JHI ok; CALL morestack
② morestack 怎么保存上下文? 第 4.1:PC/SP/BP 写入 G.sched,切到 g0 栈执行 newstack
③ newstack 如何决定扩多大? 第 4.3:2× 倍增,从 2KB 到 1GB 只需 19 次扩容
④ copystack 怎么调整指针? 第 5.2:funcdata 位图 + adjustpointer → 所有指向旧栈的指针加 delta
⑤ 缩容的条件是什么? 第 6.1:GC 扫描时 used < total/4 且 total > 2KB → 缩一半
⑥ 守护页怎么防溢出? 第 7.1:栈底 4KB mprotect(PROT_NONE),SIGSEGV 被 runtime 捕获
⑦ GODEBUG 怎么看栈操作? 第 9.1:schedtrace 显示 G 状态 3(stack growth)/4(stack copy)

案例完整根因链条:

机构数据导致 Portfolio 200 层嵌套
  → 每个 goroutine 的 Value() 递归 200 层
  → 每个 goroutine 栈从 2KB 扩容到 256KB(经过 ~7 次扩容)
  → 500 并发 × 50 goroutine = 25000 个 goroutine,每个 256KB → 6.4GB 栈空间
  → GC 触发 → 扫描 25000 个栈 → 80% 使用量 < 64KB
  → shrinkstack 判定 used < total/4 → 缩容到 128KB 或更小
  → 20000 次 copystack ≈ 20000 × (2µs copy + 6µs adjust) ≈ 160ms CPU
  → 但 16 核上每个 P 串行执行 → 实际时间被放大到数秒
  → 且缩容后下一次请求又扩容 → "缩→扩"循环
  → CPU 打满 → P99 2.3s → OOM Kill
1
2
3
4
5
6
7
8
9
10

优化方案:

// 方案 A:为已知深递归的 goroutine 预分配足够栈(Go 1.19+)
debug.SetMemoryLimit(12 * 1024 * 1024 * 1024) // 12GB

// 方案 B:限制递归深度,转为迭代或分批
func (p *Portfolio) ValueIterative() float64 {
    stack := []*Portfolio{p}
    total := 0.0
    for len(stack) > 0 {
        // 用显式栈替代递归调用栈
        cur := stack[len(stack)-1]
        stack = stack[:len(stack)-1]
        // ...
    }
    return total
}

// 方案 C:限制单次请求的 goroutine 数
sem := make(chan struct{}, 10)  // 最多 10 个并行 goroutine
for i, sub := range port.SubPorts {
    sem <- struct{}{}
    wg.Add(1)
    go func(idx int, sp *Portfolio) {
        defer func() { <-sem; wg.Done() }()
        results[idx] = sp.Value(0)
    }(i, sub)
}

// 方案 D:避免缩容风暴——业务侧预热 goroutine 池
// 让栈稳定在 256KB,不要缩了又扩
pool := make(chan func(), 50)
for i := 0; i < 50; i++ {
    go func() {
        // 先递归一次让栈涨到目标大小
        warmUp(200)
        for fn := range pool {
            fn()
        }
    }()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

# 10.2 一次栈扩容的完整路径

以风控系统中一个 goroutine 的 Value() 第 50 次递归调用触发的栈扩容为例:

goroutine G4872 的栈:当前大小 128KB,使用了 126KB
────────────────────────────────────────────────
1. 第 50 次递归调用 Value()
     → 编译器插入的序言:CMP SP, G4872.stackguard0
     → SP = stack.lo + 960(还剩 960 字节可用空间)
     → stackguard0 = stack.lo + 928
     → 960 > 928 → JHI ok → 正常执行 ✓

2. 第 51 次递归调用 Value()
     → stackguard0 = stack.lo + 928 = 同一个值
     → 第 50 层的栈帧消耗了 40 字节 → SP = lo + 920
     → 920 < 928 → 触发 CALL runtime.morestack_noctxt

3. morestack 保存 G4872 上下文
     → G4872.sched.pc = 0x4a2f80(Value() 中 ok 标签后的地址)
     → G4872.sched.sp = lo + 920
     → G4872.sched.bp = ...
     → 切换到 g0 栈执行

4. newstack 计算新大小
     → oldsize = 128KB, used = 126KB
     → newsize = 128KB × 2 = 256KB
     → newsize < 1GB → 通过

5. stackalloc(256KB)
     → order = log2(256KB/2KB) = 7
     → mcache.stackcache[7] 有缓存?无 → mheap 分配

6. copystack(G4872, 256KB)
     → 分配新栈 256KB
     → memmove: 拷贝 126KB 使用量
     → gentraceback + adjustframe:
         遍历 51 层栈帧 → 每帧 ≈ 3~5 个指针
         → 找到 51×4 ≈ 204 个指针中的 ~150 个指向旧栈
         → 每个加 delta = 新基址 - 旧基址
     → 调整 G4872._defer 链:1 个 defer(defer wg.Done())
     → 调整 G4872._panic 链:0 个
     → G4872.stack = {newLo, newHi}
     → G4872.stackguard0 = newLo + 928

7. stackfree(旧栈 128KB)
     → mcache.stackcache[6] 未满 → 放入链表

8. gogo(&G4872.sched)
     → SP = newLo + (126KB - 128B) = 新栈上的对应位置
     → PC = 0x4a2f80
     → "就像什么都没发生过"——Value() 第 51 次递归继续执行

总耗时:约 1.5μs(拷贝)+ 2μs(指针遍历)= 3.5μs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

# 10.3 设计哲学回扣

哲学 1:用类型信息换取运行时安全

Go 的连续栈能工作,根本前提是编译器在编译期把"哪个偏移是指针"嵌入二进制。这是一个**编译期付出(更大的二进制),换取运行时能力(安全地移动栈)**的经典权衡。C 语言没有这个选择——因为 C 从一开始就没在二进制里存类型信息,到现在也加不进去(ABI 兼容性问题)。Go 作为新语言,从第一天就设计了这个机制——这是"设计的先发优势"。

哲学 2:用倍增避免频繁操作

2× 倍增扩容 + 1/4 缩容阈值——这是一对精心设计的搭档。倍增让扩容次数趋近 O(log N),1/4 阈值让缩容免于抖动。如果扩容是固定增量(+4KB),深递归需要数百次扩容;如果缩容阈值是 1/2,会出现"缩→扩→缩"循环。这两个数字不是拍脑袋——是 Go 团队观察了编译器、Docker、Kubernetes 等真实负载后选定的。

哲学 3:用硬件能力兜底软件逻辑

三层栈溢出检测(序言→上限→守护页)体现了防御深度。序言检查覆盖 99.99% 的正常扩容;上限检测捕获无限的递归;守护页是最后一道物理防线。每一层失效概率低,三层同时失效的概率趋近于零——且即使最底层被触发,runtime 仍能打印调用栈(不像 C 的 segfault)。

哲学 4:让"正常路径"几乎零开销

每个函数调用都执行 MOVQ + CMPQ + JHI——这三条指令在 CPU 预测命中(栈够用)的情况下约 0.3ns。对于 99.9% 不会触发扩容的函数调用,这个成本可以忽略。Go 的设计哲学是:"为常见情况做极速优化,让不常见情况正确工作"——序言检查完美体现了这一点。

# 10.4 速查表

栈参数速查:

参数 值 说明
初始栈 2KB _FixedStack,Go 1.4+
扩容倍数 2× 倍增,从 2KB 到 1GB 需 ~19 次
最大栈 1GB maxstacksize(64位)
缩容阈值 used < total/4 防止抖动
缩容大小 total/2 缩一半,保底 2KB
栈守护页 4KB 页级别 mprotect(PROT_NONE)
StackGuard 928 字节 _StackGuard = 928
StackSmall 128 字节 NOSPLIT 函数的余量
序言检查 ~0.3ns CPU 预测命中时
copystack 成本 ~3.5μs (128KB) 含 memmove + adjustpointers

栈缓存层级:

层级 缓存单元 粒度
stackcache 每 P 的 mcache 中 按 2^n 分桶(2KB/4KB/8KB...)
mheap 全局堆 整页分配
OS mmap/munmap 页粒度 4KB

诊断命令:

# 栈操作追踪
GODEBUG=schedtrace=1000 ./app        # 每秒输出调度状态,含栈操作 G 状态

# goroutine 栈 profile
go tool pprof http://localhost:6060/debug/pprof/goroutine
# → (pprof) top 看哪些函数栈帧最"重"
# → (pprof) list funcName 看具体调用链

# CPU profile 找栈拷贝热点
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# → runtime.copystack 占比 > 5% → 缩容风暴
# → runtime.morestack 占比 > 3% → 扩容频繁

# 栈内存统计
curl http://localhost:6060/debug/pprof/heap?debug=1 | grep Stack

# 强制缩容验证
GODEBUG=gcstoptheworld=1 ./app     # GC 会在 STW 中缩容

# 汇编窥视
go tool compile -S file.go          # 看序言 CMP SP, stackguard0
go tool objdump -s funcName app     # 看完整汇编
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

下一篇:我们已经把 goroutine 栈的每一次"生长"和"收缩"拆到了汇编级别,下一步进入 23.panic 与 recover 内部实现——看看当栈上的代码触发 panic 时,defer 链表如何被遍历、栈帧如何被展开、以及 recover 如何在"即将崩溃"的瞬间把控制权抢回来。

#Go
上次更新: 2026/06/13, 21:14:36
抢占式调度器原理
上下文取消与传播

← 抢占式调度器原理 上下文取消与传播→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式