文章目录
Linux io_uring
1. Linux IO 系统调用简介
在Linux系统中,可以进行IO操作的系统调用有read
和write
,并在此基础之上提供了功能更强的pread
与pwrite
,可以从指定的偏移位置开始读写。此外还有preadv
与pwritev
支持向量化的读写操作,以及进一步的preadv2
与pwritev2
允许设置修改标志。这些系统调用虽然在一般IO功能上进行了增强,但是它们都是同步的。即系统调用在数据就绪的时候返回。在某些情况下,这种方式使得程序无法达到最佳的性能。虽然POSIX中有aio_read
与aio_write
异步IO系统调用接口,但是性能一般[1]。
Linux 的异步IO接口主要有一下几点局限:
- 异步IO接口仅支持
O_DIRECT
的方式(非buffer),而如果要使用带缓存的方式,则接口的工作方式与同步的相同。这使得部分场景下该异步IO接口无法发挥作用; - 对于一些存储设备,仅有固定个数的请求槽(request slot)。如果某个时刻这些request slot都正在使用,那么IO的提交过程需要阻塞等待,而该阻塞具有不确定性;
- IO操作的过程包括提交请求与等待完成两个步骤。该接口的每个IO的提交需要复制 64 + 8 64+8 64+8个字节,并且在完成时需要复制 32 32 32字节的数据。这样,对于完整的单个IO操作总共需要复制 104 104 104个字节,这种额外的复制操作会使得IO操作变得缓慢
2. io_uring 简介
在前面提到Linux的aio接口会在IO过程中涉及到比较多的复制操作。为了提高IO性能,需要避免复制操作,而这需要内核与应用共享IO过程中的数据结构,以及两者的同步管理。如果采用应用与内核共享锁的方式,则应用部分需要系统调用,而这额外的系统调用会影响到IO的性能。因此,可以采用单生产者单消费者的环形缓冲区(ring buffer)的方式。采用这种方式,整个的异步IO的操作包含两个部分,分别是IO请求的提交以及对应的处理结束事件。对于IO操作请求提交步骤,应用程序时生产者而内核是消费者,而对于IO操作的结束事件则与之相反。因此,需要一对环形缓冲区,分别是提交队列(submission queue, SQ)与 完成队列(completion queue,CQ)。
2.1 io_uring 数据结构
io_uring 中,完成队列中的数据结构如下所示:
struct io_uring_cqe {
__u64 user_data;
__s32 res;
__u32 flags;
};
该数据结构中包含user_data
,包含应用对IO请求的标识信息,一种常用的做法是采用指针的方式指向原始的IO请求,内核不会对该字段进行修改。res
保存了IO请求的结果。flags
保存与本次IO操作相关的元数据(metda data),目前,该字段没有使用。
相比于上面完成队列中的数据结构,IO请求的数据结构更为复杂。不仅包含了必要的字段,同时还考虑到对以后请求类型的可扩展性。
struct io_uring_sqe {
__u8 opcode;
__u8 flags;
__u16 ioprio;
__s32 fd;
__u64 off;
__u64 addr;
__u32 len;
union {
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
__u16 poll_events;
__u32 sync_range_flags;
__u32 msg_flags;
};
__u64 user_data;
union {
__u16 buf_index;
__u64 __pad2[3];
};
};
刻画IO完成事件的数据结构中,opcode
字段保存IO请求的操作类型,比如IORING_OP_READV
表示向量化的读取操作。flags
字段保存修改标志。ioprio
保存该IO的优先级。fd
是IO操作目标的文件描述符。off
字段保存本次IO操作开始的位置偏移量。addr
保存了opcode
指定的操作的起始地址。比如,当IO操作是向量化的时候,addr
是一个指向iovec array
的指针;而对于非向量化的IO操作,addr
必须包含地址。对于非向量化的操作,len
字段包含IO的字节个数;对于向量化的操作,则保存vectors的个数。接下来是由标志位组成的union。结构体最后的union结构用于padding到64字节,在内存中对齐。
2.2 通信管道
尽管提交队列与完成队列是对称的,但是它们索引的方法并不相同。
完成队列(以下简称cqe) 在实现中是一个数组,内核与用户的应用程序都可以对其进行修改。由于该数组是内核创建的,实际上只有内核对cqe进行实际上的修改操作。当有一个新完成的IO事件,则会被内核提交到cqe,更新队列的尾部。当用户应用程序从队列中取出后,更新队列的头部。当队列的头部与尾部不同的时候,用户程序可知当前有一个或者多个事件可以取出处理。队列的计数器使用32位的整数,并且环形缓冲区的长度为2的指数。
为了找到事件的索引,需要使用到mask,操作过程如下所示:
unsigned head;
head = cqring→head;
read_barrier();
if (head != cqring→tail) {
struct io_uring_cqe *cqe