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_MAPS
和 HASH_OF_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
分别返回消费者/生产者的当前逻辑位置。
返回的值是环形缓冲区状态的瞬时快照,并且在辅助函数返回时可能不准确,因此这应该仅用于调试/报告目的或用于实现各种启发式算法,这些算法考虑了其中一些特征的高度可变性。
一种这样的启发式算法可能涉及对环形缓冲区中新数据可用性的 poll/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 字节的头部,其中包含预留记录的长度,以及两个额外的位:一个忙碌位,表示记录仍在处理中,以及一个丢弃位,如果记录被丢弃,则在提交时可能会设置此位。在后一种情况下,消费者应该跳过该记录并移到下一条。记录头部还编码了记录相对于环形缓冲区数据区域开头(以页为单位)的相对偏移量。这使得 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)表明,这允许实现非常高的吞吐量,而无需诉诸于诸如“仅每 N 个样本通知一次”之类的技巧,这些技巧在 perf 缓冲区中是必需的。对于极端情况,当 BPF 程序需要更多手动控制通知时,commit/discard/output 辅助函数接受 BPF_RB_NO_WAKEUP
和 BPF_RB_FORCE_WAKEUP
标志,这些标志提供对数据可用性通知的完全控制,但使用此 API 需要格外小心和谨慎。