Linux io_uring

文章目录

  • Linux io_uring
    • 1. Linux IO 系统调用简介
    • 2. io_uring 简介
      • 2.1 io_uring 数据结构
      • 2.2 通信管道
    • 3. io_uring 接口
      • 3.1 io_uring_setup
      • 3.2 SQE的有序
      • 3.3 链接SQE(Linked SQES)
      • 3.2 超时指令(Timeout Commands)
    • 4. 内存排序
    • 5. io_uring 支持的其他特性
      • 5.1 固定文件与缓冲
      • 5.2 轮询IO(polled IO)
      • 5.3 内核侧轮询
    • 6. 性能
    • 参考:

Linux io_uring

1. Linux IO 系统调用简介

在Linux系统中,可以进行IO操作的系统调用有readwrite,并在此基础之上提供了功能更强的preadpwrite,可以从指定的偏移位置开始读写。此外还有preadvpwritev支持向量化的读写操作,以及进一步的preadv2pwritev2允许设置修改标志。这些系统调用虽然在一般IO功能上进行了增强,但是它们都是同步的。即系统调用在数据就绪的时候返回。在某些情况下,这种方式使得程序无法达到最佳的性能。虽然POSIX中有aio_readaio_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;
	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事件的顺序是不确定的,即提交队列中的事件顺序与完成队列中的事件顺序并不同。

3. io_uring 接口

下面是使用io_uring 需要使用到的一些系统调用。

3.1 io_uring_setup

函数定义: 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 个事件可以获取。

3.2 SQE的有序

通常,提交队列sqe是独立的,彼此的执行不相互影响。这样,它们可以并行处理以达到最大化CPU利用率的目的。但是在某些情况下,IO请求的顺序是必要的,比如当进行连续的写操作的时候。一般针对这种情况,应用程序就采用了 写-等待 的同步方式在保证顺序。io_uring 则将这些同步的操作放到队列中,并确保操作不会在前驱操作完成之前开始执行。该操作需要设置sqe结构体中的flags字段中的IOSQE_IO_DRAIN

3.3 链接SQE(Linked SQES)

尽管在3.2 部分,通过设置IOSQE_IO_DRAIN可以保证顺序性上的要求。io_uring 支持更加细粒度上的序列控制。Linked sqes 提供了一种描述sqes序列之间依赖关系的机制。每一个sqe的执行都依赖于前驱sqe是否已经被正确执行。为了使用该机制,需要设置flags中的IOSQE_IO_LINK标志位。这样,每一个sqe都只会在前面一个sqe成功完成之后才会开始。如果前驱sqe没有被成功运行,则linked sqe会返回-ECANCELED错误码。

3.2 超时指令(Timeout Commands)

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指令就会结束。

4. 内存排序

正确且高效的使用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即可。

5. io_uring 支持的其他特性

io_uring 库支持直接的IO、带缓冲的IO、socket IO等不同种类的IO操作。此外,io_uring 还支持了其他一些特性。

5.1 固定文件与缓冲

每次文件描述符被放到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创建固定的缓冲区,其他参数与上面类似。

5.2 轮询IO(polled IO)

对于那些追求低延迟的应用,io_uring 轮询IO支持。轮询IO是指不依赖于硬件的中断信号来表示当前的IO请求已经完成。使用轮询IO,应用会不停地从硬件获取当前提交的IO操作的状态。这种方式相比于依赖于硬件中断的方式,在高IO per seconds 的场景下会有更高的性能。

使用轮询IO的工作方式,需要设置io_uring_setup 中的标志位 IORING_SETUP_IOPOLL。当使用轮询的方式时,应用程序无法再使用检查完成队列 CQ 的尾部来检验完成情况,因为此时硬件不再是自动的同步触发。因此,应用程序需要使用io_uring_enter 并设置标志位 IORING_ENTER_GETEVENTS

5.3 内核侧轮询

尽管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);

6. 性能

首先,如果要使用到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

你可能感兴趣的:(操作系统,内核)