BPF 环形缓冲区

本文档介绍了 BPF 环形缓冲区的设计、API 和实现细节。

动机

这项工作有两个独特的动机,现有的 perf 缓冲区无法满足这些动机,这促使创建新的环形缓冲区实现。

  • 通过在 CPU 之间共享环形缓冲区来提高内存利用率;

  • 即使跨多个 CPU(例如,任务的 fork/exec/exit 事件),也能保持按时间顺序发生的事件的顺序。

这两个问题是独立的,但 perf 缓冲区无法满足这两者。两者都是每个 CPU 都有 perf 环形缓冲区的选择的结果。两者也可以通过 MPSC 实现的环形缓冲区来解决。从技术上讲,可以通过一些内核计数来解决 perf 缓冲区的排序问题,但考虑到第一个问题需要 MPSC 缓冲区,相同的解决方案会自动解决第二个问题。

语义和 API

单个环形缓冲区以 BPF_MAP_TYPE_RINGBUF 类型的 BPF 映射实例呈现给 BPF 程序。考虑过其他两种替代方案,但最终被拒绝。

一种方法是,类似于 BPF_MAP_TYPE_PERF_EVENT_ARRAY,使 BPF_MAP_TYPE_RINGBUF 可以表示环形缓冲区数组,但不强制执行“仅限同一 CPU”规则。这将是更熟悉的接口,与 BPF 中现有的 perf 缓冲区使用兼容,但如果应用程序需要更高级的逻辑来按任意键查找环形缓冲区,则会失败。BPF_MAP_TYPE_HASH_OF_MAPS 通过当前方法解决了这个问题。此外,鉴于 BPF 环形缓冲区的性能,许多用例会选择在所有 CPU 之间共享一个简单的单个环形缓冲区,对于这种情况,当前的方法将是过度杀戮。

另一种方法可以引入一个新概念,与 BPF 映射并行,来表示通用的“容器”对象,该对象不一定具有带有查找/更新/删除操作的键/值接口。这种方法会增加很多额外的基础设施,必须为可观察性和验证器支持构建。它还会添加 BPF 开发人员必须熟悉的另一个概念,libbpf 中的新语法等等。但是,与使用映射的方法相比,实际上不会提供任何额外的好处。BPF_MAP_TYPE_RINGBUF 不支持查找/更新/删除操作,但其他一些映射类型(例如,队列和堆栈;数组不支持删除等)也不支持。

所选择的方法具有以下优势:重用现有的 BPF 映射基础设施(内核中的自省 API、libbpf 支持等),是熟悉的概念(无需教用户 BPF 程序中的新对象类型),并利用现有工具(bpftool)。对于使用所有 CPU 的单个环形缓冲区的常见场景,它像使用专用的“容器”对象一样简单直接。另一方面,通过成为映射,它可以与 ARRAY_OF_MAPSHASH_OF_MAPS map-in-maps 结合使用,以实现各种各样的拓扑结构,从每个 CPU 一个环形缓冲区(例如,作为 perf 缓冲区用例的替代),到复杂的应用程序哈希/分片环形缓冲区(例如,使用一小组环形缓冲区,并将哈希的任务 tgid 作为查找键以保持顺序,但减少争用)。

键和值大小强制为零。max_entries 用于指定环形缓冲区的大小,必须是 2 的幂的值。

perf 缓冲区(BPF_MAP_TYPE_PERF_EVENT_ARRAY)和新的 BPF 环形缓冲区语义之间存在许多相似之处

  • 可变长度记录;

  • 如果环形缓冲区中没有剩余空间,则预留失败,没有阻塞;

  • 用于用户空间应用程序的内存可映射数据区域,以便于使用和高性能;

  • 用于新传入数据的 epoll 通知;

  • 但仍然能够在必要时进行忙轮询以获取新数据,以实现最低延迟。

BPF ringbuf 向 BPF 程序提供两组 API

  • bpf_ringbuf_output() 允许将数据从一个位置复制到环形缓冲区,类似于 bpf_perf_event_output()

  • bpf_ringbuf_reserve()/bpf_ringbuf_commit()/bpf_ringbuf_discard() API 将整个过程分为两个步骤。首先,预留固定数量的空间。如果成功,则返回指向环形缓冲区数据区域内数据的指针,BPF 程序可以使用该指针,类似于数组/哈希映射中的数据。准备就绪后,要么提交此内存片段,要么丢弃。丢弃类似于提交,但使使用者忽略该记录。

bpf_ringbuf_output() 的缺点是会产生额外的内存复制,因为必须首先在其他位置准备记录。但它允许提交长度事先不为验证器所知的记录。它也与 bpf_perf_event_output() 非常匹配,因此将大大简化迁移。

bpf_ringbuf_reserve() 通过直接向环形缓冲区内存提供内存指针来避免额外的内存复制。在许多情况下,记录大于 BPF 堆栈空间允许的大小,因此许多程序都使用额外的每个 CPU 数组作为准备样本的临时堆。bpf_ringbuf_reserve() 完全避免了这种需求。但作为交换,它只允许预留已知常数大小的内存,以便验证器可以验证 BPF 程序无法访问其预留记录空间之外的内存。bpf_ringbuf_output() 虽然由于额外的内存复制而稍慢,但涵盖了一些不适合 bpf_ringbuf_reserve() 的用例。

