服务明明很慢,CPU 火焰图为什么找不到热点?

一个接口的 P99 延迟从 80 ms 升到 2 s,进程 CPU 却只有 35%。工程师抓了一张 CPU 火焰图,没看到明显的业务计算热点,于是得出结论:“代码没问题,应该是网络抖动。”但继续查看线程后才发现,大量请求都阻塞在连接池和互斥锁上——这些时间根本不属于 CPU 火焰图的主要采样对象。

这正是火焰图最常见的误用:把一种采样事件画出的宽度,误当成程序所有时间的分布。CPU 火焰图回答“线程在 CPU 上运行时做了什么”,Off-CPU 火焰图回答“线程离开 CPU 后在哪里等待”,分配图和锁图又分别观察不同事件。选错事件,图画得再漂亮也不会出现真正的瓶颈。

火焰图不是一个实时监控指标,而是一种 采样 Profiling 结果的可视化方式。它适合回答的问题是:

CPU 时间、阻塞时间、内存分配或锁等待,最终都消耗在哪些调用链上?

系统监控通常先告诉我们“哪里异常”,火焰图进一步告诉我们“代码为什么异常”。它应该放在 topvmstatpidstatiostat、业务指标之后使用,而不是一上来就对所有进程采样。

本文的 perf、BCC/eBPF 和 /proc 命令面向 Linux。当前整理环境是 macOS,因此只对命令含义和文档进行核对,没有在本机伪造采样结果;生产环境还要结合内核、perf、BCC 版本与权限实际验证。


1. 第一件事不是找最宽的框,而是先读对图

一张典型火焰图中:

位置 含义
横向宽度 采样命中的次数占比,越宽表示越热
纵向高度 调用栈深度,不代表耗时长短
底部 调用链入口或线程根部
顶部 当前正在执行或等待的位置
颜色 通常只是为了区分块,不一定有语义

需要注意:

  • 横轴不是时间线,左右顺序没有先后含义。
  • 宽度表示“采样占比”,不是单次函数耗时。
  • 一个很宽的函数不一定是问题,它可能只是所有请求都会经过的公共入口。
  • 真正有价值的是找到从业务入口一路向上的热点调用链。

火焰图常见类型:

类型 看什么问题 常见工具
CPU 火焰图 CPU 时间消耗在哪些函数 perfasync-profilerpy-spy
Off-CPU 火焰图 线程阻塞、等待、睡眠在哪里 bcc offcputime、eBPF 工具
内存火焰图 内存分配来自哪些调用链 heaptrackjemalloc profasync-profiler
锁火焰图 锁竞争和等待在哪里 eBPF、语言运行时 profiler

2. 使用前先确认问题类型

不要只因为“服务慢”就直接抓火焰图。建议先用监控指标判断方向:

1
2
3
4
5
6
7
业务延迟升高 / 吞吐下降

看 CPU、Load、内存、I/O、网络、错误率

确认是 CPU 忙,还是线程在等待

选择 CPU 火焰图、Off-CPU 火焰图或其他 profiler

简单判断:

现象 优先方向
单核或多核 CPU 长时间打满 CPU 火焰图
Load 很高但 CPU 使用率不高 Off-CPU、I/O、锁等待
%iowait 磁盘、网络存储、同步写
线程数很多,CPU 不高,延迟高 锁等待、连接池、下游阻塞
内存持续增长或频繁 OOM 内存分配和泄漏分析

火焰图要和业务时间窗口对应起来。例如报警发生在 14:0314:08,采样也应尽量覆盖这个窗口,而不是事后在正常流量下采样。


3. C++ 程序采样前的准备

C++ 程序能不能得到清晰调用栈,主要取决于符号和栈回溯能力。

3.1 编译参数

建议保留调试符号,并避免省略 frame pointer:

1
-g -fno-omit-frame-pointer

常见组合:

1
CXXFLAGS="-O2 -g -fno-omit-frame-pointer"

说明:

  • 不需要为了 profiling 关闭优化,生产性能分析通常仍使用 -O2 或项目默认优化级别。
  • -g 用于保留符号信息,方便把地址解析成函数名和源码位置。
  • -fno-omit-frame-pointer 能让 perf -g 更稳定地回溯调用栈。
  • 如果线上二进制不能带完整 debug 信息,可以使用单独的 debug symbol 包。

3.2 最小可运行目标:同一个程序里同时制造计算和等待

先用一个可控程序验证采样链路,比直接在复杂服务上猜 [unknown] 更容易。下面的程序会运行约 30 秒:burn_cpu() 持续进行计算,wait_for_work() 模拟等待下游或队列。

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
#include <chrono>
#include <cstdint>
#include <iostream>
#include <thread>

