一、 理论上的“卡顿”时刻:GC 周期中的 STW
每个 GC 周期的开始和结束,都有两个必须 Stop The World (STW) 的阶段。这是程序在理论上“绝对”会停顿的时刻。
- 标记准备阶段 (Sweep Termination)
- 标记终止阶段 (Mark Termination)
二、 实际中的“加剧”因素:为什么你会感觉到明显的卡顿?
在正常情况下,上述 STW 仅为微秒级。但当以下因素出现时,停顿时间会延长,或者即便没有 STW,你也会感觉到明显的性能下降(卡顿感)。
延长 STW 时间的因素
这类因素会导致程序完全停止运行,表现为极高的 P99 延迟,甚至程序瞬间“假死”。
- 抢占延迟 (Preemption Delay)
- 原因:在进入 STW 前,GC 必须等待所有 Goroutine 停在安全点。如果某个 Goroutine 处于无法被抢占的状态(如紧凑循环、长时间 CGO 调用),会导致整个程序停下来等它一个。
- 根对象扫描负担 (Root Scanning)
- 原因:STW 期间需要扫描栈、全局变量。
- 海量 Goroutine:如果你有几十万个 Goroutine,即便每个栈扫描很快,累加起来的 STW 也会变得可观。
- 巨大的全局变量:存储了大量指针的全局数据结构。
- 原因:STW 期间需要扫描栈、全局变量。
- 标记终止阶段的收尾工作 (Mark Termination)
- 原因:在 STW 阶段需要完成最后的标记清理。如果并发阶段没能有效处理完复杂的指针引用,最后的收尾工作就会变重。
导致性能下降/响应变慢的因素
这类因素通常不会停止程序,但会显著消耗系统资源,导致业务逻辑变慢,表现为吞吐量下降或整体响应时间(Average/P90 Latency)拉长。
- GC 协助标记 (GC Assist / Mark Assist)
- 机制:当分配内存过快时,Go 会扣除申请内存的协程的 CPU 时间,强迫它去帮 GC 干活。
- 影响:这是最直接的“性能损耗”。用户请求的响应时间会因为被拉去干活而变长。
- 写屏障开销 (Write Barrier Overhead)
- 机制:在标记期间,所有的指针赋值(如
a.ptr = b)都会触发额外的函数调用。 - 影响:对于写密集的业务(如频繁更新缓存、修改复杂 Tree/Graph 结构),CPU 效率会下降。
- 机制:在标记期间,所有的指针赋值(如
- 后台标记 worker 抢占 CPU (Mark Workers)
- 机制:GC 会默认占用约 25% 的 CPU 核心用于后台标记。
- 影响:业务逻辑可用的 CPU 资源变少,在高负载情况下会加剧 CPU 竞争。
- 内存碎片与分配压力
- 机制:由于 GC 无法进行内存整理(Non-moving),频繁的分配会导致碎片化。
- 影响:这会导致
mallocgc在分配内存时需要遍历更多的数据结构来寻找空闲空间,增加了分配本身的开销。
总结对比
| 类别 | 表现形式 | 用户感受 | 核心指标 |
|---|---|---|---|
| 延长 STW | 程序完全静止 | 尖锐的 P99 延迟,偶尔“假死” | pause_ns |
| 性能下降 | 响应变慢,吞吐降低 | 整体运行沉重,P50/P90 上升 | mark assist / CPU utilization |