ebpf ring buffer

本文是对内核 bpf ring buffer 文档的翻译,附加一些阅读过程中的额外补充。

BPF 环形缓冲区

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

动机

这项工作的出现有两个明显的动机,而现有的 perf buffer 无法满足这些需求,因此促成了新的环形缓冲区实现。

  • 通过在多个 CPU 之间共享环形缓冲区,提高内存使用效率;
  • 保留按时间顺序连续发生的事件的顺序,即使这些事件发生在多个 CPU 上,例如某个任务的 fork、exec、exit 事件。

这两个问题彼此独立,但 perf buffer 都无法满足。两者都源于 perf 采用了“每个 CPU 一个 perf 环形缓冲区”的设计选择。这两个问题也都可以通过 MPSC,也就是多生产者、单消费者的环形缓冲区实现来解决。排序问题在技术上也可以通过在 perf buffer 中加入某种内核计数机制来解决;但既然第一个问题已经需要 MPSC 缓冲区,那么同一个方案也会自动解决第二个问题。

语义和 API

单个环形缓冲区会以 BPF_MAP_TYPE_RINGBUF 类型的 BPF map 实例形式暴露给 BPF 程序。曾经考虑过另外两种方案,但最终都被放弃了。

一种方案是,类似于 BPF_MAP_TYPE_PERF_EVENT_ARRAY,让 BPF_MAP_TYPE_RINGBUF 表示一组环形缓冲区,但不强制“只能访问同一 CPU 的缓冲区”这一规则。这个接口会更熟悉,也更兼容现有 BPF 中 perf buffer 的使用方式;但如果应用需要根据任意 key 查找环形缓冲区的高级逻辑,这种方案就不够用了。当前方案可以通过 BPF_MAP_TYPE_HASH_OF_MAPS 解决这个问题。此外,考虑到 BPF ringbuf 的性能,很多使用场景其实只会选择一个简单的、由所有 CPU 共享的单一环形缓冲区;对于这种场景,数组式设计反而显得过度复杂。

另一种方案是,在 BPF map 之外引入一种新的概念,用来表示通用的“容器”对象。这个对象不一定具备 key/value 接口,也不一定支持 lookup、update、delete 操作。但这种方案会引入大量额外基础设施,例如可观测性支持、verifier 支持等。它还会给 BPF 开发者增加一个新的概念,并需要 libbpf 中的新语法等。更重要的是,它并不会比使用 map 的方案带来真正额外的好处。BPF_MAP_TYPE_RINGBUF 不支持 lookup、update、delete 操作,但其他一些 map 类型也不支持这些操作,例如 queue 和 stack;array 也不支持 delete。

最终选择的方案的优势在于,它复用了现有 BPF map 基础设施,包括内核中的 introspection API、libbpf 支持等;它也是开发者熟悉的概念,不需要让用户学习 BPF 程序中的新对象类型;同时还能利用现有工具,例如 bpftool。对于所有 CPU 共享一个环形缓冲区的常见场景,它与专门设计一个“容器”对象一样简单直接。另一方面,因为它本质上是一个 map,所以可以与 ARRAY_OF_MAPSHASH_OF_MAPS 这类 map-in-map 组合使用,从而实现各种不同拓扑结构:从每个 CPU 一个环形缓冲区,也就是替代 perf buffer 的使用场景,到复杂的应用级哈希/分片环形缓冲区,例如维护一小组环形缓冲区,并用任务的 tgid 哈希值作为查找 key,以保留事件顺序,同时减少竞争。

key 和 value 的大小会被强制要求为 0。max_entries 用来指定环形缓冲区的大小,并且必须是 2 的幂。

perf buffer,也就是 BPF_MAP_TYPE_PERF_EVENT_ARRAY,和新的 BPF ring buffer 在语义上有很多相似之处:

  • 支持可变长度记录;
  • 如果环形缓冲区没有剩余空间,reservation 会失败,不会阻塞;
  • 数据区可以被用户空间应用 mmap,从而便于消费并获得高性能;
  • 支持通过 epoll 通知新到达的数据;
  • 必要时仍然可以通过 busy polling 轮询新数据,以获得最低延迟。

