原文
虽然内核从2.5就开始支持异步IO(AIO),但是长久以来用户一直对现有实现充斥着抱怨。目前的AIO接口不但难用也非常低效,而且并不是所有类型的IO都得到了完善的支持。现在,随着Jens Axboe提出的名为io_uring的新接口的引入,这种情况也许会得到改善。正如名称所展现的那样,io_uring引入了内核最需要的组件:环形缓冲区。
设置
一个完整的AIO实现需要包含对操作的提交,以及在未来某个时间点对完成数据的收集。io_uring通过两个环形缓冲区分别实现提交队列和完成队列来达到以上目的。应用程序的第一步是通过一个崭新的syscall来设置/建立这些数据结构:
int io_uring_setup(int entries, struct io_uring_params *params)
其中参数entries用来指定提交队列和完成队列的size,而params数据结构的简化定义为:
struct io_uring_params {
__u32 sq_entries;
__u32 cq_entries;
__u32 flags;
__u32 resv[3];
struct io_sqring_offsets sq_off;
struct io_cqring_offsets cq_off;
}
在调用syscall时,这个结构(除了flags字段)应该简单地被初始化为全零。当syscall成功返回时,sq_entries
和cq_entries
字段将被设置为提交队列和完成队列的实际大小。syscall的实现将根据’entries’参数来分配提交条目,而完成条目的数量默认为两倍于提交条目。
io_uring_setup()的返回值是一个文件句柄(fd descriptor)。这个文件句柄可以被传递给mmap(2)来讲缓冲区映射到用户进程地址空间。具体来讲需要三个mmap(2)来分别映射两个环形缓冲区(ring buffers)和一个提交队列条目数组(array of submission-queue)。而io_uring_params
对象中的sq_off
和cq_off
字段则包含了完成这些映射所需要的所有信息。特别地,提交队列(submission queue),实际是一个整数数组实现的索引环,通过如下调用进行映射:
subqueue = mmap(0,
params.sq_off.array + params.sq_entries * sizeof(__u32),
PROT_READ | PROT_WRITE | MAP_SHARED | MAP_POPULATE,
ring_fd, IORING_OFF_SQ_RING);
其中params
是io_uring_params
类型的数据结构,ring_fd
是通过io_uring_setup(2)
得到的文件句柄。而length参数额外增加params.sq_off.array
则说明实际的ring并不位于被映射区域的头部,在ring之前还存在其他一些内容。而实际的submission-queue entries array
则通过如下调用进行映射:
sqentries = mmap (0,
params.sq_entries * sizeof (struct io_uring_sqe),
PROT_READ|PROT_WRITE|MAP_SHARED|MAP_POPULATE,
ring_fd, IORING_OFF_SQES);
将queue entries和ring buffer分离是必要的,因为I/O操作的完成顺序并不一定和提交顺序一致。
完成队列则非常简单,因为entrires和queue是一体的:
cqentries = mmap(0,
params.cq_off.cqes + params.cq_entries*sizeof(struct io_uring_cqe),
PROT_READ|PROT_WRITE|MAP_SHARED|MAP_POPULATE,
ring_fd, IORING_OFF_CQ_RING);
Axboe正在开发一个用户空间库向用户隐藏这些接口的复杂细节。
IO提交
当io_uring的数据结构都准备妥当后就可以用于执行异步IO了。在提交IO请求时需要先填充一个io_uring_sqe
对象,简化的定义如下:
struct io_uring_sqe {
__u8 opcode; /* type of operation for this sqe */
__u8 flags; /* IOSQE_ flags */
__u16 ioprio; /* ioprio for the request */
__s32 fd; /* file descriptor to do IO on */
__u64 off; /* offset into file */
void *addr; /* buffer or iovecs */
__u32 len; /* buffer size or number of iovecs */
union {
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
};
__u64 user_data; /* data to be passed back at completion time */
__u16 buf_index; /* index into fixed buffers, if used */
};
opcode代表要执行的操作,包括IORING_OP_READV
, IORING_OP_WRITEV
,IORING_OP_FSYNC
等。其中有许多参数会影响IO的执行过程,但大部分都比较简单:fd
代表要执行IO的文件,addr
和len
描述一组iovec
结构指向执行IO需要的内存。
如前一节所述,所有的io_uring_sqe
对象都存放在一个array里,并且该array同时被映射进内核空间和用户空间。而实际提交一个io_uring_sqe则需要将其索引存放入所谓提交队列(submission queue
),提交队列的定义和实现如下:
struct io_uring {
u32 head;
u32 tail;
};
struct io_sq_ring {
struct io_uring r;
u32 ring_mask;
u32 ring_entries;
u32 dropped;
u32 flags;
u32 array[];
};
head
和tail
值用于管理ring中的entries,两者值相等时代表ring为空。用户空间代码通过将io_uring_sqe
entry的arry index放入io_sq_ring.arry[r.tail]
来提交entry到io_sq_ring
,并递增tail指针。而r.head
则只能被内核改动。一个或者多个entries被放入io_sq_ring
后,就可以通过如下syscall来进行实际的submit了:
int io_uring_enter(
unsigned int fd, u32 to_submit,
u32 min_complete, u32 flags);
其中的fd
是与ring相关的文件描述符,to_submit
是环中此刻应该提交的条目数量。成功时返回0。
完成事件io_uring_cqe
就在操作完成后被放入完成队列io_cq_ring
。如果flags
中包含IORING_ENTER_GETEVENT
且min_complete
大于0,io_uring_enter(2)
将会阻塞直至min_complete
个操作完成。实际结果被存放在如下的结构中:
struct io_uring_cqe {
__u64 user_data; /* sqe->user_data submission passed back */
__s32 res; /* result code for this event */
__u32 flags;
};
其中user_data
是操作被提交时从用户空间传递到io_uring_sqe
的值,res
是操作的返回值。如果请求可以在不执行真正的IO的情况下完成的话,flags
字段将包含IOCQE_FLAG_CACHEHIT
,这通常代表实际的IO命中了page cache,当然这个选项的使用还有争论。
io_uring_cqe
的实例将存放在所谓完成队列中(completion queue),其结构和提交队列类似:
struct io_cq_ring {
struct io_uring r;
u32 ring_mask;
u32 ring_entries;
u32 overflow;
struct io_uring_cqe cqes[];
};
其中r.head
指向第一个可读完成事件,而r.tail
指向最后一个。用户空间代码应该只改变r.head
。
到目前为止所描述的接口足以使用户空间程序对多个I/O操作进行排队并在操作完成时收割结果。功能与当前AIO接口提供的功能类似,但接口有很大的不同。Axboe声称它的效率要高很多,但目前还没有基准测试结果来支持这一说法。除此之外,在所需数据位于页面缓存中的情况下该接口可以在不切换上下文的情况下进行异步缓冲IO。缓冲IO一直是Linux AIO的一个痛点。
高级特性
有一些高级特性值得专门强调。比如是将用户进程的IO buffer映射到内核的能力。这个映射操作通常在每个IO请求时发生以便于数据被从该buffer写入或读出。当操作完成时该buffer会被unmap。如果这个buffer会在用户进程生命周期中被反复多次使用的话,可以保持这种映射而不进行unmap来有效提高效率。这种映射是通过填充另一个描述要映射的缓冲区的结构来完成的:
struct io_uring_register_buffers {
struct iovec *iovecs;
__u32 nr_iovecs;
};
以该结构的实例作为参数进行如下的syscall:
int io_uring_register(
unsigned int fd, unsigned int opcode, void *arg);
在本例中,操作码应该是IORING_REGISTER_BUFFERS
。只要初始文件描述符保持打开状态,缓冲区就会一直被映射,直到使用IORING_UNREGISTER_BUFFERS
显式地取消映射。以这种方式映射缓冲区本质上是将内存锁定到RAM中,所以通常应用于mlock()
的资源限制在这里也适用。当对预映射缓冲区执行I/O操作时应该使用IORING_OP_READ_FIXED
和IORING_OP_WRITE_FIXED
操作码。
另外还有一个IORING_REGISTER_FILES
操作,用于优化在同一文件上执行许多操作的情况。
最后值得一提额是一种完全轮询模式,(几乎)免去了进行任何系统调用。通过在环setup时设置IORING_SETUP_SQPOLL
标志来启用该模式。调用io_uring_enter()将启动一个内核线程,该线程将偶尔轮询提交队列并自动提交找到的任何请求。如果需要,该线程也会针对接收队列进行轮询。只要应用程序持续不断的提交IO并收割结果,IO的整个过程将不再发生系统调用。
如果一段时间(一秒左右)没有提交新的请求,内核最终会停止轮询。此时,提交队列结构中的flags
字段将设置IORING_SQ_NEED_WAKEUP
位。应用程序应该检查这个位,如果置位了,则可以调用io_uring_enter()
重新启动该机制。
在撰写本文时,这个补丁集已经是第三个版本了。尽管这有点欺骗性,因为在它之前(至少)有10个 polled AIO patch set 。虽然看起来接口越来越稳定,但后续发生一些重大的变化也不足为奇。Matthew Wilcox要求将名字改为“看起来不那么像io_urine”的评论还没有得到回应。这可能成为最大的遗留话题,命名总是最后最困难的部分。但是一旦这些细节都确定下来,内核就会拥有一个没有那么多持续抱怨来源的异步I/O实现了。
对于好奇的人,Axboe提供了一个使用io_uring接口的完整程序示例。