#include <unistd.h>

__attribute__((noinline))
std::uint64_t burn_cpu(std::uint64_t value) {
for (int i = 0; i < 20'000'000; ++i) {
value ^= value << 13;
value ^= value >> 7;
value ^= value << 17;
}
return value;
}

__attribute__((noinline))
void wait_for_work() {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}

int main() {
std::cout << "pid=" << getpid() << '\n' << std::flush;
const auto deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(30);

std::uint64_t checksum = 1;
while (std::chrono::steady_clock::now() < deadline) {
checksum ^= burn_cpu(checksum);
wait_for_work();
}

std::cout << "checksum=" << checksum << '\n';
}

在 Linux 上使用 GCC 或 Clang 编译:

1
2
3
clang++ -std=c++20 -O2 -g -fno-omit-frame-pointer \
profiler_demo.cpp -o profiler_demo
./profiler_demo

程序会打印 PID。在另一个终端用该 PID 采样:CPU 图中应能看到 burn_cpu(),Off-CPU 图中则应看到包含 wait_for_work()/睡眠路径的调用栈。__attribute__((noinline)) 是 GCC/Clang 扩展,只用于让演示函数在优化构建中保留清晰栈帧;MSVC 需要使用对应属性。

这个例子也说明:同一个进程既可能在计算,也可能在等待。选择哪张图取决于你要解释的是哪部分时间,而不是程序使用了哪种语言。

3.3 系统权限

Linux 上 perf 可能受内核参数限制:

1
2
cat /proc/sys/kernel/perf_event_paranoid
cat /proc/sys/kernel/kptr_restrict

如果权限不足,可能出现无法采样内核栈、无法 attach 目标进程或符号不完整。生产环境不要随意全局放开权限,应按公司规范申请临时权限或在专用诊断机器上复现。


4. CPU 火焰图:perf + FlameGraph

这是 Linux C/C++ 服务最常见的方式。

4.1 找到目标进程

1
2
3
ps -ef | grep <process_name>
top -Hp <PID>
pidstat -p <PID> 1

如果只是某个线程 CPU 高,先记下线程 ID:

1
top -Hp <PID>

top 里的线程 ID 是十进制 TID,后续可以用 perf/proc/<PID>/task/<TID> 继续看。

4.2 采样目标进程

1
perf record -F 99 -g -p <PID> -- sleep 30

参数含义:

参数 含义
-F 99 每秒采样 99 次,避免和固定周期产生明显共振
-g 记录调用栈
-p <PID> 只采样目标进程
sleep 30 采样 30 秒后自动停止

生产环境建议:

  • 先采样 15 到 60 秒,不要长时间抓取。
  • 优先指定进程,不要直接全机采样。
  • 高峰期采样要控制频率和时长。
  • 采样期间记录业务 QPS、延迟、错误率,方便对照。

4.3 先用 perf report 快速查看

生成火焰图前,可以先看文本报告:

1
perf report

或输出为文本:

1
perf report --stdio

如果这里已经能看到明显热点,就不一定必须生成火焰图。

4.4 生成火焰图

需要 Brendan Gregg 的 FlameGraph 脚本:

1
git clone https://github.com/brendangregg/FlameGraph.git

生成流程:

1
2
3
perf script > out.perf
FlameGraph/stackcollapse-perf.pl out.perf > out.folded
FlameGraph/flamegraph.pl out.folded > cpu-flame.svg

然后用浏览器打开:

1
2
xdg-open cpu-flame.svg   # Linux 桌面环境
open cpu-flame.svg # macOS 本地查看

在 Linux 服务器上可以把 cpu-flame.svg 下载到本地查看。SVG 支持搜索函数名,适合从业务函数、库函数或系统调用一路展开。


5. 常用 perf 采样模式

5.1 采样整个系统

适合不知道哪个进程消耗 CPU 的场景:

1
perf record -F 99 -a -g -- sleep 30

-a 表示 all CPUs。这个模式开销和输出量更大,生产环境要谨慎。

5.2 采样指定线程

1
perf record -F 99 -g -t <TID> -- sleep 30

适合一个进程中只有某个工作线程异常的场景。

5.3 使用 DWARF 回溯

如果程序没有 frame pointer,可以尝试:

1
perf record -F 99 --call-graph dwarf -p <PID> -- sleep 30

DWARF 回溯通常开销更大,也更依赖 debug 信息。长期来看,更推荐在线上构建中保留 frame pointer。

5.4 观察热点事件

默认采样通常是 CPU cycles。也可以指定事件:

1
2
perf record -e cycles -F 99 -g -p <PID> -- sleep 30
perf record -e cache-misses -F 99 -g -p <PID> -- sleep 30

