io_uring and network in 2023

原作者:Jens Axboe

原文:github.com/axboe/liburing/wiki/io_uring-and-networking-in-2023

译者:等疾风

(本翻译已获得原作者授权。未经译者允许,请勿转载。)

介绍

作为一个 IO 模型,io_uring 既适用于存储,也适用于网络。在 UNIX 中,人们经常吹捧“一切皆文件”。多数情况下这是对的,然而,一旦你需要对文件做 IO,所有的文件就不再众生平等,而是必须被区别对待。

由于基于完成模型的设计,将存储应用迁移到 io_uring 非常简单,只需遵循这几步:

  1. 将 libaio 之类的调用简单替换为 io_uring
  2. 加入 io_uring 独有的高级特性。

对于网络应用来说,迁移到地道的 io_uring 会稍微复杂些。几十年来,网络应用程序一直使用基于就绪模型编写,最常见的做法是借助epoll(2),在 socket 有可用数据时收到通知。虽然你可以将 epoll 简单地替换为 io_uring,继续沿用基于就绪模型,这虽然能够减少系统调用,但并不利于后期的优化,因为你将无法利用 io_uring 提供的高级特性。所以,你需要将基于就绪模型改造为基于完成模型

本文旨在介绍当前版本已有的、针对网络应用量身定制的功能,并假设读者已经了解 io_uring 的基础知识。本文并非为了展示从 epoll 迁移到 io_uring 的成功案例和收益。

批量处理(Batching)

io_uring 的主要优点之一是可以在单个系统调用中完成多个操作。
只要你试过同时填写多个 SQE(submission queue entry), 最后一次性地向内核提交所有请求,你就能明显地体会到。(io_uring_get_sqe() + io_uring_prep_foo(sqe) + io_uring_submit())

io_uring_enter(2) 是 io_uring 中用于提交 IO 请求的低级系统调用,它还能顺便等待 IO 的完成。这是一个重要的设计,相比于将提交 IO 和等待 IO 分开成两次操作,合并成一次操作有着更高的同步效率。liburing 封装了 io_uring_submit_and_wait(),以便将提交和等待操作合成到单个系统调用中。

这在 IO 事件循环中非常有用,因为一个 IO 完成后通常又需要做新的 IO。应用程序可以处理这些完成事件,同时获取新的 SQE 并准备提交,最后调用另一个 io_uring_submit_and_wait() 来开启新一轮事件循环。

相关 man 手册页:io_uring_submit_and_wait(3), io_uring_get_sqe(3), io_uring_submit(3)

连发(Multi-shot)

默认情况下,任何 SQE 都只会产生一个完成事件,即一个 CQE。例如,应用提交一个 read 请求,当 read 完成时,只会产生一个完成事件。但是,io_uring 还支持连发请求。连发请求可以产生多个完成事件,即一个 IO 能触发多次。

考虑一个网络服务器应用,它需要不断接受(accept)新连接。程序可以申请 SQE 并用io_uring_prep_accept()来接受一次连接。一旦连接请求到来,就产生一个完成事件,之后,程序必须提交新的 SQE 来处理后来的连接。

这种循环模式可以考虑改用io_uring_prep_multishot_accept()来做“连发”。这会告诉 io_uring 在接受了一个连接并产生完成事件后,继续保持监听状态,下一次收到新的连接时继续产生新的 CQE。这减少了程序在处理新连接后的啰嗦工作。连发的 accept 从 Linux 5.19 起可用。

默认情况下,一个连发请求会保持活跃,直到:

  1. 它们被显式取消(例如使用io_uring_prep_cancel()之类的函数),或者
  2. 请求本身遇到错误。

如果连发请求仍然活跃,则 CQE(struct io_uring_cqe) 的 flags 字段将包含 IORING_CQE_F_MORE 标志。如果没有此标志,意味着连发请求失效,程序应该重新提交新的请求。一般来说,连发请求会一直有效,除非遇到错误。

除了 accept,还有其它 IO 支持连发。例如一个网络应用需要在 socket 上连续多次接收数据,就可以考虑将 io_uring_prep_recv() 替换为 io_uring_prep_recv_multishot()。与连发的 accept 类似,每当在 socket 上有数据可读时都会产生一个完成事件。敏锐的读者可能会问“但是收到的数据放在哪里?”。为此,io_uring 提供一个"provided buffers"的概念(将在下一节中详细介绍)。连发的 recv 从 Linux 6.0 开始可用。