BPF ringbuf 向 BPF 程序提供两组 API:

  • bpf_ringbuf_output() 允许从某个位置把数据复制到环形缓冲区,类似于 bpf_perf_event_output()
  • bpf_ringbuf_reserve()bpf_ringbuf_commit()bpf_ringbuf_discard() 这组 API 将整个过程拆成两步。首先,预留一段固定大小的空间。如果成功,会返回一个指向环形缓冲区数据区内部的指针,BPF 程序可以像使用 array/hash map 中的数据一样使用它。准备完成后,这块内存要么被提交,要么被丢弃。discard 类似 commit,但会让消费者忽略这条记录

bpf_ringbuf_output() 的缺点是会引入额外的内存拷贝,因为记录必须先在其他地方准备好。但它允许提交长度在 verifier 阶段无法提前得知的记录。它也与 bpf_perf_event_output() 非常接近,因此可以显著简化迁移过程。

bpf_ringbuf_reserve() 通过直接返回指向环形缓冲区内存的指针,避免了额外的内存复制。在很多情况下,记录大小会超过 BPF 栈空间允许的范围,因此许多程序不得不使用额外的 per-CPU array 作为临时堆,用于准备 sample。bpf_ringbuf_reserve() 完全避免了这种需求。但作为交换,它只允许预留已知的常量大小内存(通常指编译时确定的大,如 通过 sizeof 返回的大小,而不是运行时动态大小),这样 verifier 才能验证 BPF 程序不会越界访问超出其预留记录空间之外的内存bpf_ringbuf_output() 虽然因为额外复制而略慢,但覆盖了一些不适合 bpf_ringbuf_reserve() 的使用场景。

eBPF 程序的栈通常只有 512 字节。内核 Q: How much stack space a BPF program uses?也明确说,目前所有 BPF program types 都限制在 512 bytes stack space。
对于下面这种代码,会有问题:

struct event {
    __u32 pid;
    char comm[16];
    char filename[256];
    char argv[1024];
};

SEC("tracepoint/xxx")
int handle(void *ctx)
{
    struct event e = {};   // 这里就在 BPF 栈上分配,很容易超出栈的最大容量

    e.pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(&e.comm, sizeof(e.comm));

    bpf_ringbuf_output(&rb, &e, sizeof(e), 0);
    return 0;
}

所以以前常见写法是搞一个 per-CPU array:

struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __uint(max_entries, 1);
    __type(key, __u32);
    __type(value, struct event);
} heap SEC(".maps");

然后在程序里:

__u32 key = 0;
struct event *e;

e = bpf_map_lookup_elem(&heap, &key);
if (!e)
    return 0;

e->pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&e->comm, sizeof(e->comm));

bpf_ringbuf_output(&rb, e, sizeof(*e), 0);

这里 struct event 不在 BPF 栈上,而在 percpu array 的 value 里。
所谓的"临时堆"不是说用 malloc 分配堆内存,只是用 map value 模拟一块可写的大内存。
而 ringbuf_reserve 直接返回 ringbuf 内部 payload 指针,payload 大小可以通过 size 指定,因此真正的大 struct event 本身就存在 ringbuf 的 map 内存区域里,不是 BPF 栈上。

commit 和 discard 之间的差异很小。**discard 只是把某条记录标记为已丢弃,消费者代码应该忽略这样的记录。**discard 对一些高级使用场景很有用,例如确保多记录提交的全有或全无语义,或者在单次 BPF 程序调用中模拟临时的 malloc() / free()

每个已预留的记录都会通过现有的引用跟踪逻辑被 verifier 跟踪,类似于 socket 的引用跟踪。因此,预留一条记录后却忘记提交或丢弃,是不可能通过验证的。

bpf_ringbuf_query() helper 允许查询环形缓冲区的各种属性。目前支持 4 种:

  • BPF_RB_AVAIL_DATA 返回环形缓冲区中尚未消费的数据量;
  • BPF_RB_RING_SIZE 返回环形缓冲区的大小;
  • BPF_RB_CONS_POS / BPF_RB_PROD_POS 分别返回消费者 / 生产者当前的逻辑位置。

返回值只是环形缓冲区状态在某一瞬间的快照。helper 返回时,这些值可能已经过时。因此,它们应当只用于调试、报告,或者实现某些启发式策略;这些策略需要考虑到其中一些属性高度易变的特点。

