引言:当性能分析工具成为系统杀手
在现代 Java 应用性能调优中,async-profiler 已成为不可或缺的工具。它能够以极低的开销收集 CPU 使用情况、内存分配、锁竞争等关键性能指标,生成直观的火焰图帮助开发者定位性能瓶颈。然而,近期在 Linux 内核 6.17 版本中,这个性能分析工具却意外地变成了系统杀手 —— 使用 async-profiler 会导致整个系统完全冻结,只能通过硬重启恢复。
这个问题的根源并非 async-profiler 本身,而是 Linux 内核 perf_events 子系统中的一个微妙死锁。本文将从工程实践角度出发,深入分析这一内核级死锁的成因、调试方法以及解决方案。
问题现象:系统冻结的诡异表现
当开发者在 Ubuntu 25.10 或 Fedora 40 等使用内核 6.17 的系统中运行 async-profiler 时,会遭遇以下症状:
- 完全冻结:系统对任何输入无响应,包括键盘、鼠标、SSH 连接
- 无错误信息:没有内核 panic,没有 oops 信息,系统静默地停止工作
- 硬重启唯一解:只能通过物理电源按钮或硬重启恢复
- 可重现性:每次使用 async-profiler 都会触发,与具体应用无关
这种完全冻结的现象让人联想到硬件故障,但实际上是一个纯软件问题。更令人困惑的是,这个问题只影响特定内核版本,在旧版本内核中 async-profiler 工作正常。
Async-profiler 工作原理:perf_events 与 hrtimer
要理解这个死锁,首先需要了解 async-profiler 的工作原理。async-profiler 是一个采样分析器,它通过定期中断应用程序线程并收集堆栈跟踪来工作。在 Linux 上,它主要使用 perf_events 内核子系统。
perf_events 的工作流程
async-profiler 使用 perf_events 的软件事件 cpu-clock,该事件由内核的高分辨率定时器(hrtimer)驱动。具体流程如下:
-
初始化阶段:为每个被分析的线程,async-profiler 打开一个
perf_event文件描述符,配置为在指定 CPU 时间间隔(如 10ms)后生成信号。 -
事件激活:通过
ioctl(fd, PERF_EVENT_IOC_REFRESH, 1)激活事件,设置为单次触发模式。这种设计旨在只测量应用程序的 CPU 时间,排除信号处理程序自身的开销。 -
定时器触发:当配置的 CPU 时间耗尽时,内核的 hrtimer 触发并向目标线程发送信号。
-
信号处理:async-profiler 的信号处理程序捕获堆栈跟踪并记录样本。在处理程序结束时,重置计数器并重新激活事件以进行下一次采样。
这种循环在整个分析会话期间重复,创建一系列堆栈跟踪样本,最终聚合成火焰图或热力图。
关键设计:单次触发与重置
async-profiler 使用 PERF_EVENT_IOC_REFRESH(1) 的单次触发机制,结合处理程序结束时的 PERF_EVENT_IOC_RESET。这种设计确保了每次采样都是独立的,避免了累积误差。然而,正是这种单次触发机制与内核 bug 的交互导致了死锁。
内核死锁分析:自引用的定时器取消
死锁发生在内核 6.17 版本中,由提交 18dbcbfabfff ("perf: Fix the POLL_HUP delivery breakage") 引入。这个提交原本是为了修复另一个 bug,但却在 cpu-clock 事件处理中引入了死锁。
死锁调用链
当 PERF_EVENT_IOC_REFRESH(1) 的计数器达到零时,buggy 内核中的调用链如下:
-
hrtimer 触发:cpu-clock 事件的 hrtimer 触发,在中断上下文中调用
perf_swevent_hrtimer() -
溢出处理:
perf_swevent_hrtimer()调用__perf_event_overflow()处理计数器溢出 -
事件停止决策:
__perf_event_overflow()检测到计数器已达到 0(PERF_EVENT_IOC_REFRESH(1)后),决定停止事件,调用cpu_clock_event_stop() -
定时器取消:
cpu_clock_event_stop()调用perf_swevent_cancel_hrtimer(),后者调用hrtimer_cancel()取消定时器 -
死锁发生:
hrtimer_cancel()是一个阻塞调用,它会自旋等待任何活动的回调完成。但问题在于,我们正在 hrtimer 回调中执行!系统永远等待自身完成,形成死锁
死锁的严重性
这个死锁之所以导致系统完全冻结,有几个关键原因:
- 中断上下文:死锁发生在中断上下文中,此时该 CPU 上的中断被禁用
- 多 CPU 影响:每个被分析的线程都有自己的
perf_event,当多个线程同时被分析时,多个 CPU 会同时陷入死锁 - IPI 连锁反应:当其他 CPU 尝试向死锁的 CPU 发送处理器间中断(IPI)时,它们会等待响应,最终导致所有 CPU 都被阻塞
这种连锁反应使得整个系统完全无响应,无法处理任何外部输入或网络请求。
内核修复方案:非阻塞取消与状态标志
内核修复提交 eb3182ef0405 通过两个关键更改解决了这个死锁:
1. 替换阻塞的 hrtimer_cancel
将阻塞的 hrtimer_cancel() 替换为非阻塞的 hrtimer_try_to_cancel():
- hrtimer_cancel(&hwc->hrtimer);
+ hrtimer_try_to_cancel(&hwc->hrtimer);
hrtimer_try_to_cancel() 是非阻塞的,它立即返回:
0:定时器未激活1:定时器成功取消-1:定时器回调当前正在运行
与 hrtimer_cancel() 不同,它不会自旋等待回调完成。当从回调自身内部调用时,它简单地返回 -1 并继续执行。
2. 使用 PERF_HES_STOPPED 标志
停止函数现在设置一个标志:
static void cpu_clock_event_stop(struct perf_event *event, int flags)
{
+ event->hw.state = PERF_HES_STOPPED;
perf_swevent_cancel_hrtimer(event);
...
}
而 hrtimer 回调检查这个标志:
static enum hrtimer_restart perf_swevent_hrtimer(struct hrtimer *hrtimer)
{
- if (event->state != PERF_EVENT_STATE_ACTIVE)
+ if (event->state != PERF_EVENT_STATE_ACTIVE ||
+ event->hw.state & PERF_HES_STOPPED)
return HRTIMER_NORESTART;
修复的工作原理
当 cpu_clock_event_stop() 从 hrtimer 回调内部调用时:
- 设置
PERF_HES_STOPPED标志 hrtimer_try_to_cancel()返回-1(回调正在运行)但不阻塞- 执行返回到
perf_swevent_hrtimer() perf_swevent_hrtimer()完成并返回HRTIMER_NORESTART(因为__perf_event_overflow()返回1,表示事件应停止)- hrtimer 子系统看到
HRTIMER_NORESTART并且不重新调度定时器
PERF_HES_STOPPED 标志充当安全网,确保无论设置标志和定时器触发之间的竞争条件如何,定时器都会停止。
工程实践:内核级调试方法
对于系统工程师和性能分析师来说,理解如何调试这类内核问题同样重要。以下是实际调试过程中使用的方法:
1. 环境隔离与问题复现
首先需要在受控环境中复现问题:
# 设置 perf_events 权限
echo -1 | sudo tee /proc/sys/kernel/perf_event_paranoid
# 使用 QEMU 创建测试环境
qemu-system-x86_64 \
-enable-kvm \
-m 4096 \
-smp 4 \
-drive file=ubuntu-25.10.qcow2,if=virtio \
-netdev user,id=net0,hostfwd=tcp::9000-:9000 \
-device virtio-net-pci,netdev=net0 \
-monitor tcp:127.0.0.1:55555,server,nowait \
-s
2. GDB 内核调试配置
调试内核需要正确配置符号和地址空间布局随机化(KASLR):
# 禁用 KASLR(仅用于调试)
# 编辑 /etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="quiet splash nokaslr"
sudo update-grub
# 安装内核调试符号
echo "deb http://ddebs.ubuntu.com $(lsb_release -cs) main restricted universe multiverse" | sudo tee /etc/apt/sources.list.d/ddebs.list
sudo apt install ubuntu-dbgsym-keyring
sudo apt update
sudo apt install linux-image-$(uname -r)-dbgsym
3. 实时内核状态分析
通过 GDB 连接到 QEMU 的 GDB 服务器:
gdb /usr/lib/debug/boot/vmlinux-$(uname -r)
(gdb) target remote :1234
(gdb) info threads
(gdb) thread apply all bt
关键观察点:
- 检查哪些 CPU 卡在
hrtimer_try_to_cancel中 - 分析调用栈确认自死锁模式
- 检查定时器结构确认回调函数指针
4. 内存取证与状态修改
在极端情况下,可以尝试通过修改内核状态来恢复系统(仅用于研究目的):
// 检查定时器结构
(gdb) print *timer
$1 = {node = {node = {__rb_parent_color = 18446612682661667048,
rb_right = 0x0, rb_left = 0x0}, expires = 48514879474},
_softexpires = 48514879474,
function = 0xffffffff81716dd0 <perf_swevent_hrtimer>,
base = 0xffff88813bda1440, state = 0 '\000',
is_rel = 0 '\000', is_soft = 0 '\000', is_hard = 1 '\001'}
// 强制修改返回值绕过死锁
(gdb) set $eax = 0
警告:这种方法极不稳定,可能导致内核崩溃或数据损坏,仅适用于实验环境。
临时解决方案与最佳实践
在生产环境中遇到此问题时,有以下几种解决方案:
1. 使用 ctimer 事件绕过
最简单的解决方案是让 async-profiler 使用 ctimer 事件而不是默认的 cpu-clock:
./profiler.sh -e ctimer -d 30 -f profile.svg <pid>
ctimer 使用不同的定时器机制,不依赖有问题的 perf_events hrtimer 路径。
2. 降级内核版本
如果可能,暂时降级到不受影响的内核版本(6.16 或更早版本)。
3. 应用内核补丁
手动应用修复补丁并重新编译内核:
# 获取内核源码
git clone https://github.com/torvalds/linux.git
cd linux
# 应用修复提交
git cherry-pick eb3182ef0405ff2f6668fd3e5ff9883f60ce8801
# 编译并安装
make oldconfig
make -j$(nproc)
sudo make modules_install
sudo make install
4. 等待发行版更新
大多数主流发行版会在后续更新中包含此修复。可以跟踪发行版的更新公告。
监控与预防措施
为了避免类似问题影响生产环境,建议实施以下监控和预防措施:
1. 性能分析工具的健康检查
# 定期测试 async-profiler 功能
#!/bin/bash
TEST_PID=$$
TIMEOUT=10
# 尝试启动 profiler,设置超时
timeout $TIMEOUT ./profiler.sh -d 5 -o /dev/null $TEST_PID
EXIT_CODE=$?
if [ $EXIT_CODE -eq 124 ]; then
echo "WARNING: Profiler timed out - possible kernel deadlock"
# 触发警报
send_alert "Async-profiler deadlock detected on $(hostname)"
elif [ $EXIT_CODE -ne 0 ]; then
echo "ERROR: Profiler failed with code $EXIT_CODE"
fi
2. 内核版本兼容性矩阵
维护一个内核版本与性能分析工具的兼容性矩阵:
| 内核版本 | async-profiler | perf | BPF 工具 | 备注 |
|---|---|---|---|---|
| 6.16 及更早 | ✅ 正常 | ✅ 正常 | ✅ 正常 | 稳定版本 |
| 6.17 | ⚠️ 需 -e ctimer | ✅ 正常 | ✅ 正常 | 存在死锁 bug |
| 6.18+ | ✅ 正常 | ✅ 正常 | ✅ 正常 | 包含修复 |
3. 生产环境分析策略
在生产环境中使用性能分析工具时:
- 先在测试环境验证:确保分析工具在当前内核版本上工作正常
- 使用保守参数:避免过于频繁的采样间隔
- 设置超时机制:确保分析会话不会无限期运行
- 准备回滚计划:如果分析导致问题,知道如何快速恢复
深入思考:软件复杂性与边缘情况
这个死锁 bug 揭示了现代软件系统中的几个重要问题:
1. 修复 bug 引入新 bug
原始提交 18dbcbfabfff 是为了修复 POLL_HUP 传递问题,但却在完全不同的代码路径中引入了死锁。这提醒我们,即使是最有经验的开发者,在修改复杂系统时也可能引入意想不到的副作用。
2. 定时器取消的语义
hrtimer_cancel() 的阻塞语义在大多数情况下是合理的,但在自引用场景中会导致死锁。修复方案通过引入 hrtimer_try_to_cancel() 和状态标志,提供了更细粒度的控制。
3. 用户空间与内核空间的交互
async-profiler 作为用户空间工具,通过 perf_events 接口与内核交互。这种跨边界的设计使得用户空间工具能够触发深层次的内核问题,增加了调试的复杂性。
4. 测试覆盖的挑战
这种死锁只在特定条件下触发:
- 使用
PERF_EVENT_IOC_REFRESH(1)的单次触发模式 - 在 hrtimer 回调中决定停止事件
- 多个事件几乎同时触发
这种边缘情况很难通过常规测试发现,强调了需要更全面的测试策略,包括压力测试和边界条件测试。
结论与展望
async-profiler 内核死锁问题是一个典型的技术深度与工程实践结合的案例。它涉及:
- 用户空间工具设计:async-profiler 的单次触发采样机制
- 内核子系统:perf_events 和 hrtimer 的交互
- 并发编程:中断上下文中的自死锁
- 系统调试:内核级调试技术和工具
对于系统工程师和性能分析师,这个案例提供了宝贵的经验:
- 理解工具背后的机制至关重要
- 内核更新可能引入意想不到的兼容性问题
- 掌握调试技能可以在问题发生时快速定位原因
- 在生产环境中使用新工具或新内核版本时需要谨慎
随着 Linux 内核的持续发展,类似的边缘情况 bug 可能还会出现。通过深入理解系统工作原理、建立完善的测试流程和掌握高级调试技术,我们可以更好地应对这些挑战,确保系统的稳定性和性能。
参考资料:
- QuestDB 博客文章:How a Kernel Bug Froze My Machine: Debugging an Async-profiler Deadlock
- async-profiler GitHub Issue #1578:Machine freeze when using async-profiler on kernel 6.17
- Linux 内核提交 eb3182ef0405:perf: Fix cpu-clock deadlock in perf_swevent_hrtimer ()