在 Linux 内核的 eBPF 运行时中,spinlock 一直是并发安全的核心机制,但也正是死锁问题的高发区域。2025 年 Linux Storage, Filesystem, Memory-Management and BPF 峰会上,开发者 Kumar Kartikeya Dwivedi 正式推出了 Resilient Queued Spinlock(弹性排队自旋锁),这是一套从根本上改变 eBPF 子系统锁行为的解决方案。本文将深入解析该技术的设计理念、实现细节以及调试修复的工程实践。

eBPF spinlock 的演进历程

理解当前困境需要回顾 eBPF 中 spinlock 的发展脉络。2019 年,Alexei Starovoitov 引入了 bpf_spin_lock(),允许 BPF 程序以原子方式更新 map 值。这项设计虽然解决了数据竞争问题,但带来了严格的运行时限制:BPF 程序只能同时持有一把锁,且在持锁期间禁止执行任何函数调用。这些约束是为了让 verifier 能够通过静态分析避免死锁,但极大限制了开发者的编程灵活性。

随着 2022 年 sched_ext 的引入,内核数据结构的更多成员 —— 包括链表和红黑树 —— 被引入 BPF 领域。 verifier 被要求确保程序能够正确地锁定和解锁这些数据结构,但仍然只支持单锁持有和受限操作。一些算法本可以更优雅地表达,却因为这些安全限制而变得笨拙。

然而,真正的挑战不在 BPF 程序层面,而在内核自身的 BPF 运行时代码。syzbot 内核模糊测试系统定期在 BPF 运行时中发现死锁,这些问题甚至 verifier 也无法检测到 —— 因为它们发生在内核代码中,而非加载的 BPF 程序内部。Dwivedi 在演讲中坦言,这是 “一个无穷无尽的问题来源”。

Resilient Queued Spinlock 的设计原理

Dwivedi 提出的解决方案是引入一种全新的锁类型 ——Resilient Queued Spinlock(弹性排队自旋锁),它能够在运行时动态检测死锁和活锁,同时保持与普通自旋锁相当的性能。该锁的基本结构仅占用四个字节,包含三个关键字段:

  • locked:一位值,锁被持有时为 1,释放时为 0。
  • pending:一位值,用于指示有第二个线程正在等待该锁。
  • tail:两字节索引,指向等待队列表,当超过两个线程竞争同一把锁时使用。

当线程在锁上等待时(原本会浪费 CPU 时间自旋),它会检查每个 CPU 的持有锁表,与锁队列中存储的信息进行比对以检测死锁。如果等待时间过长而锁仍未被释放,系统会返回错误以指示活锁情况。

在不同的竞争级别下,锁的行为有所差异。无竞争时,线程通过 compare-and-exchange 指令将 locked 设为 1,行为与现有自旋锁完全相同。两个线程竞争时,第二个线程会设置 pending 标志并开始自旋。当超过两个线程时,第三线程发现 pending 已设置,便将自身添加到新队列,并将 tail 字段指向该队列条目。此时,队列头部的线程负责执行死锁检查,而 pending 线程可以自由自旋,从而在锁释放时保持低延迟。如果持有锁的线程长时间不释放,队列头部同样会检测到这一情况,所有等待线程会移除队列并返回错误信息。

性能基准测试表明,在 Intel x86_64 CPU 上,新锁仅比现有自旋锁略差,在 arm64 上则几乎完全一致。locktorture 和 will-it-scale 等测试工具的结果显示,这种微小差异在大多数应用场景下可以忽略不计。

内核锁依赖图谱与调试技术

面对 eBPF 运行时中的死锁问题,开发者需要掌握系统级的调试方法。首先,启用内核的锁调试功能是关键:CONFIG_LOCK_DEBUGGINGCONFIG_PROVE_LOCKING 和 lockdep 能够跟踪锁依赖关系并标记潜在的死锁循环。当系统出现挂起时,可以通过 NMI 回调(使用 SysRq-t)转储所有 CPU 的栈追踪,寻找典型的死锁模式 —— 例如一个 CPU 正在 synchronize_rcu_tasks_trace() 中等待,而另一个 CPU 处于 BPF 或追踪路径中,持有 RCU 读锁但被阻塞在某个互斥锁或自旋锁上。

2025 年曝光的 CVE-2025-37884 就是一个典型案例:该漏洞源于 tracing 事件互斥锁与 RCU 追踪路径之间的锁顺序反转。上游修复方案是将 trace_set_clr_event() 移动到工作队列中执行,从而打破上下文内的锁依赖 —— 这与 Resilient Queued Spinlock 的设计思路不谋而合:将同步操作 Deferred 化,避免在持锁状态下进行可能导致阻塞的操作。

对于 BPF 程序本身的 spinlock 使用,审计锁定规则至关重要:确保每条控制流路径都能释放锁(包括错误和提前返回),且永远不在持锁期间调用可能睡眠的辅助函数。尽可能使用 per-CPU map 替代细粒度锁,通过周期性的聚合操作消除对锁的依赖。

工程落地的实践参数

在生产环境中采用这套方案时,有几个关键参数值得关注。内核版本方面,确保升级到包含 CVE-2025-37884 补丁的版本;对于企业级内核,需查阅厂商的安全公告确认 BPF 死锁修复已集成。在压力测试层面,建议构建针对性的回归测试:并发运行带有 spinlock 的 eBPF map 操作,同时模拟追踪事件的切换和 BPF 程序的加载卸载,作为升级或打补丁前的门禁验证。

Resilient Queued Spinlock 已被合并至 Linux 6.15 内核主干,Dwivedi 的下一步计划是将 BPF 运行时中的所有现有自旋锁逐步迁移到新锁类型。这不仅将消除运行时死锁问题,还能为 BPF 程序带来更灵活的锁使用体验 —— 未来开发者可能能够在 BPF 程序中同时持有多个锁,只需在运行时检测潜在冲突。Dwivedi 在演讲中表示:“内核不会崩溃,但你的程序可能会崩溃”,这种设计哲学意味着个别出错的程序会被内核主动终止,而不会影响整个系统的稳定性。

资料来源:本文技术细节主要参考 LWN.net 报道的《A new type of spinlock for the BPF subsystem》及 2025 Linux Storage, Filesystem, Memory-Management and BPF 峰会演讲。