其中一种启发式策略可能涉及对 ring buffer 中新数据可用性的 poll/epoll 通知进行更细粒度的控制。结合 output、commit、discard helper 的 BPF_RB_NO_WAKEUP / BPF_RB_FORCE_WAKEUP 标志,BPF 程序可以获得很高程度的控制能力,例如实现更高效的批量通知。不过,默认的自平衡策略对大多数应用已经足够,并且能够可靠、高效地工作。

设计和实现

这种 reserve/commit 模式为多个生产者提供了一种自然的协作方式。无论这些生产者位于不同 CPU 上,还是位于同一个 CPU、甚至同一个 BPF 程序中,它们都可以预留彼此独立的记录,并在不阻塞其他生产者的情况下处理这些记录。这意味着,如果一个 BPF 程序被另一个共享同一 ring buffer 的 BPF 程序中断,只要缓冲区还有足够空间,两者都可以各自成功预留一条记录,然后独立处理并提交。NMI 上下文同样适用,不过由于 reservation 阶段使用了 spinlock,在 NMI 上下文中,bpf_ringbuf_reserve() 可能无法获取锁。这种情况下,即使 ring buffer 没满,reservation 也可能失败。

环形缓冲区本身在内部实现为一个大小为 2 的幂的循环缓冲区,并维护两个逻辑上持续递增的计数器。这些计数器在 32 位架构上可能回绕,但这不是问题:

  • consumer counter 表示消费者已经消费到哪个逻辑位置;
  • producer counter 表示所有生产者已经预留的数据量。

每次记录被预留时,拥有该记录的生产者会成功推进 producer counter。此时数据还没有准备好被消费。每条记录都有一个 8 字节 header,其中包含预留记录的长度,以及两个额外 bit:busy bit 表示这条记录仍在被处理;discard bit 可以在 commit 时设置,用于表示该记录已被丢弃。在后一种情况下,消费者应该跳过这条记录,并继续处理下一条记录。记录 header 还会编码该记录相对于 ring buffer 数据区起点的偏移量,单位是 page。这样,bpf_ringbuf_commit() / bpf_ringbuf_discard() 只需要接收指向记录本身的指针,而不需要额外接收指向 ring buffer 本身的指针。ring buffer 的内存位置可以从记录元数据 header 中恢复出来。这显著简化了 verifier,也提升了 API 的易用性。

producer counter 的递增会通过 spinlock 串行化,因此 reservation 之间存在严格顺序。另一方面,commit 完全是无锁且相互独立的。所有记录都会按照 reservation 的顺序对消费者可见,但只有在之前所有记录都已经 commit 之后才会可见。因此,较慢的生产者可能会暂时阻塞那些虽然已经提交、但 reservation 时间更晚的记录。

一个很有意思的实现细节是,数据区会在虚拟内存中连续映射两次,前后相接。这个设计显著简化了生产者和消费者的实现,并因此提升了速度。由于这种映射方式,对于需要在循环缓冲区末尾发生 wrap around 的 sample,不需要采取任何特殊处理。因为最后一个数据页后面的下一页,会再次映射到第一个数据页,所以 sample 在虚拟内存中看起来仍然是完全连续的。可以参考 bpf_ringbuf_area_alloc() 中的注释和简单 ASCII 图示。

BPF ringbuf 区别于 perf ring buffer 的另一个特性,是它对新数据可用性通知采用了自节奏控制。bpf_ringbuf_commit() 的实现只有在消费者已经刚好追上正在 commit 的这条记录时,才会发送“新记录可用”的通知。如果不是这样,消费者仍然需要继续追赶,因此无论如何都会看到新数据,不需要额外的 poll 通知。基准测试,也就是 tools/testing/selftests/bpf/benchs/bench_ringbufs.c,表明这种机制可以实现非常高的吞吐量,而不需要使用 perf buffer 中常见的技巧,例如“每 N 个 sample 才通知一次”。在极端情况下,如果 BPF 程序希望手动控制通知,commit、discard、output helper 可以接受 BPF_RB_NO_WAKEUPBPF_RB_FORCE_WAKEUP 标志,从而完全控制数据可用性的通知。不过,使用这些 API 时需要格外谨慎和细致。

评论