[译]linux io_uring 手册
nxdong November 05, 2022 [linux, 翻译] #io_uring异步 I/O 组件。
头文件
描述
io_uring
是Linux
特有的异步I/O API。它允许用户提交一个或者多个I/O请求,它们被异步处理而不会阻塞调用进程。
io_uring
的名字来自于在用户空间和内核空间之间共享的环形缓冲区。
这种安排允许高效的 I/O,同时尽可能避免在它们之间复制缓冲区的开销。
这个接口使 io_uring
不同于其他 UNIX I/O API,在这里,不仅仅是通过系统调用在内核和用户空间之间进行通信,而是使用环缓冲区作为主要的通信模式。
这个机制有很多性能上的优势,下面一节将分条表述。
本手册使用可交替的共享缓冲区,共享环形缓冲区,队列。
下面概述了 io_uring
需要遵循的一般编程模型:
-
使用
io_uring_setup(2)
和mmap(2)
设置共享缓冲区,将共享缓冲区映射到用户空间,作为提交队列(SQ)
和完成队列(CQ)
。你将想做的I/O请求放到SQ里,而内核将这些操作的结果放到CQ里。 -
对于每个你想做的I/O请求(比如读文件,写文件,接收socket连接等等),你都创建一个提交队列条目,或者说SQE,它描述了你想要做的I/O 请求并且将它放到提交队列(SQ)的末尾。实际上,如果您没有使用
io_uring
的话,每个 I/O 操作都相当于一个系统调用。根据要请求的操作数量,可以向队列中添加多个 SQE。 -
在添加了一个或多个 SQE 之后,需要调用
io_uring_enter(2)
来告诉内核将 I/O 请求从 SQ 队列取出并开始处理它们。 -
对于您提交的每个 SQE,一旦处理完请求,内核就会在完成队列或 CQ 的尾部放置一个完成队列事件或 CQE。
-
对于提交的每个SQE,一旦处理完它的请求,内核把一个完成队列事件(或者说CQE)放到完成队列(或者说CQ)的队尾。对于在SQ上提交的每个SQE,内核在CQ中只放置一个匹配的CQE。在取到CQE之后,如果您直接使用它而不使用
io_uring
,那么您可能会对检查CQE结构的res字段感兴趣,该字段对应于系统调用的等效返回值。例如,io_uring
下的读取操作(以IORING_OP_READ
开始的操作)发出与read(2)
系统调用等效的命令。实际上,它混合了pread(2)
和preadv2(2)
的语义,因为它采用显式offset
,并支持使用-1
作为offset
的值来表示应该使用文件当前的位置,而不是传递一个显式偏移。有关更多细节,请参见opcode
文档。考虑到io_ uring
是一个异步接口,errno
从不用于传回错误信息。与此对应,res
将保存在成功的情况下等效的系统调用将要返回的内容,而在出错的情况下res
将包含-errno
。例如,如果正常读取系统调用返回-1
并将errno
设置为EINVAL
,则res
将包含-EINVAL
。 -
另外,
io_uring_enter(2)
还可以等待内核处理指定数量的请求,然后再返回。如果您指定了需要等待完成的事件的数量,那么内核将至少在CQ上放置这些数量的CQE,您可以在从io_uring_enter(2)
返回后立即读取这些CQE。 -
一定要记住,提交给内核的 I/O 请求可能按任何顺序完成。内核没有必要按照放置的顺序一个接一个的处理请求。假设接口是一个环,请求将按顺序提交,但是这并不意味着在请求完成时有任何顺序。当有多个请求正在运行时,无法确定哪个请求将首先完成。当将CQE从CQ队列中取出时,您应该始终检查它对应于哪个提交请求。最常用的方法是利用请求中的
user_data
字段,该字段在完成端传递回来。
添加和读取队列:
-
应用将SQE添加到SQ的尾部。内核从队列的头部读取SQE。
-
内核将CQE添加到CQ的尾部。应用从队列的头部读取CQE。
提交队列轮询
io_uring
的目标之一是为高效的 I/O 提供途径。为此,io_uring
支持一种轮询模式,可以避免调用用于通知内核应用已经将 SQE 入队到 SQ 上的 io _uring_enter(2)
接口。使用SQ轮询,io_uring
启动一个内核线程,轮询提交队列中通过添加SQE提交的任何I/O请求。启用SQ轮询后,您无需调用io_uring_enter(2)
,从而避免了系统调用的开销。指定的内核线程在您添加SQE时将其从SQ中取出,并将其分派于异步处理。
设置 io_uring
设置io_uring
的主要步骤包括使用mmap(2)
映射共享缓冲区。
在本手册页中包含的示例程序中,函数app_setup_uring()
使用QUEUE_DEPTH
大小的深度设置提交队列io_uring
(注,这个地方没搞明白,根据io_uring_setup
的文档,这个函数的第一个参数是个掩码标志,标志了这次调用要设置的内容,而在这个文档的sample程序中,QUEUE_DEPTH
的值是1,根据文档是IORING_SETUP_IOPOLL
)。注意设置共享提交和完成队列的2个mmap(2)
调用。如果您的内核早于5.4版,则需要三次mmap(2)
调用。
提交I/O 请求
提交请求的过程包括描述需要使用io_uring_sqe
结构实例完成的I/O操作。这些详细信息描述了等效系统调用及其参数。因为Linux支持的I/O操作范围非常多样,io_uring_sqe
结构需要能够描述这些操作,所以它有几个字段,为了节省空间,有些字段被打包成union。
下面是struct io_uring_sqe
的简化版本,其中包含一些最常用的字段:
;
完整的 是这样的(文档内):
;
注意,这个结构的定义在最新的linux代码中略有不同。
要向io_uring
提交I/O请求,应用需要从提交队列(SQ)中获取一个提交队列条目(SQE),填写您要提交的操作的详细信息,然后调用io_uring_enter(2)
。有名字叫io_uring_prep_X
的助手函数,用于正确设置SQE。如果希望避免调用 io_uring_enter(2)
,可以选择设置提交队列轮询。
SQE被添加到提交队列的尾部。内核从SQ的头部拾取SQE。
获得下一个可用的SQE并更新尾部的一般算法如下。
struct io_uring_sqe *sqe;
unsigned tail, index;
tail = *sqring->tail;
index = tail & ;
sqe = &sqring->sqes;
/* fill up details about this I/O request */
;
/* fill the sqe index into the SQ ring array */
sqring->array = index;
tail++;
;
要获取条目的索引,应用程序必须使用环的大小做掩码屏蔽当前的尾部索引。
这对SQ和CQ都适用。
获取SQE后,填写必要的字段,描述请求。
虽然CQ环直接索引CQE的共享阵列,但提交侧在它们之间具有间接阵列。
提交端环形缓冲区是该数组的索引,该数组又包含SQE的索引。
下面的代码片段演示了如何通过使用必要的参数填充SQE来描述读取操作(相当于preadv2(2)
系统调用)。
struct iovec iovecs;
...
sqe->opcode = IORING_OP_READV;
sqe->fd = fd;
sqe->addr = iovecs;
sqe->len = 16;
sqe->off = offset;
sqe->flags = 0;
Memory ordering
现代编译器和 CPU 可以自由地对读写进行重新排序,而不会影响程序的结果以优化性能。
在 SMP 系统上需要记住这方面的一些问题,因为 io_uring
涉及内核和用户空间之间共享的缓冲区。
这些缓冲区从内核和用户空间都是可见的并且可以修改的。
由于属于这些共享缓冲区的头部和尾部由内核和用户空间更新,因此无论在内核用户模式切换发生后是否发生了CPU切换,都需要在两侧一致可见。
我们使用内存屏障来加强这种一致性。
内存屏障本身是一个很大的主题,因此不在本手册页的进一步讨论范围之内。
让内核知道I/O提交
一旦将一个或多个SQE放置到SQ上,需要让内核知道已经这样做了。
可以通过调用io_uring_enter(2)
系统调用来实现这一点。
此系统调用还能够等待指定的事件计数完成。
通过这种方式,您可以确保在完成队列中找到完成事件,而无需在以后轮询事件。
读取完成事件
与提交队列(SQ)类似,完成队列(CQ)是内核和用户空间之间的共享缓冲区。
应用将提交队列条目放在SQ的尾部,内核读取其头部;当涉及到CQ时,内核将完成队列事件或CQE放在CQ的尾部,应用读取其头部。
提交是灵活的(因此有点复杂),因为它需要能够对采用各种参数的不同类型的系统调用进行编码。
另一方面,完成更简单,因为我们只需要从内核返回一个返回值。
通过查看完成队列事件结构结构io_uring_cqe
可以很容易地理解这一点。
;
user_data
是自定义数据,从提交到完成都是不变的。也就是说,从SQE到CQE。此字段可用于设置上下文,唯一标识已完成的提交。鉴于I/O请求可以按任何顺序完成,该字段可用于将提交与完成关联起来。
res
是作为提交的一部分执行的系统调用的结果的返回值。
flags
字段将来可以携带特定于请求的元数据,但目前尚未使用。
从完成队列中读取完成事件的一般顺序如下:
unsigned head;
head = *cqring->head;
if
;
需要提醒的是,内核将 CQE 添加到 CQ 的尾部,而应用需要从头部删除它们。
要在头部获取条目的索引,应用程序必须使用环的大小掩码屏蔽当前头部索引。
一旦CQE已被消耗或处理,则需要更新头部以反映CQE的消耗。
应注意读写屏障,以确保成功读取和更新head。
IO_URING 性能
由于内核和用户空间之间的共享环形缓冲区,io_uring
可以是一个零拷贝系统。
当涉及到在内核和用户空间之间传输数据的系统调用时,需要将缓冲区在内核和用户之间复制。
但是,由于io_uring
中的大部分通信是通过内核和用户空间之间共享的缓冲区进行的,因此完全避免了这种巨大的性能开销。
虽然系统调用看起来不是一个很大的开销,但在高性能应用程序中,进行大量调用将开始变得关键。
尽管操作系统为处理Spectre和Meltdown而采取的变通方法最好是取消,但不幸的是,其中一些变通方法是围绕系统调用接口进行的,使得在受影响的硬件上进行的系统调用不像以前那么便宜。
虽然较新的硬件不需要这些解决方案,但具有这些漏洞的硬件可能会在很长一段时间内处于闲置状态。
在使用同步编程接口时,甚至在Linux下使用异步编程接口时时,每个请求的提交至少涉及一个系统调用。
另一方面,在io_uring
中,您可以一次批处理多个请求,只需将多个SQE排队,每个SQE描述您想要的I/O操作,并对io_uring_enter(2)
进行一次调用。由于io_uring
基于共享缓冲区的设计,这是可能的。
虽然此批处理本身可以避免与潜在的多个频繁系统调用相关的开销,但您可以通过提交队列轮询进一步减少此开销,方法是在将SQE添加到提交队列时让内核轮询并拾取它们进行处理。这避免了您需要进行的io_uring_enter(2)
调用,以通知内核选择SQE。对于高性能应用程序,这意味着更少的系统调用开销。
Conforming to
io_uring is Linux-specific.
例子
下面的示例使用io_uring
将stdin
复制到stdout
。使用shell
重定向,您应该能够使用此示例复制文件。
因为它只使用一个队列深度,所以这个示例一个接一个地处理I/O请求。特意保持这种方式以帮助理解。
然而,在现实场景中,您希望有更大的队列深度来并行化I/O请求处理,以便获得io_uring
异步处理请求所带来的性能优势。
/* Macros for barriers needed by io_uring */
int ring_fd;
unsigned *sring_tail, *sring_mask, *sring_array,
*cring_head, *cring_tail, *cring_mask;
struct io_uring_sqe *sqes;
struct io_uring_cqe *cqes;
char buff;
off_t offset;
/*
* System call wrappers provided since glibc does not yet
* provide wrappers for io_uring system calls.
* */
int
int
int
/*
* Read from completion queue.
* In this function, we read completion events from the completion queue.
* We dequeue the CQE, update and head and return the result of the operation.
* */
int
/*
* Submit a read or a write request to the submission queue.
* */
int
int
See Also
io_uring_enter(2)
io_uring_register(2)
io_uring_setup(2)