eBPF 自旋锁与内核调试实战
深入剖析 eBPF 自旋锁缺陷如何在 Linux 内核中显现、为何难以排查,以及调试过程如何揭示 eBPF 的安全模型。

eBPF 中的自旋锁 bug 不会通过堆栈跟踪或错误信息来告诉你它的存在,而是让机器停止响应。没有日志,没有警告,也没有优雅降级,只有一个 CPU 核心在一把永远不会被释放的锁上无休止地空转。运气好的话,看门狗定时器触发,你会得到一个带调用栈的内核 panic;运气差的话,机器直接卡死,你只能断电重启。
eBPF 本应是安全的。它运行在内核中,但要经过验证器(verifier)的检查,证明程序会终止、不会访问非法内存,也不会破坏内核状态。那么自旋锁 bug 是怎么漏过去的呢?原因在于,验证器检查的是单个程序,而自旋锁 bug 往往源于程序、映射(map)与内核调度决策之间的相互作用,这些都是任何静态验证器无法完全预测的。
eBPF 自旋锁的用途
eBPF 程序通常运行在内核上下文中,而且经常在多个 CPU 上同时执行。当两个 eBPF 程序需要更新同一个 map 值,比如一个计数器、一个数据结构或一个状态机时,就必须做同步。为此,eBPF 提供了 bpf_spin_lock 和 bpf_spin_unlock。
自旋锁是最简单的一种锁:尝试获取,如果锁已被持有,就在紧凑的循环中自旋等待,直到锁被释放。它不会睡眠,不会让出 CPU,也不排队。这对 eBPF 来说很合适,因为 eBPF 程序所处的上下文中禁止睡眠,比如中断处理程序、软中断,以及禁用抢占的代码段。如果在这里使用会让调用者进入睡眠的互斥锁,内核直接就崩了;自旋锁只是烧掉 CPU 周期,直到锁可用为止。
struct map_value {
struct bpf_spin_lock lock;
__u64 counter;
__u32 last_pid;
};
SEC("tp/sched/sched_switch")
int handle_sched_switch(struct trace_event_raw_sched_switch *ctx) {
__u32 key = 0;
struct map_value *val;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
bpf_spin_lock(&val->lock);
val->counter++;
val->last_pid = ctx->next_pid;
bpf_spin_unlock(&val->lock);
return 0;
}
看起来很直接。对于这种简单场景,也就是加锁、更新一个值、解锁,它确实工作良好。问题出现在加锁模式变得复杂,或者 eBPF 程序与内核自身的锁机制之间产生了意料之外的依赖关系时。
自旋锁 bug 的表现形式
经典的自旋锁 bug 是死锁:程序 A 持有锁 1 并尝试获取锁 2,而程序 B 持有锁 2 并尝试获取锁 1。两者都会永远自旋等待对方。在内核上下文中,这意味着这些 CPU 核心就废了,它们再也无法执行任何其他任务。
eBPF 验证器能防住其中一部分情况。它会拒绝同时持有多个自旋锁的程序,从而消除了经典的 AB-BA 死锁。但它无法防止所有死锁场景,因为有些死锁源于 eBPF 自旋锁与内核自身锁机制之间的关系。
设想这样一个场景:一个挂载在 tracepoint 上的 eBPF 程序获取了 map 中的一把自旋锁。锁被持有期间,同一个 CPU 上触发了一个硬件中断。中断处理程序运行另一个 eBPF 程序,该程序尝试获取同一把自旋锁。死锁就此形成:中断在拿到锁之前无法返回,而锁要等被打断的程序恢复执行才能释放,但那又要等中断返回才能发生。
Deadlock scenario (same CPU):
CPU 0 running eBPF program A
→ acquires spinlock on map_value
→ hardware interrupt fires
→ CPU 0 runs interrupt handler
→ interrupt handler runs eBPF program B
→ program B tries to acquire same spinlock
→ spinlock is held by program A
→ program B spins... forever
→ interrupt handler never returns
→ program A never resumes
→ program A never releases lock
→ CPU 0 is permanently stuck
内核自身的自旋锁通过在持锁期间禁用中断(spin_lock_irqsave)来应对这种情况。eBPF 自旋锁也采取类似做法:bpf_spin_lock 会禁用抢占,并且在 5.1 及以上版本的内核中禁用软中断。但并非所有中断上下文都被覆盖,不同内核版本的处理方式也不尽相同,而这正是微妙 bug 的藏身之处。
调试的难点
在 eBPF 中调试自旋锁问题之所以困难,有以下几个具体原因。
问题难以稳定复现。自旋锁 bug 依赖于时序:哪个 CPU 运行哪个程序、中断何时触发、临界区执行多久,都会影响结果。一个测试跑 999 次都没问题,第 1000 次可能就死锁了。开发机的 CPU 拓扑、中断频率和负载模式往往与生产环境不同,所以很难在开发阶段可靠地复现这类问题。
故障本身会破坏现场证据。自旋锁死锁发生时,受影响的 CPU 不再执行任何代码,包括那些能告诉你发生了什么的日志和监控基础设施。如果只是单个 CPU 死锁,机器可能还能勉强运行;但如果多个 CPU 同时死锁(或者被锁住的 CPU 持有其他 CPU 需要的资源),整台机器就彻底挂起了。
传统调试工具帮不上忙。你无法直接挂载调试器去检查死锁时的内核状态(当然,KGDB 可以,但前提是死锁前就已经配置好了串口连接)。dmesg 也没用,因为此时已经没有任何东西在写入它。唯一可靠的信息来源是锁死检测器(lockup detector)的输出,前提是它在机器彻底死掉之前触发了。
真正有效的应对策略
尽管困难重重,还是有人能找到并修复 eBPF 自旋锁 bug。方法如下。
锁顺序分析。在运行任何程序之前,先分析每个 eBPF 程序的加锁行为:每个程序访问了哪些 map?获取了哪些锁?两个操作同一把锁的程序是否可能在同一个 CPU 上运行,比如同一个 tracepoint,或者 tracepoint 与中断之间?这种分析枯燥且需要手工完成,但它能在问题发生之前捕获最常见的死锁模式。
锁持有时间监控。对 eBPF 程序进行插桩,测量自旋锁的持有时长。持有超过几微秒的自旋锁就是危险信号:它会扩大基于中断的死锁窗口,还会让其他 CPU 白白空转。短小的临界区既更快,也更安全。
// Monitoring lock hold time in eBPF
SEC("tp/sched/sched_switch")
int handle_switch(void *ctx) {
struct map_value *val;
__u64 start, elapsed;
__u32 key = 0;
val = bpf_map_lookup_elem(&my_map, &key);
if (!val)
return 0;
start = bpf_ktime_get_ns();
bpf_spin_lock(&val->lock);
// Critical section — keep this minimal
val->counter++;
bpf_spin_unlock(&val->lock);
elapsed = bpf_ktime_get_ns() - start;
// Report if lock was held too long
if (elapsed > 1000) { // > 1 microsecond
bpf_printk("lock held for %llu ns", elapsed);
}
return 0;
}
使用 per-CPU 数据结构。最好的自旋锁 bug,就是根本不可能发生的 bug。如果每个 CPU 都操作自己的数据副本,之后再进行聚合,那就不存在竞争,也不需要任何锁。eBPF 的 BPF_MAP_TYPE_PERCPU_HASH 和 BPF_MAP_TYPE_PERCPU_ARRAY 正是为此设计的。计数器、直方图和累加器几乎都不需要共享状态的自旋锁。
用原子操作代替锁。对于简单操作,比如递增计数器、比较并交换某个值,__sync_fetch_and_add 等原子操作既更快,也不会死锁。递增计数器根本不需要自旋锁。只有在确实需要原子地同时更新多个字段时,才值得动用自旋锁。
这对 eBPF 安全模型意味着什么
eBPF 的安全性故事令人印象深刻,但也颇为微妙。验证器保证单个程序是安全的:它们会终止,不会访问非法内存,也不会破坏内核状态。但当它们之间的相互作用产生了验证器无法分析的时序依赖时,这些“安全”的程序组合在一起,就可能构成不安全的系统。
这是静态分析的根本局限。验证器是孤立地看待每个程序的,它不知道还有哪些其他程序已被加载,它们共享哪些 map,也不知道它们运行在什么内核上下文中。一个获取自旋锁的程序本身是“安全”的,它正确地获取并释放锁。但它与所有其他已加载的 eBPF 程序组合在一起时是否安全,则取决于运行时条件。
内核社区一直在逐步解决这个问题。近期的补丁增加了一些限制,包括哪些 eBPF 程序类型可以使用自旋锁、临界区最长能持续多久,以及持锁期间禁止执行哪些操作。每一项限制都缩小了 bug 出现的窗口,但同时也限制了 eBPF 程序能做的事情。
eBPF 加锁的实用准则
在排查了足够多的自旋锁问题之后,逐渐总结出了一些避坑的规律。
- 优先使用 per-CPU map。如果你的数据可以按 CPU 划分,并在用户空间中汇总,那就这么做。没有锁,没有竞争,也不会死锁。这足以覆盖 80% 的场景,比如计数器、事件日志和直方图。
- 优先使用原子操作而非自旋锁。如果你需要一个共享计数器或比较并交换操作,就用原子操作。它们是无锁的,不可能死锁。
- 如果必须使用自旋锁,就让临界区尽量小。只做递增计数器、更新时间戳、交换指针这类操作,别的什么都不要做。持锁期间绝不要调用辅助函数,因为你无法确定它们内部会做什么样的加锁。
- 绝不让两种 eBPF 程序类型持有同一把锁。如果一个 kprobe 程序和一个 tracepoint 程序都锁住了同一个 map 值,那你离死锁就只差一次中断了。改用独立的 map 或 per-CPU 数据。
- 在负载下测试。自旋锁 bug 依赖时序,它们会在竞争激烈时浮现,比如 CPU 利用率高、中断频繁、大量 eBPF 程序同时运行。请用真实的生产负载测试,而不是在空闲的开发机上测。
eBPF 从根本上改变了我们与 Linux 内核交互的方式,让用户空间程序能够安全地访问内核级的可观测性和网络能力。但“安全”也是有边界的。相比直接编写原始内核模块,验证器让 eBPF 安全得多,差距是巨大的。但它并不意味着 eBPF 像用户态编程那样安全。弄清这些边界在哪里,尤其是在共享状态和加锁方面,正是区分“测试能跑、生产能用”的 eBPF 程序与其他程序的关键。