大多数业务排查先从默认 CPU 采样开始即可,不要一开始就采各种硬件事件。


6. Off-CPU 火焰图:看线程在哪里等待

CPU 火焰图只能告诉我们“线程在 CPU 上运行时做了什么”。如果服务延迟很高,但 CPU 不高,线程可能在:

  • 等锁
  • 等磁盘 I/O
  • 等网络返回
  • 等条件变量
  • 等连接池或队列
  • 被调度器挂起

这种情况需要 Off-CPU 火焰图。

常见方式是使用 bcc/eBPF 工具:

1
2
offcputime -f -p <PID> 30 > offcpu.folded
FlameGraph/flamegraph.pl --color=io --title="Off-CPU Time" offcpu.folded > offcpu.svg

含义:

参数 含义
-f 输出 folded stack,便于直接生成火焰图
-p <PID> 只观察目标进程
30 采样 30 秒

BCC offcputime 的 folded 输出会把相同阻塞调用栈聚合,并以累计 Off-CPU 微秒数作为计数,因此图中的宽度表示该采样窗口内聚合的阻塞时间占比。-d 在当前 BCC 脚本中表示在用户栈和内核栈之间插入分隔符,并不表示 duration;不同发行版打包的脚本参数可能不同,执行前应先运行 offcputime --help

Off-CPU 图适合定位“慢在等待哪里”,但它仍需结合等待原因、业务请求和唤醒者分析。例如同一条 futex 栈既可能是正常条件变量等待,也可能是严重锁竞争;仅看函数名还不足以下结论。


7. 容器环境中的注意点

容器内分析火焰图经常遇到三个问题:

  1. 容器没有 perf、bcc 或对应权限。
  2. PID namespace 导致宿主机 PID 和容器内 PID 不一致。
  3. 二进制和 debug symbol 在容器内,采样工具在宿主机上,符号解析不完整。

常见做法:

1
2
3
4
5
# 在宿主机找容器主进程
docker inspect --format '{{.State.Pid}}' <container>

# 在宿主机看目标进程树
pstree -p <host_pid>

如果在宿主机上对容器进程采样,要使用宿主机视角下的 PID。符号解析不完整时,需要把容器内二进制、动态库和 debug symbol 路径对应好。

Kubernetes 环境中通常通过临时调试容器、节点侧诊断工具或平台提供的 profiling 能力完成,不建议直接改业务容器镜像来塞大量诊断工具。


8. 语言运行时的火焰图工具

不同语言有更适合自己的 profiler。混合栈项目应优先使用能理解语言运行时的工具。

语言 / 场景 推荐工具 说明
C / C++ perf、eBPF、heaptrack 最通用,适合 native 服务
Java async-profiler 支持 CPU、wall、lock、alloc 等事件
Python py-spyscalene 可以无侵入 attach Python 进程
Go pprof 运行时内置支持较好
Node.js 0xclinic flame 更适合 V8 栈

例如 Go 服务通常不需要先上 perf,可以直接用:

1
go tool pprof -http=:8080 http://127.0.0.1:6060/debug/pprof/profile?seconds=30

Java 服务可以用 async-profiler 生成 HTML:

1
./profiler.sh -d 30 -e cpu -f cpu.html <PID>

9. 常见结论怎么解释

9.1 热点集中在业务函数

说明 CPU 主要花在业务逻辑上。继续看:

  • 是否有重复计算
  • 是否有低效循环或算法复杂度问题
  • 是否可以减少序列化、拷贝、格式转换
  • 是否存在不必要的日志拼接或字符串处理

9.2 热点集中在 malloc / free

可能是频繁小对象分配:

  • 检查请求路径中是否大量创建临时对象。
  • 复用 buffer,减少短生命周期分配。
  • 检查容器扩容和字符串拼接。
  • 必要时使用内存 profiler 看分配来源。

9.3 热点集中在锁相关函数

例如 pthread_mutex_lockfutex

  • 可能存在锁竞争。
  • 检查锁粒度是否过大。
  • 检查临界区里是否做了 I/O、日志或复杂计算。
  • CPU 图看到 futex 不一定足够,需要结合 Off-CPU 图确认等待时间。

9.4 热点集中在系统调用

例如 readwritesendrecvepoll_wait

  • 阻塞中的 epoll_wait 不会持续消耗 CPU 采样;如果它在 CPU 火焰图中仍然很宽,应检查高频唤醒、空轮询或系统调用开销,并用 Off-CPU 数据确认真实等待时间。
  • read/write/send/recv 宽时,要结合吞吐、包量、系统调用次数看。
  • 同步日志、频繁小包、频繁文件 I/O 都可能放大系统调用成本。

