突破内核性能瓶颈:Linux BPF 局部存储与数据包元数据的优化探索

在 Linux 网络子系统中,当使用 BPF(eBPF)程序对数据包进行过滤或重定向时,程序往往需要在数据包穿过内核的整个生命周期中,为其附带一些自定义的元数据(如标记、时间戳等)。

为了满足这种需求,内核提供了 BPF 局部存储 API(Local BPF Storage API),允许将自定义数据与具体的内核对象绑定。然而,在海量并发与极高吞吐的网络场景下,局部存储的访问效率与开销成为了不可忽视的性能瓶颈。

在 Linux 存储、文件系统、内存管理与 BPF 峰会上,Amery Hung 与 Jakub Sitnicki 深入探讨了如何优化局部存储的访问效率,以及如何在数据包结构体(sk_buff)中更高效地存储元数据。

一、 局部存储的锁竞争与死锁难题

在过去一段时间里,Amery Hung 一直致力于利用局部存储来实现更高效的内核与用户空间通信。但在开发过程中,他注意到 BPF 的自测试(self-tests)偶尔会因为锁竞争(Lock Contention)而失败,导致局部存储的读写操作直接报错。

1. 为什么局部存储需要两把锁?

为了保证安全,内核的局部存储目前由两把锁共同保护:

  • 常规锁(Main Lock): 用于防止并发访问导致的数据竞争。

  • 单 CPU 锁(Per-CPU Lock): 专门用于防止内核死锁。

之所以需要“单 CPU 锁”,是因为 BPF 程序可能会挂载到某些跟踪点(Tracepoints)上,而这些跟踪点恰好会在访问局部存储时被触发。如果同一个 CPU 上的 BPF 程序递归触发并尝试两次获取主锁,就会发生内核自锁(Deadlock)

因此,内核将用于访问局部存储的辅助函数(kfuncs)设计为“易错的(fallible)”。一旦单 CPU 锁检测到同一 CPU 上有两个 BPF 程序试图同时访问该存储,就会直接返回错误以规避死锁。

2. 过于保守的安全策略

这种设计虽然安全,但过于保守。即使两个 BPF 程序完全不存在死锁可能,单 CPU 锁也会误伤它们,导致正常的访问失败。

为了解决这个问题,Hung 提出用 rqspinlock(可恢复队列自旋锁,Resilient Queued Spinlock) 替换主锁。rqspinlock 能够在运行时动态检测死锁风险。由于主锁本身具备了死锁防御能力,单 CPU 锁便可以被安全移除,从而做到“仅在真正存在死锁风险时才让操作失败”。

二、 释放内存时的“不可失败”困境

从普通自旋锁切换到 rqspinlock 在大多数代码路径上表现良好,但在释放(Free)内核数据结构时遇到了棘手的问题。

释放一个带有局部存储的内核对象是不可失败的操作(Infallible Operation)——内核不能因为锁发生竞争就拒绝释放内存。然而,rqspinlock 无法完美支持这种不可失败的场景。

为了解决释放时的锁问题,Hung 进行了一系列尝试:

[尝试 1] 使用 kmalloc_nolock()
 └── 失败:局部存储依赖 BPF 分配器与 Slab 分配器。两者解耦是硬性性能要求,无法统一改用单一种类。

[尝试 2] 引入不可失败的释放操作(最终妥协方案)
 └── 成功(但有代价):在最坏情况下,每次释放会造成 176 字节的内存泄露。

性能实测与收益

虽然方案存在微小的内存泄露代价,但移除单 CPU 锁带来的性能提升十分明显:

  • x86_64 微基准测试: 在循环创建和释放局部存储的场景下,吞吐量提升了 1.5%(Arm 架构上差异不明显)。

  • 解锁 Slab 优化(Sheaves): 该改动允许部分 Slab 分配器使用 Sheaves 机制,在另一项微基准测试中带来了 5.1% 的性能提升