除了 recv 和 accept 之外,io_uring 还支持 poll 的连发。理念是相同的 —— 发起一次请求,每当关心的 poll 掩码变为真就得到一次通知。从 Linux 5.13 起可用。

相关的 man 手册页:io_uring_prep_recv_multishot(3), io_uring_prep_multishot_accept(3), io_uring_prep_poll_multishot(3)

Provided buffers

基于就绪的 IO 模型在推迟提供 buffer 方面有显著的优势 —— 你可以等到收到 IO 就绪通知之后再提供 buffer 并拷贝数据。但对于基于完成的模型来说,buffer 却需要在提交请求前就准备好。诚然,应用程序不难在提交请求时先申请一个 buffer,但如果堆积的请求数高达十几万个,这种做法可能会导致内存紧张,可扩展性不强。

于是,io_uring 支持一种称为 provided buffers 的机制。应用程序预留一个 buffer 池,并将这些 buffer 交给 io_uring 管理。这允许内核在拷贝数据前在 buffer 池中选择合适的 buffer,而不是在提交请求时就指定好。最后,产生的 CQE 将告诉你选择了哪一个 buffer。

io_uring 支持两种形式的 provided buffers:

  1. 老接口 io_uring_prep_provide_buffers(),在 Linux 5.7 后可用。
  2. 新的做法是"环形映射 buffers"("ring mapped buffers"),从 Linux 5.19 开始支持。

这两种形式的原理是一样的,唯一的区别是程序如何向 io_uring 提供 buffer。新的做法性能更高(请参阅 commit),是应用程序的首选。

每个 buffer 由一个 group ID 和一个 buffer ID 确定。Group 旨在便于应用程序区分 buffer 的各种类型。一个例子是根据 buffer 的大小分成不同的 group。在同一个 group 内的 buffer ID 必须是唯一的。

应用程序必须先对每个 group ID 注册一个环形队列。调用io_uring_register_buf_ring(),它会初始化一个环形队列,由应用程序和内核共享。然后,应用程序可以用io_uring_buf_ring_add()将 buffer 添加到环中,从而能被内核使用。这高效率地把 buffer 的所有权转给了内核。一旦内核使用了一个 buffer,内核将告诉应用程序所用的 group ID 和 buffer ID,意味着 buffer 的所有权移交给应用程序。每次添加完若干 buffer 后,应用程序必须调用io_uring_buf_ring_advance()来告诉内核添加了多少 buffer。

一旦应用程序处理完一个 buffer,就可以将其所有权转给内核,使得 buffer 可以循环使用。

利用 provided buffers 机制,应用程序可以提交 recv 请求(或者其他读请求),而无需预先提供 buffer。程序只需在 SQE 的 flags 字段中设置 IOSQE_BUFFER_SELECT 标志,并在 buf_group 字段中设置好 group ID 即可。原本用于拷贝数据的地址应该填成 NULL。一旦 recv 操作就绪,内核就会自动选择一个 buffer,并且在产生的 CQE 的 flags 字段中包含这个 buffer ID。CQE 的 flags 字段还会包含 IORING_CQE_F_BUFFER标志,表明此操作使用了 provided buffers。

如果 IO 触发的太快了,provided buffers 可能会因为来不及循环补充而被耗尽。在这种情况下,请求将会失败,并且带有错误码-ENOBUFS。一种缓解办法是,为同一类型的 buffer 安排多个 buffer group,然后循环使用这些 group。一旦收到缓冲区溢出错误,应用程序就立即切换到下一个 buffer group,并重新提交连发(或者普通)的 IO 请求。

