Published at 2022-04-25 | Last Update 2022-04-25
本文翻译自 BPF 核心开发者 Andrii Nakryiko 2020 的一篇文章:BPF ring buffer。
文章介绍了 BPF ring buffer 解决的问题及背后的设计,并给出了一些代码示例和内核 patch 链接,深度和广度兼备,是学习 ring buffer 的极佳参考。
由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。
以下是译文。
很多场景下,BPF 程序都需要将数据发送到用户空间(userspace), BPF perf buffer(perfbuf)是目前这一过程的事实标准,但它存在一些问题,例如 浪费内存(因为其 per-CPU 设计)、事件顺序无法保证等。
作为改进,内核 5.8 引入另一个新的 BPF 数据结构:BPF ring buffer(环形缓冲区,ringbuf),
此外,实验与真实环境的压测结果都表明,从 BPF 程序发送数据给用户空间时, 应该首选 BPF ring buffer。
perfbuf 是 per-CPU 环形缓冲区(circular buffers),能实现高效的 “内核-用户空间”数据交互,在实际中也非常有用,但 per-CPU 的设计 导致两个严重缺陷:
因此内核 5.8 引入了 ringbuf 来解决这个问题。 ringbuf 是一个“多生产者、单消费者”(multi-producer, single-consumer,MPSC) 队列,可安全地在多个 CPU 之间共享和操作。perfbuf 支持的一些功能它都支持,包括,
此外,它还解决了 perfbuf 的下列问题:
下面具体来看。
perfbuf 为每个 CPU 分配一个独立的缓冲区,这意味着开发者通常需要 在内存效率和数据丢失之间做出折中:
对于那些大部分时间都比较空闲、周期性来一大波数据的场景, 这个问题尤其突出,很难在两者之间取得一个很好的平衡。
ringbuf 的解决方式是分配一个所有 CPU 共享的大缓冲区,
另外,ringbuf 内存效率的扩展性也更好,比如 CPU 数量从 16 增加到 32 时,
如果 BPF 应用要跟踪一系列关联事件(correlated events),例如进程的启动和终止、 网络连接的生命周期事件等,那保持事件的顺序就非常关键。 perfbuf 在这种场景下有一些问题:如果这些事件发生的间隔非常短(几毫秒)并且分散 在不同 CPU 上,那事件的发送顺序可能就会乱掉 ——这同样是 perbuf 的 per-CPU 特性决定的。
举个真实例子,几年前我写的一个应用需要跟踪进程 fork/exec/exit 事件,收集进程级别(per-process)的资源使用量。BPF 程序将这些事件 写入 perfbuf,但它们到达的顺序经常乱掉。这是因为内核调度器在不同 CPU 上调度进程时, 对于那些存活时间很短的进程,fork(), exec(), and exit() 会在极短的时间内在不同 CPU 上执行。 这里的问题很清楚,但要解决这个问题,就需要在应用逻辑中加入大量的判断和处理, 只有亲自做过才知道有多复杂。
但对于 ringbuf 来说,这根本不是问题,因为它是共享的同一个缓冲区。ringbuf 保证 如果事件 A 发生在事件 B 之前,那 A 一定会先于 B 被提交,也会在 B 之前被消费。 这个特性显著简化了应用处理逻辑。
BPF 程序使用 perfbuf 时,必须先初始化一份事件数据,然后将它复制到 perfbuf, 然后才能发送到用户空间。这意味着数据会被复制两次:
更糟糕的是,如果 perfbuf 已经没有足够空间放数据了,那第一步的复制完全是浪费的。
BPF ringbuf 提供了一个可选的 reservation/submit API 来避免这种问题。
预留成功后,
后面会有具体例子。
对于所有实际场景(尤其是那些基于 bcc/libbpf 的默认配置在使用 perfbuf 的场景), ringbuf 的性能都优于 perfbuf 性能。各种不同场景的仿真压测(synthetic benchmarking) 结果见内核 patch。
Per-CPU buffer 特性的 perfbuf 在理论上能支持更高的数据吞吐, 但这只有在每秒百万级事件(millions of events per second)的场景下才会显现。
在编写了一个真实场景的高吞吐应用之后,我们证实了 ringbuf 在作为与 perfbuf 类似的 per-CPU buffer 使用时,仍然可以作为 perfbuf 的一个高性能替代品,尤其是用到手动管理事件通知(manual data availability notification)机制时。
基本的 multi-ringbuf example 见内核 selftests:
唯一需要注意、最好先试验一下的场景:BPF 程序必须在
NMI (non-maskable interrupt) context 中执行时,例如处理 cpu-cycles
等 perf events 时。
ringbuf 内部使用了一个非常轻量级的 spin-lock,这意味着如果 NMI context 中有竞争,data reservation 可能会失败。 因此,在 NMI context 中,如果 CPU 竞争非常严重,可能会 导致丢数据,虽然此时 ringbuf 仍然有可用空间。
除了 NMI context 之外,在其他所有场景中优先选择 ringbuf 而不是 perfbuf 都是非常明智的。
完整代码见 bpf-ringbuf-examples project。
BPF 程序的功能是 trace 所有进程的 exec()
操作,也就是创建新进程事件。
exec()
事件:收集进程 ID (pid
)、进程名字 (comm
)、可执行文件路径 (filename
),然后发送给用户空间程序;printf()
打印输出。用三种不同方式实现,输出都类似:
$ sudo ./ringbuf-reserve-commit # or ./ringbuf-output, or ./perfbuf-output
TIME EVENT PID COMM FILENAME
19:17:39 EXEC 3232062 sh /bin/sh
19:17:39 EXEC 3232062 timeout /usr/bin/timeout
19:17:39 EXEC 3232063 ipmitool /usr/bin/ipmitool
19:17:39 EXEC 3232065 env /usr/bin/env
19:17:39 EXEC 3232066 env /usr/bin/env
19:17:39 EXEC 3232065 timeout /bin/timeout
19:17:39 EXEC 3232066 timeout /bin/timeout
19:17:39 EXEC 3232067 sh /bin/sh
19:17:39 EXEC 3232068 sh /bin/sh
^C
事件的结构体定义:
#define TASK_COMM_LEN 16
#define MAX_FILENAME_LEN 512
// BPF 程序发送给 userspace 的事件
struct event {
int pid;
char comm[TASK_COMM_LEN];
char filename[MAX_FILENAME_LEN];
};
这里有意让这个结构体的大小超过 512 字节,这样 event 变量就无法 放到 BPF 栈空间(max 512Byte)上,后面会看到 perfbuf 和 ringbuf 程序分别怎么处理。
// 声明一个 perfbuf map。几点注意:
// 1. 不用特意设置 max_entries,libbpf 会自动将其设置为 CPU 数量;
// 2. 这个 map 的 per-CPU buffer 大小是 userspace 设置的,后面会看到
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); // perf buffer (array)
__uint(key_size, sizeof(int));
__uint(value_size, sizeof(int));
} pb SEC(".maps");
// 一个 struct event 变量的大小超过了 512 字节,无法放到 BPF 栈上,
// 因此声明一个 size=1 的 per-CPU array 来存放 event 变量
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); // per-cpu array
__uint(max_entries, 1);
__type(key, int);
__type(value, struct event);
} heap SEC(".maps");
SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
unsigned fname_off = ctx->__data_loc_filename & 0xFFFF;
struct event *e;
int zero = 0;
e = bpf_map_lookup_elem(&heap, &zero);
if (!e) /* can't happen */
return 0;
e->pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_probe_read_str(&e->filename, sizeof(e->filename), (void *)ctx + fname_off);
// 发送事件,参数列表 <context, &perfbuf, flag, event, sizeof(event)>
bpf_perf_event_output(ctx, &pb, BPF_F_CURRENT_CPU, e, sizeof(*e));
return 0;
}
完整代码 the user-space side, 基于 BPF skeleton(更多信息见 这里)。
看一个关键点:使用 libbpf user-space perf_buffer__new()
API 来创建一个 perf buffer consumer:
struct perf_buffer *pb = NULL;
struct perf_buffer_opts pb_opts = {};
struct perfbuf_output_bpf *skel;
/* Set up ring buffer polling */
pb_opts.sample_cb = handle_event;
pb = perf_buffer__new(bpf_map__fd(skel->maps.pb), 8 /* 32KB per CPU */, &pb_opts);
这里设置 per-CPU buffer 为 32KB,
注意其中的 8 表示的是 number of memory pages,每个 page 是 4KB,因此总大小:
8 pages x 4096 byte/page = 32KB
。
完整代码:
bpf_ringbuf_output()
在设计上遵循了 bpf_perf_event_output()
的语义,
以使应用从 perfbuf 迁移到 ringbuf 时更容易。为了看出二者有多相似,这里展示下
两个示例代码的 diff。
--- src/perfbuf-output.bpf.c 2020-10-25 18:52:22.247019800 -0700
+++ src/ringbuf-output.bpf.c 2020-10-25 18:44:14.510630322 -0700
@@ -6,12 +6,11 @@
char LICENSE[] SEC("license") = "Dual BSD/GPL";
-/* BPF perfbuf map */
+/* BPF ringbuf map */
struct {
- __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
- __uint(key_size, sizeof(int));
- __uint(value_size, sizeof(int));
-} pb SEC(".maps");
+ __uint(type, BPF_MAP_TYPE_RINGBUF);
+ __uint(max_entries, 256 * 1024 /* 256 KB */);
+} rb SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
@@ -35,7 +34,7 @@
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_probe_read_str(&e->filename, sizeof(e->filename), (void *)ctx + fname_off);
- bpf_perf_event_output(ctx, &pb, BPF_F_CURRENT_CPU, e, sizeof(*e));
+ bpf_ringbuf_output(&rb, e, sizeof(*e), 0);
return 0;
}
只有两个小改动:
ringbuf map 的大小(max_entries)可以在 BPF 侧指定了,注意这是所有 CPU 共享的大小。
bpf_map__set_max_entries()
;max_entries
的单位是字节,必须是内核页大小(
几乎永远是 4096)的倍数,也必须是 2 的幂次。bpf_perf_event_output()
替换成了类似的 bpf_ringbuf_output()
,后者更简单,不需要 BPF context 参数。
事件 handler 签名有点变化:
-void handle_event(void *ctx, int cpu, void *data, unsigned int data_sz)
+int handle_event(void *ctx, void *data, size_t data_sz)
{
const struct event *e = data;
struct tm *tm;
如果 CPU index 对你很重要,那你需要自己在 BPF 代码中记录它。
另外,ring_buffer
API 不提供丢失数据(lost samples)的回调函数,而 perf_buffer
是支持的。
如果需要这个功能,必须自己在 BPF 代码中处理。
这样的设计对于一个(所有 CPU)共享的 ring buffer 能最小化锁竞争,
同时也避免了为不需要的功能买单:在实际中,这功能除了能用户在 userspace 打印出有数据丢失之外,其他基本也做不了什么,
而类似的目的在 BPF 中可以更显式和高效地完成。
第二个不同是 ring_buffer__new()
API 更加简洁:
/* Set up ring buffer polling */
- pb_opts.sample_cb = handle_event;
- pb = perf_buffer__new(bpf_map__fd(skel->maps.pb), 8 /* 32KB per CPU */, &pb_opts);
- if (libbpf_get_error(pb)) {
+ rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);
+ if (!rb) {
err = -1;
- fprintf(stderr, "Failed to create perf buffer\n");
+ fprintf(stderr, "Failed to create ring buffer\n");
goto cleanup;
}
接下来基本上就是文本替换一下的事情了:
perf_buffer__poll()
-> ring_buffer__poll()
printf("%-8s %-5s %-7s %-16s %s\n",
"TIME", "EVENT", "PID", "COMM", "FILENAME");
while (!exiting) {
- err = perf_buffer__poll(pb, 100 /* timeout, ms */);
+ err = ring_buffer__poll(rb, 100 /* timeout, ms */);
/* Ctrl-C will cause -EINTR */
if (err == -EINTR) {
err = 0;
break;
}
if (err < 0) {
- printf("Error polling perf buffer: %d\n", err);
+ printf("Error polling ring buffer: %d\n", err);
break;
}
}
bpf_ringbuf_output()
API 的目的是确保从 perfbuf 到 ringbuf 迁移时无需对 BPF 代
码做重大改动,但这也意味着它继承了 perfbuf API 的一些缺点:
额外的内存复制(extra memory copy)
这意味着需要额外的空间来构建 event 变量,然后将其复制到 buffer。不仅低效, 而且经常需要引入只有一个元素的 per-CPU array,增加了不必要的处理复杂性。
非常晚的 buffer 空间申请(data reservation)
如果这一步失败了(例如由于用户空间消费不及时导致 buffer 满了,或者有大量 突发事件导致 buffer 溢出了),那上一步的工作将变得完全无效,浪费内存空间和计算资源。
如果能提前知道事件将在第二步被丢弃,就无需做第一步了,
节省一些内存和计算资源,消费端反而因此而消费地更快一些。
但 xxx_output()
风格的 API 是无法实现这个目的的。
这就是为什么引入了新的 bpf_ringbuf_reserve()
/bpf_ringbuf_commit()
API。
NULL
);预留成功后,一旦数据写好了,将它发送到 userspace 是一个不会失败的操作。
也就是说只要 bpf_ringbuf_reserve()
返回非空,那随后的 bpf_ringbuf_commit()
就永远会成功,因此它没有返回值。
另外,ring buffer 中预留的空间在被提交之前,用户空间是看不到的, 因此 BPF 程序可以从容地组织自己的 event 数据,不管它有多复杂、需要多少步骤。 这种方式也避免了额外的内存复制和临时存储空间(extra memory copying and temporary storage spaces)。
唯一的限制是:BPF 校验器在校验时(at verification time), 必须知道预留数据的大小 (size of the reservation),因此不支持动态大小的事件数据。
bpf_ringbuf_output()
方式来提交,忍受额外的数据复制开销;--- src/ringbuf-output.bpf.c 2020-10-25 18:44:14.510630322 -0700
+++ src/ringbuf-reserve-submit.bpf.c 2020-10-25 18:36:53.409470270 -0700
@@ -12,29 +12,21 @@
__uint(max_entries, 256 * 1024 /* 256 KB */);
} rb SEC(".maps");
-struct {
- __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
- __uint(max_entries, 1);
- __type(key, int);
- __type(value, struct event);
-} heap SEC(".maps");
-
SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
unsigned fname_off = ctx->__data_loc_filename & 0xFFFF;
struct event *e;
- int zero = 0;
- e = bpf_map_lookup_elem(&heap, &zero);
- if (!e) /* can't happen */
+ e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
+ if (!e)
return 0;
e->pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_probe_read_str(&e->filename, sizeof(e->filename), (void *)ctx + fname_off);
- bpf_ringbuf_output(&rb, e, sizeof(*e), 0);
+ bpf_ringbuf_submit(e, 0);
return 0;
}
用户空间代码与之前的 ringbuf output API 完全一样,因为这个 API 涉及到的只是提交方(生产方), 消费方还是一样的方式来消费。
在高吞吐场景中,最大的性能损失经常来自提交数据时,内核的信号通知开销(in-kernel signalling of data availability) ,也就是内核的 poll/epoll 通知阻塞在读数据上的 userspace handler 接收数据。
这一点对 perfbuf 和 ringbuf 都是一样的。
perfbuf 处理这种场景的方式是提供了一个采样通知(sampled notification)机制: 每 N 个事件才会发送一次通知。用户空间创建 perfbuf 时可以指定这个参数。
这种机制能否解决问题,因具体场景而异。
ringbuf 选了一条不同的路:bpf_ringbuf_output()
和 bpf_ringbuf_commit()
都支持一个额外的 flags 参数,
BPF_RB_NO_WAKEUP
:不触发通知BPF_RB_FORCE_WAKEUP
:会触发通知基于这个 flags,用户能实现更加精确的通知控制。例子见 BPF ringbuf benchmark。
默认情况下,如果没指定任何 flag,ringbuf 会采用自适应通知 (adaptive notification)机制,根据 userspace 消费者是否有滞后(lagging)来动态 调整通知间隔,尽量确保 userspace 消费者既不用承担额外开销,又不丢失任何数据。 这种默认配置在大部分场景下都是有效和安全的,但如果想获得极致性能,那 显式控制数据通知就是有必要的,需要结合具体应用场景和处理逻辑来设计。
本文介绍了 BPF ring buffer 解决的问题及其背后的设计。
文中给出的示例代码和内核代码链接,展示了 ringbuf API 的基础和高级用法。 希望阅读本文之后,读者能对 ringbuf 有一个很好的理解和把握,能根据自己的具体应用 选择合适的 API 来使用。