尽管微基准测试并不能完全代表复杂的真实业务场景,且局部存储对象的生命周期管理(如未完全初始化导致内核 Panic)仍有待解决,但这为彻底优化局部存储迈出了关键一步。

三、 数据包(SKB)元数据存储的演进路线

除了通用的 BPF 局部存储,Jakub Sitnicki 主持讨论了另一个紧密相关的问题:如何在网络栈的数据包结构体(sk_buff / SKB)中高效存储少量元数据?

BPF 程序经常需要随数据包携带标签或时间戳。Sitnicki 此前尝试将现有的 XDP 和 SKB 元数据暴露给 BPF,但遇到了根本性阻碍:这些元数据只能在 XDP 和 TC(流量控制)层访问,无法穿透到网络栈的高层(如 TCP/IP 协议栈)。

如果要让元数据跨层持久存在,网络子系统就必须锁定其内存布局,而网络维护者出于维护性考虑拒绝了这一方案。为此,社区展开了多种替代方案的博弈:

方案对比:如何在 SKB 中存元数据?

方案设计实现机制优势劣势 / 挑战
方案 1:SKB 扩展指针通过 SKB 扩展持有指向局部存储的指针无需修改 SKB 主结构开销过大: 每个数据包需要 3 次内存分配
方案 2:BPF 专用 SKB 扩展将元数据扩展与 SKB 放在同一次内存分配每个数据包仅 1 次分配,访问间接寻址少增大 SKB 尺寸;即使不使用也需承担内存成本;需改变 SKB 克隆逻辑
方案 3:全局 BPF 哈希表以 SKB 指针作为 Key,将元数据存在外部 Map 中完全不增加 SKB 内存开销难以确定最佳 Map 尺寸;数据包丢弃时需要额外的清理开销

四、 核心争议与未来优化方向

在探讨上述方案时,BPF 社区与网络子系统维护者针对细节展开了深入讨论:

1. 引导参数(Boot Flag)与动态分配

针对方案 2(SKB 扩展)带来的内存浪费问题,Alexei Starovoitov 建议增加一个内核启动参数(Boot Flag)。只有明确开启该标志的用户才需要承担内存开销。

对于多程序共享元数据空间的问题,社区提出了两种分配思路:

  • 静态划分: 管理员为每个 BPF 程序手动配置固定的元数据偏移范围(实现简单,灵活性差)。

  • 动态分配: BPF 程序按需声明所需的元数据大小,由内核透明分配(体验极佳,实现复杂)。

2. 哈希表方案的数据清理难题

针对方案 3(全局哈希表),最大的挑战在于生命周期管理

当数据包在网络栈的某个中间环节被丢弃(Drop)时,必须清理哈希表中的元数据。如果挂载跟踪点(Tracepoint)来监听所有 SKB 的释放,会带来巨大的性能损耗。

为了解决这一痛点,Starovoitov 提出了标志位结合条件跟踪点的优化思路:

  1. 在 SKB 中仅保留 1 个 Bit 作为标志位,用于标记该数据包是否携带有元数据。

  2. 只有当标志位为 1 且数据包被异常丢弃时,才触发跟踪点清理内存。

  3. 正常的业务数据包在被 BPF 消费时顺便清理元数据,从而将性能开销降到最低。

五、 总结与展望

无论是优化局部存储的锁机制,还是为数据包寻找最贴合的元数据载体,核心矛盾都在于:如何在保证内核绝对安全与不增加通用内存开销的前提下,尽可能剥离多余的指针寻址与分配开销。

接下来的关键步骤包括:

  • 进一步明确不同内核对象对局部存储的特定需求(例如套接字需优化创建速度)。

  • 在具体的网络场景下(如 100 万并发连接)测算携带有元数据的数据包实际比例,避免盲目设计过于复杂的通用机制。

  • 探索在 verifier(验证器)中引入“助手修复(helper fixups)”,并尝试完全消除套接字局部存储的动态内存分配。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Kernel_RDMA

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值