9.5 栈大量显示 [unknown]

通常是符号或栈回溯问题:

  • 二进制缺少符号。
  • 没有 frame pointer。
  • 动态库符号不可见。
  • JIT 语言没有开启对应符号映射。
  • 容器和宿主机路径不一致。

10. 生产环境使用规范

火焰图采样有开销,线上使用要遵守最小化原则:

  • 明确目标:先知道要采哪个进程、哪个时间窗口。
  • 控制范围:优先 -p <PID>,谨慎使用 -a
  • 控制时间:通常 15 到 60 秒足够。
  • 控制频率:CPU 图常用 -F 49-F 99-F 199
  • 留存上下文:记录采样时间、机器、进程、版本、流量和告警。
  • 不在未知影响下长时间运行 profiler。
  • 不在生产容器内临时安装大量工具,优先使用平台诊断能力。

一次采样记录建议包含:

1
2
3
4
5
6
7
8
9
10
11
服务名:
机器 / Pod:
进程 PID:
采样类型:CPU / Off-CPU / Alloc / Lock
采样命令:
采样时间:
当时 QPS:
P95 / P99 延迟:
CPU / Load / 内存 / I/O 概况:
结论:
后续动作:

11. 推荐排查流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 业务指标确认问题

2. 系统指标判断资源方向

3. top / pidstat 定位进程或线程

4. CPU 高:抓 CPU 火焰图
CPU 不高但延迟高:抓 Off-CPU 火焰图
内存异常:抓分配火焰图或内存 profiler

5. 找最宽的业务相关调用链

6. 修改代码或配置

7. 用相同流量和相同采样方式复测

优化前后要比较同一口径:

  • 相同机器规格
  • 相同版本和配置
  • 相同流量模型
  • 相同采样时间
  • 相同业务指标

火焰图只能证明“采样期间热点在哪里”,不能单独证明“优化一定有效”。最终仍要回到延迟、吞吐、错误率和资源使用率。


12. 快速命令备忘

CPU 火焰图:

1
2
3
4
perf record -F 99 -g -p <PID> -- sleep 30
perf script > out.perf
FlameGraph/stackcollapse-perf.pl out.perf > out.folded
FlameGraph/flamegraph.pl out.folded > cpu-flame.svg

全机 CPU 火焰图:

1
perf record -F 99 -a -g -- sleep 30

指定线程:

1
perf record -F 99 -g -t <TID> -- sleep 30

DWARF 回溯:

1
perf record -F 99 --call-graph dwarf -p <PID> -- sleep 30

Off-CPU 火焰图:

1
2
offcputime -f -p <PID> 30 > offcpu.folded
FlameGraph/flamegraph.pl --color=io --title="Off-CPU Time" offcpu.folded > offcpu.svg

快速查看 perf 报告:

1
perf report --stdio

查看线程:

1
2
top -Hp <PID>
pidstat -t -p <PID> 1

13. 最终检查清单

抓火焰图前:

  • 是否确认是 CPU 问题,还是等待问题?
  • 是否知道目标 PID / TID?
  • 是否覆盖了异常时间窗口?
  • 是否控制了采样频率和时长?
  • 是否保留了二进制符号和 frame pointer?

分析火焰图时:

  • 最宽的栈是否和业务路径有关?
  • 热点是计算、分配、锁、系统调用,还是等待?
  • 是否存在大量 [unknown]
  • 结论是否能被业务指标或系统指标印证?

优化后:

  • 是否用相同方式重新采样?
  • P95 / P99 延迟是否下降?
  • CPU、Load、I/O 或内存是否符合预期?
  • 是否引入新的瓶颈?

14. 回到开头:没有 CPU 热点,能说明代码没有问题吗?

不能。CPU 火焰图只描述线程实际占用 CPU 时的调用栈。开头那个 P99 达到 2 秒、CPU 只有 35% 的服务,更可能把时间花在锁、连接池、磁盘或下游响应上;此时应把业务时间窗口与线程状态对齐,再选择 Off-CPU、锁、I/O 或分配事件。

可以带走四条结论:

  1. 先用业务和系统指标确定问题类型,再选择采样事件。
  2. 火焰图宽度表示输入数据中的聚合计数,不天然等于所有墙钟时间。
  3. [unknown]、容器 PID 和符号路径属于采样质量问题,应在解释热点前解决。
  4. 火焰图只能提出和验证热点假设,优化是否成功仍由相同负载下的延迟、吞吐和错误率证明。

最实用的做法是:每次采样都保存命令、二进制版本、时间窗口、QPS、P95/P99 和原始 profile。没有这些上下文,一张 SVG 很快就会变成无法复现的截图。

参考资料