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 非常简单,只需遵循这几步:
- 将 libaio 之类的调用简单替换为 io_uring
- 加入 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 起可用。
默认情况下,一个连发请求会保持活跃,直到:
- 它们被显式取消(例如使用
io_uring_prep_cancel()
之类的函数),或者 - 请求本身遇到错误。
如果连发请求仍然活跃,则 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:
- 老接口
io_uring_prep_provide_buffers()
,在 Linux 5.7 后可用。 - 新的做法是"环形映射 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 | struct io_uring_buf_ring *setup_buffer_ring(struct io_uring *ring) |
这个例子硬编码了 4096 作为内存页大小,实战中应该用 sysconf(_SC_PAGESIZE)
或其他办法来避免硬编码。在处理 CQE 时,应用程序是这么写的:
1 | io_uring_wait_cqe(ring, &cqe); |
如果归还 buffer 和归还 CQE 同时发生(就和上面一样),则可以只调用一个函数:
1 | /* 同时归还 CQE 和 buffer 的所有权 */ |
相关手册页: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。随着网络领域的应用越来越多,这里肯定会有更多的创新,因此,本文在很大程度上也只是一项正在进行中的工作。
参考
- io_uring kernel tree: git.kernel.dk/cgit/linux-block (
git clone https://git.kernel.dk/linux.git
) - liburing source and man pages: git.kernel.dk/cgit/liburing (
git clone https://git.kernel.dk/liburing.git
)
Jens Axboe 著 2023-02-14
等疾风 译 2023-10-14