提交和丢弃之间的差异非常小。丢弃只是将记录标记为已丢弃,并且使用者代码应该忽略这些记录。丢弃对于一些高级用例很有用,例如确保全有或全无的多记录提交,或在单个 BPF 程序调用中模拟临时 malloc()/free()

每个预留的记录都通过现有的引用跟踪逻辑(类似于套接字引用跟踪)由验证器跟踪。因此,不可能预留记录,但忘记提交(或丢弃)它。

bpf_ringbuf_query() 辅助函数允许查询环形缓冲区的各种属性。目前支持 4 个

  • BPF_RB_AVAIL_DATA 返回环形缓冲区中未消耗的数据量;

  • BPF_RB_RING_SIZE 返回环形缓冲区的大小;

  • BPF_RB_CONS_POS/BPF_RB_PROD_POS 分别返回消费者/生产者的当前逻辑位置。

返回值是环形缓冲区状态的瞬时快照,在辅助函数返回时可能会有偏差,因此这仅应用于调试/报告原因或用于实现各种启发式方法,这些启发式方法会考虑到其中一些特征的高度可变性质。

其中一种启发式方法可能涉及更精细地控制有关环形缓冲区中新数据可用性的轮询/epoll 通知。结合 output/commit/discard 辅助函数的 BPF_RB_NO_WAKEUP/BPF_RB_FORCE_WAKEUP 标志,它允许 BPF 程序高度控制,例如,更有效地批量通知。但是,默认的自平衡策略对于大多数应用程序来说应该是足够的,并且已经可以可靠且高效地工作。

设计和实现

这种保留/提交模式允许多个生产者(无论是在不同的 CPU 上,还是在同一个 CPU 上/在同一个 BPF 程序中)以一种自然的方式保留独立的记录并使用它们,而不会阻塞其他生产者。这意味着如果 BPF 程序被另一个共享同一环形缓冲区的 BPF 程序中断,它们都将获得一个保留的记录(前提是有足够的空间剩余),并且可以独立地使用它并提交它。这也适用于 NMI 上下文,但由于在保留期间使用了自旋锁,在 NMI 上下文中,bpf_ringbuf_reserve() 可能无法获得锁,在这种情况下,即使环形缓冲区未满,保留也会失败。

环形缓冲区本身在内部实现为一个大小为 2 的幂的循环缓冲区,带有两个逻辑上不断增加的计数器(在 32 位架构上可能会回绕,这不是问题)

  • 消费者计数器显示消费者消费数据的逻辑位置;

  • 生产者计数器表示所有生产者保留的数据量。

每次保留记录时,“拥有”该记录的生产者将成功地递增生产者计数器。但在这一点上,数据仍然未准备好被消费。每个记录都有一个 8 字节的头部,其中包含保留记录的长度,以及两个额外的位:busy 位表示记录仍在被处理,discard 位,如果记录被丢弃,可以在提交时设置。在后一种情况下,消费者应该跳过该记录并移动到下一个记录。记录头部还编码了记录相对于环形缓冲区数据区域起始位置的偏移量(以页为单位)。这允许 bpf_ringbuf_commit()/bpf_ringbuf_discard() 仅接受指向记录本身的指针,而无需同时指向环形缓冲区本身的指针。环形缓冲区的内存位置将从记录元数据头部恢复。这大大简化了验证器,并提高了 API 的可用性。

生产者计数器的递增在自旋锁下进行序列化,因此保留之间存在严格的顺序。另一方面,提交是完全无锁且独立的。所有记录都按照保留的顺序提供给消费者,但只有在所有先前的记录都已提交之后才会如此。因此,慢速生产者可能会暂时阻止稍后保留的已提交记录。

一个有趣的实现细节,大大简化(并因此加速)了生产者和消费者的实现,是数据区域如何在虚拟内存中连续两次背靠背映射。这允许对必须在循环缓冲区数据区域末尾回绕的样本不采取任何特殊措施,因为最后一个数据页之后的下一页将再次是第一个数据页,因此该样本在虚拟内存中仍然显得完全连续。请参阅 bpf_ringbuf_area_alloc() 中的注释和一个简单的 ASCII 图,以直观地显示这一点。

BPF 环形缓冲区与 perf 环形缓冲区的另一个区别是新的可用数据的自我步调通知。bpf_ringbuf_commit() 的实现仅在消费者已经追赶到被提交的记录时才会发送新记录可用的通知。如果不是,消费者仍然需要追赶,因此无论如何都会看到新数据,而无需额外的轮询通知。基准测试(请参阅 tools/testing/selftests/bpf/benchs/bench_ringbufs.c)表明,这允许实现非常高的吞吐量,而无需像 perf 缓冲区那样诉诸“仅每 N 个样本通知一次”的技巧。对于极端情况,当 BPF 程序想要更多地手动控制通知时,提交/丢弃/输出辅助函数接受 BPF_RB_NO_WAKEUPBPF_RB_FORCE_WAKEUP 标志,这些标志可以完全控制数据可用性的通知,但需要在使用此 API 时格外小心和勤勉。