在Linux系统中,可以进行IO操作的系统调用有read
和write
,并在此基础之上提供了功能更强的pread
与pwrite
,可以从指定的偏移位置开始读写。此外还有preadv
与pwritev
支持向量化的读写操作,以及进一步的preadv2
与pwritev2
允许设置修改标志。这些系统调用虽然在一般IO功能上进行了增强,但是它们都是同步的。即系统调用在数据就绪的时候返回。在某些情况下,这种方式使得程序无法达到最佳的性能。虽然POSIX中有aio_read
与aio_write
异步IO系统调用接口,但是性能一般[1]。
Linux 的异步IO接口主要有一下几点局限:
O_DIRECT
的方式(非buffer),而如果要使用带缓存的方式,则接口的工作方式与同步的相同。这使得部分场景下该异步IO接口无法发挥作用;在前面提到Linux的aio接口会在IO过程中涉及到比较多的复制操作。为了提高IO性能,需要避免复制操作,而这需要内核与应用共享IO过程中的数据结构,以及两者的同步管理。如果采用应用与内核共享锁的方式,则应用部分需要系统调用,而这额外的系统调用会影响到IO的性能。因此,可以采用单生产者单消费者的环形缓冲区(ring buffer)的方式。采用这种方式,整个的异步IO的操作包含两个部分,分别是IO请求的提交以及对应的处理结束事件。对于IO操作请求提交步骤,应用程序时生产者而内核是消费者,而对于IO操作的结束事件则与之相反。因此,需要一对环形缓冲区,分别是提交队列(submission queue, SQ)与 完成队列(completion queue,CQ)。
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字节,在内存中对齐。
尽管提交队列与完成队列是对称的,但是它们索引的方法并不相同。
完成队列(以下简称cqe) 在实现中是一个数组,内核与用户的应用程序都可以对其进行修改。由于该数组是内核创建的,实际上只有内核对cqe进行实际上的修改操作。当有一个新完成的IO事件,则会被内核提交到cqe,更新队列的尾部。当用户应用程序从队列中取出后,更新队列的头部。当队列的头部与尾部不同的时候,用户程序可知当前有一个或者多个事件可以取出处理。队列的计数器使用32位的整数,并且环形缓冲区的长度为2的指数。
为了找到事件的索引,需要使用到mask,操作过程如下所示:
unsigned head;
head = cqring→head;
read_barrier();
if (head != cqring→tail) {
struct io_uring_cqe *cqe;
unsigned index;
index = head & (cqring→mask);
cqe = &cqring→cqes[index];
/* process completed cqe here */
...
/* we've now consumed this entry */
head++;
}
cqring→head = head;
write_barrier();
其中ring->cques[]
中保存的就是前面提到的io_uring_cqu
结构体。
对于用户程序IO请求提交端则与刚才上面的完成队列的操作相反。与前面cqes 直接对共享数组索引,提交端采取一种间接的方式,在提交侧的环形缓冲区之前还有一个数组索引。这是由于一些应用程序会将IO请求嵌套在数据结构中,这样能够在一个操作中提交多个IO请求。操作提交队列的方法如下所示:
struct io_uring_sqe *sqe;
unsigned tail, index;
tail = sqring→tail;
index = tail & (*sqring→ring_mask);
sqe = &sqring→sqes[index];
/* this call fills in the sqe entries for this IO */
init_io(sqe);
/* fill the sqe index into the SQ ring array */
sqring→array[index] = index;
tail++;
write_barrier();
sqring→tail = tail;
write_barrier();
当提交队列中的一项被内核取出处理之后,用户应用程序就可以使用该位置。如果在某一个数组项被处理了之后,内核还需要访问该位置,则需要进行拷贝操作。提交队列的长度决定了提交的IO请求的数量,因此用户程序需要注意提交IO请求的数量不要超过队列的长度。默认情况下,完成队列的长度是提交队列长度的两倍。完成的IO事件的顺序是不确定的,即提交队列中的事件顺序与完成队列中的事件顺序并不同。
下面是使用io_uring 需要使用到的一些系统调用。
函数定义: int io_uring_setup(unsigned entries, struct io_uring_params *params);
其中参数entries
设定提交队列的大小,params
是一个指向结构体的指针,结构体的定义如下:
struct io_uring_params {
__u32 sq_entries;
__u32 cq_entries;
__u32 flags;
__u32 sq_thread_cpu;
__u32 sq_thread_idle;
__u32 resv[5];
struct io_sqring_offsets sq_off;
struct io_cqring_offsets cq_off;
};
结构体中 sq_entries
由内核设置,应用程序通过该字段得到环形缓冲区最大支持的提交事件个数。cq_entries
与之类似,表示环形缓冲区最大支持的结束事件的个数。
当成功调用io_uring_setup
之后,内核会返回io_uring 实例的文件描述符。内核与用户的应用程序共享提交与完成IO事件的结构体。用户程序是通过mmap
内存映射的方式方位共享的结构体,使用上面参数结构体中的sq_offset
来计算得到环形缓冲区中成员的偏移量,该结构体的定义如下所示:
struct io_sqring_offsets {
__u32 head; /* offset of ring head */
__u32 tail; /* offset of ring tail */
__u32 ring_mask; /* ring mask value */
__u32 ring_entries; /* entries in ring */
__u32 flags; /* ring flags */
__u32 dropped; /* number of sqes not submitted */
__u32 array; /* sqe index array /
__u32 resv1;
__u64 resv2;
};
用户程序使用mmap
内存映射、io_uring的文件描述符以及偏移量来访问环形缓冲区。io_uring定义了如下所示的偏移量:
#define IORING_OFF_SQ_RING 0ULL
#define IORING_OFF_CQ_RING 0x8000000ULL
#define IORING_OFF_SQES 0x10000000ULL
其中,IORING_OFF_SQ_RING
用于对提交队列映射到用户的内存空间;IORING_OFF_CQ_RING
是对完成队列。IORING_OFF_SQES
映射到sqe数组中。其中SQ提交环形缓冲中保存的是sqe数组的索引,因此需要单独的映射。用户程序可能需要自己定义以下数据结构保存以下偏移值,结构体的定义如下所示:
struct app_sq_ring {
unsigned *head;
unsigned *tail;
unsigned *ring_mask;
unsigned *ring_entries;
unsigned *flags;
unsigned *dropped;
unsigned *array;
};
io_uring 提交环形缓冲区的初始化过程如下所示,包括使用mmap
进行内存映射以及计算偏移量。
struct app_sq_ring app_setup_sq_ring(int ring_fd, struct io_uring_params *p)
{
struct app_sq_ring sqring;
void *ptr;
ptr = mmap(NULL, p→sq_off.array + p→sq_entries * sizeof(__u32),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ring_fd, IORING_OFF_SQ_RING);
sring→head = ptr + p→sq_off.head;
sring→tail = ptr + p→sq_off.tail;
sring→ring_mask = ptr + p→sq_off.ring_mask;
sring→ring_entries = ptr + p→sq_off.ring_entries;
sring→flags = ptr + p→sq_off.flags;
sring→dropped = ptr + p→sq_off.dropped;
sring→array = ptr + p→sq_off.array;
return sring;
}
完成缓冲区的初始化过程与上面的操作类似。
用户的应用程序在提交一个IO请求之后需要一种方式通知内核当前有待处理的IO请求。这通过如下的系统调用实现:
int io_uring_enter(unsigned int fd, unsigned int to_submit,
unsigned int min_complete, unsigned int flags,
sigset_t sig);
其中fd
是环形缓冲区的文件描述符,to_submit
通知内核当前最多有多少个sqe是提交等待处理的。min_complete
通知内核需要等待多少个请求的完成。flags
包含修改调用行为的标志,其中比较重要的标志如下所示:
#define IORING_ENTER_GETEVENTS (1U << 0)
如果设置该标志位,则内核会等待min_complete
个事件可以获取。通常,提交队列sqe是独立的,彼此的执行不相互影响。这样,它们可以并行处理以达到最大化CPU利用率的目的。但是在某些情况下,IO请求的顺序是必要的,比如当进行连续的写操作的时候。一般针对这种情况,应用程序就采用了 写-等待 的同步方式在保证顺序。io_uring 则将这些同步的操作放到队列中,并确保操作不会在前驱操作完成之前开始执行。该操作需要设置sqe结构体中的flags
字段中的IOSQE_IO_DRAIN
。
尽管在3.2 部分,通过设置IOSQE_IO_DRAIN
可以保证顺序性上的要求。io_uring 支持更加细粒度上的序列控制。Linked sqes 提供了一种描述sqes序列之间依赖关系的机制。每一个sqe的执行都依赖于前驱sqe是否已经被正确执行。为了使用该机制,需要设置flags
中的IOSQE_IO_LINK
标志位。这样,每一个sqe都只会在前面一个sqe成功完成之后才会开始。如果前驱sqe没有被成功运行,则linked sqe会返回-ECANCELED
错误码。
io_uring 中IORING_OP_TIMEOUT
帮助 完成环形缓冲区(completion ring) 实现等待。该指令支持两种触发器类型,并且这两种触发器可能出现在同一条指令中。一种触发器类型是标准的超时函数 timeout,通过传入一个 timespec
结构体变量指定等待的时间。为了保证应用能够兼容32位以及64位的系统内核,timespec
的定义如下:
struct __kernel_timespec {
int64_t tv_sec;
long long tv_nsec;
};
第二种触发器类型是对完成的事件的一个计数。如果使用这种触发器,对完成事件的计数需要存放到sqe的offset
字段中。当指定个数的事件完成之后,该timeout指令就会结束。
正确且高效的使用io_uring 的一个关键是正确的使用内存排序原语。liburing 库提供了简化的 io_uring API。如下是两个内存排序的操作的定义:
read_barrier(); //Ensure previous writes are visible before doing subsequent memory reads.
write_barrier(); // Order this write after previous writes.
这两个操作用于保证读写的有序。以write_barrier
为例,当应用程序将任务填充到sqe中并通知内核取任务去执行。这总共包含两个步骤,首先将IO任务填充到sqe中,然后将sqe的索引放到SQ环形缓冲区中,之后SQ的尾部位置被更新,通知内核新的IO请求可以被处理。
在如下的例子中,并不能保证第七步是序列写操作中的随后一步。
1: sqe→opcode = IORING_OP_READV;
2: sqe→fd = fd;
3: sqe→off = 0;
4: sqe→addr = &iovec;
5: sqe→len = 1;
6: sqe→user_data = some_value;
7: sqring→tail = sqring→tail + 1;
因此,需要使用write_barrier
来保证顺序性,使用方式如下所示:
1: sqe→opcode = IORING_OP_READV;
2: sqe→fd = fd;
3: sqe→off = 0;
4: sqe→addr = &iovec;
5: sqe→len = 1;
6: sqe→user_data = some_value;
write_barrier(); /* ensure previous writes are seen before tail write */
7: sqring→tail = sqring→tail + 1;
write_barrier(); /* ensure tail write is seen */
对于完成环形缓冲区CQ,事件的生产者与消费者分别是内核与用户程序,在使用read_barrier
来保证读取顺序的时候,类似于writer_barrier
在读取尾部的时候使用read_barrier
即可。
io_uring 库支持直接的IO、带缓冲的IO、socket IO等不同种类的IO操作。此外,io_uring 还支持了其他一些特性。
每次文件描述符被放到sqe并提交给内核处理,内核需要获取文件的引用,在IO结束之后,文件的引用被丢弃。由于文件引用的原子性,这种方式会降低单位时间IO的次数。为了解决这个问题,io_uring 提供了一种方式将一个文件集合预先注册给一个io_uring 实例,该操作使用如下的系统调用实现:
int io_uring_register(unsigned int fd, unsigned int opcode, void *arg,
unsigned int nr_args);
参数中的fd
是io_uring 实例的文件描述符,opcode
是注册的类型。注册一个文件集合需要使用IORING_REGISTER_FILES
,然后args
指向保存文件描述符的数组,nr_args
保存该数组的长度。当使用上面这个函数成功注册文件集合之后,应用程序通过将注册的文件描述符填充到sqe中,并设置对应sqe中的IOSQE_FIXED_FILE
标志位。注册为文件集合在io_uring 实例销毁后自动释放,或者也可以通过设置io_uring_register
中的IORING_UNREGISTER_FILES
来手动销毁。
对于使用O_DIRECT
的IO方式,在进行IO之前,内核需要首先将应用的页面映射到内核,在IO完成之后需要取消映射。因此如果能够重复使用IO 缓冲区,这样能够避免每次IO都映射-取消映射带来的开销。通过使用上面的这个函数,并设置opcode
IORING_REGISTER_BUFFERS
创建固定的缓冲区,其他参数与上面类似。
对于那些追求低延迟的应用,io_uring 轮询IO支持。轮询IO是指不依赖于硬件的中断信号来表示当前的IO请求已经完成。使用轮询IO,应用会不停地从硬件获取当前提交的IO操作的状态。这种方式相比于依赖于硬件中断的方式,在高IO per seconds 的场景下会有更高的性能。
使用轮询IO的工作方式,需要设置io_uring_setup
中的标志位 IORING_SETUP_IOPOLL
。当使用轮询的方式时,应用程序无法再使用检查完成队列 CQ 的尾部来检验完成情况,因为此时硬件不再是自动的同步触发。因此,应用程序需要使用io_uring_enter
并设置标志位 IORING_ENTER_GETEVENTS
。
尽管io_uring 通过尽量减少IO请求启动与完成过程中涉及到的系统调用的个数来提高性能,在某些情况下,系统调用的个数还可以进一步减少,其中之一便是内核侧的轮询。使用这个特性,应用不再需要调用io_uring_enter
提交IO,当应用程序更新提交队列缓冲区 SQ ring,内核侧会自动发现新的IO请求并提交执行,这个操作是在内核专门为io_uring 创建的线程上执行的。使用该特性,在创建io_uring 实例的时候需要设置flags
标志位中的IORING_SETUP_SQPOLL
。为了避免在io_uring 实例不活动的时候浪费CPU资源,内核侧的线程会在一段时间持续空闲后自动sleep,此时该线程会设置提交队列缓冲区中的IORING_SQ_NEED_WAKEUP
标志。当该标志位被设置之后,应用程序不能自动发现新的提交项,需要首先调用io_uring_enter
并设置IORING_ENTER_SQ_WAKEUP
唤醒内核线程。用户程序该部分的代码如下所示:
/* fills in new sqe entries */
add_more_io();
/*
* need to call io_uring_enter() to make the kernel notice the new IO
* if polled and the thread is now sleeping.
*/
if ((*sqring→flags) & IORING_SQ_NEED_WAKEUP)
io_uring_enter(ring_fd, to_submit, to_wait, IORING_ENTER_SQ_WAKEUP);
首先,如果要使用到io_uring 这个特性,需要首先将Linux 内核至少升级到 v5.1。通过使用liburing这个库可以有比较方便的接口调用。
下面是 PATCHSET v5 中对io_uring 于系统本身的一步aio以及一个第三方的库 spdk做的IO性能上的对比。
Latency tests, 3d xpoint, 4k random read
Interface QD Polled Latency IOPS
--------------------------------------------------------------------------
io_uring 1 0 9.5usec 77K
io_uring 2 0 8.2usec 183K
io_uring 4 0 8.4usec 383K
io_uring 8 0 13.3usec 449K
libaio 1 0 9.7usec 74K
libaio 2 0 8.5usec 181K
libaio 4 0 8.5usec 373K
libaio 8 0 15.4usec 402K
io_uring 1 1 6.1usec 139K
io_uring 2 1 6.1usec 272K
io_uring 4 1 6.3usec 519K
io_uring 8 1 11.5usec 592K
spdk 1 1 6.1usec 151K
spdk 2 1 6.2usec 293K
spdk 4 1 6.7usec 536K
spdk 8 1 12.6usec 586K
从上面的对比中可以看出libaio在时延以及每秒钟的IO指标上都要由于系统本身的aio。与spdk的对比中,io_uring 的时延要相对低一些尤其是QD(storage queue depth)=8的时候,当QD=1、2、4的时候io_uring 的IOPS要低于spdk,但是当QD=8的时候,io_uring 的IOPS指标反超spdk。一般在服务器上会增加队列深度来提升IO性能,在NetApp的网站上有关于预估实现特定的每秒 I/O 吞吐量所需的队列深度: 所 需 的 队 列 深 度 = ( 每 秒 I / O 数 ) × ( 响 应 时 间 ) 所需的队列深度 = (每秒 I/O 数) × (响应时间) 所需的队列深度=(每秒I/O数)×(响应时间)。
[1]Efficient IO with io_uring
[2]Patch set V5
[3]liburing
[4]Storage Performance Development Kit, SPDK