Hotdry.
embedded-systems

Async-profiler 内核死锁调试:从系统冻结到内核源码分析

深入分析 async-profiler 在 Linux 内核 6.17 版本中触发的死锁问题,包括 perf_events 子系统工作原理、hrtimer 自死锁机制、内核修复方案与工程实践中的调试方法。

引言:当性能分析工具成为系统杀手

在现代 Java 应用性能调优中,async-profiler 已成为不可或缺的工具。它能够以极低的开销收集 CPU 使用情况、内存分配、锁竞争等关键性能指标,生成直观的火焰图帮助开发者定位性能瓶颈。然而,近期在 Linux 内核 6.17 版本中,这个性能分析工具却意外地变成了系统杀手 —— 使用 async-profiler 会导致整个系统完全冻结,只能通过硬重启恢复。

这个问题的根源并非 async-profiler 本身,而是 Linux 内核 perf_events 子系统中的一个微妙死锁。本文将从工程实践角度出发,深入分析这一内核级死锁的成因、调试方法以及解决方案。

问题现象:系统冻结的诡异表现

当开发者在 Ubuntu 25.10 或 Fedora 40 等使用内核 6.17 的系统中运行 async-profiler 时,会遭遇以下症状:

  1. 完全冻结:系统对任何输入无响应,包括键盘、鼠标、SSH 连接
  2. 无错误信息:没有内核 panic,没有 oops 信息,系统静默地停止工作
  3. 硬重启唯一解:只能通过物理电源按钮或硬重启恢复
  4. 可重现性:每次使用 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)驱动。具体流程如下:

  1. 初始化阶段:为每个被分析的线程,async-profiler 打开一个 perf_event 文件描述符,配置为在指定 CPU 时间间隔(如 10ms)后生成信号。

  2. 事件激活:通过 ioctl(fd, PERF_EVENT_IOC_REFRESH, 1) 激活事件,设置为单次触发模式。这种设计旨在只测量应用程序的 CPU 时间,排除信号处理程序自身的开销。

  3. 定时器触发:当配置的 CPU 时间耗尽时,内核的 hrtimer 触发并向目标线程发送信号。

  4. 信号处理: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 内核中的调用链如下:

  1. hrtimer 触发:cpu-clock 事件的 hrtimer 触发,在中断上下文中调用 perf_swevent_hrtimer()

  2. 溢出处理perf_swevent_hrtimer() 调用 __perf_event_overflow() 处理计数器溢出

  3. 事件停止决策__perf_event_overflow() 检测到计数器已达到 0(PERF_EVENT_IOC_REFRESH(1) 后),决定停止事件,调用 cpu_clock_event_stop()

  4. 定时器取消cpu_clock_event_stop() 调用 perf_swevent_cancel_hrtimer(),后者调用 hrtimer_cancel() 取消定时器

  5. 死锁发生hrtimer_cancel() 是一个阻塞调用,它会自旋等待任何活动的回调完成。但问题在于,我们正在 hrtimer 回调中执行!系统永远等待自身完成,形成死锁

死锁的严重性

这个死锁之所以导致系统完全冻结,有几个关键原因:

  1. 中断上下文:死锁发生在中断上下文中,此时该 CPU 上的中断被禁用
  2. 多 CPU 影响:每个被分析的线程都有自己的 perf_event,当多个线程同时被分析时,多个 CPU 会同时陷入死锁
  3. 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 回调内部调用时:

  1. 设置 PERF_HES_STOPPED 标志
  2. hrtimer_try_to_cancel() 返回 -1(回调正在运行)但不阻塞
  3. 执行返回到 perf_swevent_hrtimer()
  4. perf_swevent_hrtimer() 完成并返回 HRTIMER_NORESTART(因为 __perf_event_overflow() 返回 1,表示事件应停止)
  5. 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. 生产环境分析策略

在生产环境中使用性能分析工具时:

  1. 先在测试环境验证:确保分析工具在当前内核版本上工作正常
  2. 使用保守参数:避免过于频繁的采样间隔
  3. 设置超时机制:确保分析会话不会无限期运行
  4. 准备回滚计划:如果分析导致问题,知道如何快速恢复

深入思考:软件复杂性与边缘情况

这个死锁 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 内核死锁问题是一个典型的技术深度与工程实践结合的案例。它涉及:

  1. 用户空间工具设计:async-profiler 的单次触发采样机制
  2. 内核子系统:perf_events 和 hrtimer 的交互
  3. 并发编程:中断上下文中的自死锁
  4. 系统调试:内核级调试技术和工具

对于系统工程师和性能分析师,这个案例提供了宝贵的经验:

  • 理解工具背后的机制至关重要
  • 内核更新可能引入意想不到的兼容性问题
  • 掌握调试技能可以在问题发生时快速定位原因
  • 在生产环境中使用新工具或新内核版本时需要谨慎

随着 Linux 内核的持续发展,类似的边缘情况 bug 可能还会出现。通过深入理解系统工作原理、建立完善的测试流程和掌握高级调试技术,我们可以更好地应对这些挑战,确保系统的稳定性和性能。

参考资料

  1. QuestDB 博客文章:How a Kernel Bug Froze My Machine: Debugging an Async-profiler Deadlock
  2. async-profiler GitHub Issue #1578:Machine freeze when using async-profiler on kernel 6.17
  3. Linux 内核提交 eb3182ef0405:perf: Fix cpu-clock deadlock in perf_swevent_hrtimer ()
查看归档