初始化一个 buffer 环形队列乍一看有亿点麻烦。因为数据是经过内核映射的,我们要确保队列所在的内存是按页大小对齐的。下面的例子展示了如何初始化一个环并且在环上填满 buffer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct io_uring_buf_ring *setup_buffer_ring(struct io_uring *ring)
{
struct io_uring_buf_reg reg = { };
struct io_uring_buf_ring *br;
int i;

/* 申请环形队列的内存 */
if (posix_memalign((void **) &br, 4096,
BUFS_IN_GROUP * sizeof(struct io_uring_buf_ring)))
return NULL;

/* 向内核注册这个环形队列 */
reg.ring_addr = (unsigned long) br;
reg.ring_entries = BUFS_IN_GROUP;
reg.bgid = BUF_BGID;
if (io_uring_register_buf_ring(ring, &reg, 0))
return 1;

/* 初始化环上的每个 buffer 信息 */
io_uring_buf_ring_init(br);
for (i = 0; i < BUFS_IN_GROUP; i++) {
/* 添加一个 buffer,其 buffer ID 是 i */
io_uring_buf_ring_add(br, bufs[i], BUF_SIZE, i,
io_uring_buf_ring_mask(BUFS_IN_GROUP), i);
}

/* 我们已经准备好了所有 buffer,将其所有权转给 io_uring */
io_uring_buf_ring_advance(br, BUFS_IN_GROUP);
return br;
}

这个例子硬编码了 4096 作为内存页大小,实战中应该用 sysconf(_SC_PAGESIZE) 或其他办法来避免硬编码。在处理 CQE 时,应用程序是这么写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
io_uring_wait_cqe(ring, &cqe);
/* cqe->flags 会带有 IORING_CQE_F_BUFFER 标志。先找到 buffer ID */
buffer_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
/* 找到 buffer ID 对应的 buffer */
buf = bufs[buffer_id];
// [...你的应用逻辑...]
/* buffer 的使命结束了,把它写回环形队列里 */
io_uring_buf_ring_add(br, bufs[buffer_id], BUF_SIZE, buffer_id,
io_uring_buf_ring_mask(BUFS_IN_GROUP), 0);
/* 将所有权交给 io_uring */
io_uring_buf_ring_advance(br, 1);
/* CQE 的使命也结束了,还给 io_uring */
io_uring_cqe_seen(ring, cqe);

如果归还 buffer 和归还 CQE 同时发生(就和上面一样),则可以只调用一个函数:

1
2
/* 同时归还 CQE 和 buffer 的所有权 */
io_uring_buf_ring_cq_advance(ring, br, 1);

相关手册页:io_uring_register_buf_ring(3), io_uring_buf_ring_add(3), io_uring_buf_ring_advance(3), io_uring_buf_ring_cq_advance(3), io_uring_enter(2)

Socket 状态

默认情况下,当你提交一个 recv 请求,io_uring 就会去尝试读取一次。此时如果没有数据可读,io_uring 会启用一个轮询机制,等待 socket 的可读。为了提高用户和内核的性能,io_uring 会告诉应用程序“这个 socket 是否有更多数据可读”;类似地,应用程序可以告诉 io_uring “这个 socket 是否直接进入轮询(跳过尝试读取)”,避免无谓的性能损耗。

如果 CQE 的 flags 字段包含了 IORING_CQE_F_SOCK_NONEMPTY,那么相应的 socket 还有可读的数据。从 Linux 5.19 开始可用。

另一边,在提交 recv IO 时,如果你认为此时 socket 应该没有可读的数据,则可以提示 io_uring 跳过尝试读取。应用程序可以在 SQE 的 ioprio 字段中设置 IORING_RECVSEND_POLL_FIRST,让 io_uring 跳过第一次 recv/send 尝试,直接进入轮询等待状态。

IORING_RECVSEND_POLL_FIRST 既能用于接收也能用于发送。即便设置了这个标志,在 IO 提交时如果数据可用或者空间足够,操作仍会立即进行。从 Linux 5.19 起可用。

相关手册页:io_uring_prep_send(3), io_uring_prep_sendmsg(3), io_uring_prep_recv(3), io_uring_prep_recvmsg(3)

Task work

(译注:按照笔者的理解,task 大概可理解为 Linux 进程)

