perf + FlameGraph:利用火焰图分析Linux程序性能瓶颈

吞吐上不去、并发撑不住、单请求变慢,都要先定位卡在哪一步。火焰图把采样到的调用栈画成嵌套矩形:横条越宽,这个函数占 CPU 越久;越往上叠,调用栈越深。它回答的是 CPU 时间去哪了,最宽那块就是最占 CPU 的环节。这类图严格来说叫 On-CPU 火焰图,只统计真在 CPU 上跑的时间;等 IO、等锁、等网络的阻塞时间不在图上,对应有专门的 Off-CPU 火焰图来分析。

图怎么读

火焰图读法示意

递归在图上是一根逐层收窄的塔,塔的高度对应递归深度。

怎么生成

perf、Go 的 pprof、Python 的 py-spy、浏览器的 Performance 面板都能出这类图,读法通用。下面以 perf 为例。

被测程序是一段示例源码 flame_demo.c,编译时要保住栈信息,四个选项决定图能不能看:

选项 缺了会怎样
-g 只给出地址,图上全是十六进制
-fno-omit-frame-pointer 帧指针丢了,默认按帧指针展开,展不开
-fno-inline -O2 把热点函数吞进调用者,图塌成一片平顶
-fno-optimize-sibling-calls 调用链断一截,见下一节
gcc -O2 -g -fno-omit-frame-pointer -fno-inline -fno-optimize-sibling-calls -o flame_demo flame_demo.c

编译好就能采样出图,两条命令:

# 采样:99Hz,只采用户态;把 ./flame_demo 换成 -p <PID> 可附加到已运行的进程
perf record -e cycles:u -F 99 -g -o perf.data ./flame_demo
# 折叠并出图,产出的 SVG 可点击下钻、可按函数名搜索
perf script -i perf.data | stackcollapse-perf.pl | flamegraph.pl > flame.svg

采样不改被测程序,代价是统计误差,热点低于 1% 时宽度不可信。频率取 99Hz 而不是 100Hz,是为了避开与系统时钟节拍的整数倍关系,否则周期性任务会把样本全堆在同一条指令上。

出图结果如下:

生成的火焰图

四个部分各占约四分之一的 CPU,宽度接近。注意 run_all 只占 75.05%、part_fib 直接挂在 main 下,这是编译器优化留下的痕迹,下一节解释。

图上的伪影

火焰图是栈展开结果的图像,不是调用图的等价物,三处失真最常见。

尾调用。函数最后一句是调用另一个函数时,-O2 会编成 jmp 而不是 call,跳转前先销毁自己的栈帧,帧指针链里就少了这一层:被调函数直接挂到更上层的调用者下面,占比也对不上(上图的 run_all 只占 75.05% 就是这么来的)。加 -fno-optimize-sibling-calls 可还原。

内联。-O2 默认内联,被内联的函数在栈上不存在,热点并进调用者的块里。这是图上出现“一个平顶宽块”的常见原因之一——不过平顶也不全是伪影,函数自身计算量大、不怎么调用子函数时也会呈现平顶,需要结合代码判断。

栈展开截断。默认按帧指针展开,遇到不带帧指针的代码(系统库、汇编优化过的数学库)就停在那里,函数在图上表现为凭空多出的宽块。老内核的展开器还有深度限制,深递归的塔会比实际浅。换 DWARF 展开(perf record 加 --call-graph dwarf)或 LBR 可缓解。