上一节提到,io_uring 依赖内部的轮询机制来触发(或重新触发)操作。一旦操作准备就绪,一个 task_work 会用于处理它。顾名思义,task_work 由 task 本身运行,尤其是提交请求的那个 task。默认情况下,每当应用程序在内核态和用户态之间切换时,就会运行 task work。接下来,为了向 task 报告 task work 的状态,它可能会引发内核和用户态之间的强制转换。在 io_uring 内部,这是通过处理器间中断(inter-processor interrupt, IPI)来实现的,这将触发 task 的重新调度。这类似于 task 处理信号(signal)的方式。

粗鲁地使用中断是对性能不友好的。如果应用程序正忙于数据运算,那么强制它进出内核会拖慢速度。幸运的是,io_uring 提供了一种规避方法。在初始化 io_uring 时,应用程序可以设置 IORING_SETUP_COOP_TASKRUN,这样就能在 task_work 排队时,避免 IPI 和 task 的强制重新调度,将它们推迟到下一次用户/内核态转换(通常是下一次系统调用时)。根据等待和处理事件的方式,应用程序还可以设置 IORING_SETUP_TASKRUN_FLAG 标志,使得 io_uring 会维护一个标志,表示是否需要一次系统调用来处理 task_work。如果应用程序依靠 io_uring_peek_cqe() 来检查是否有完成事件就会很有用,它会告诉 liburing 是否需要 io_uring_enter(2) 来进入内核并处理 task_work。从 Linux 5.19 起可用。

IORING_SETUP_COOP_TASKRUN 的一个缺点是,task_work 会在任何的用户/内核态切换时执行,而繁忙的网络应用可能会存在大量与 io_uring 无关的系统调用。这使得应用程序很难做好批量处理。为了改进这一点,引入了 IORING_SETUP_DEFER_TASKRUN,如果在初始化 io_uring 时带上这个标志,io_uring 就只会在应用程序显式等待完成事件时执行 task work。实际应用已经证明这在效率方面有非常大的好处,因为它使得程序能够真正实现完成端的批量处理。注意 IORING_SETUP_DEFER_TASKRUN 必须配合IORING_SETUP_SINGLE_ISSUER 使用,它表示只有一个应用(线程)会向这个 io_uring 提交请求。这允许更多的内部优化。一般来说,不建议在线程之间共享一个 io_uring,以便避开不必要的同步。从 Linux 6.1 起可用。

相关手册页:io_uring_setup(2), io_uring_queue_init(3),io_uring_queue_init_params(3)

环间消息(Ring messages)

环间消息是一种在两个 io_uring 之间传递消息的方法。可以用来唤醒某个正在阻塞等待的 io_uring,或者单纯地在两个 io_uring 之间发送数据。

环间消息的最简单的玩法是在两个环之间传输 8 字节的数据。io_uring 不会修改或者关心数据内容,你可以传递任何 8 字节的东西,例如一个指针。一个使用场景是网络后端应用,它们需要将接收到的连接(connections)分发到各个线程中,或者是将耗时的工作卸载到另一个线程。io_uring_prep_msg_ring() 可以用来初始化环间消息的 SQE。从 Linux 5.18 起可用。

另一种玩法是在环之间传递文件描述符(file descriptor, fd),尤其是传递直接描述符(direct descriptor)(译注:这是一种 io_uring 独享的 fd,可提高性能)。例如后端利用直接描述符来做 accept,并将产生的连接分发给子线程。从 Linux 6.0 起可用。

相关手册页:io_uring_prep_msg_ring(3), io_uring_prep_msg_ring_cqe_flags(3), io_uring_prep_msg_ring_fd(3), io_uring_register_files(3)

结论

以上只是应用程序在现今的 Linux 内核中可以享用的优化和功能中的一部分。虽然本文主要以网络为中心,但很多技巧都适用于其它使用 io_uring 的应用。本文也不应该被当作完全的指南,因为还有更多未提到的优化和功能,例如“直接描述符”(direct descriptor),它可以避免在线程之间共享文件描述符表。还有 io_uring 支持的“零拷贝传输”(zero-copy transmit)。这些话题将在以后的文章中介绍。

同样值得注意的是,上述绝大多数技巧都经过充分的实际应用考验,例如 Thrift。随着网络领域的应用越来越多,这里肯定会有更多的创新,因此,本文在很大程度上也只是一项正在进行中的工作。

参考

Jens Axboe 著 2023-02-14

等疾风 译 2023-10-14