Core Counts Grow, Clock Speeds Stay Constant. Meanwhile, I/O Continues to Increase in Speed.
io_uring
io_uring
是linux 5.1引入的异步io接口,适合io密集型应用。其初衷是为了解决linux下异步io接口不完善且性能差的现状,用以替代linux aio接口(io_setup
,io_submit
,io_getevents
)。现在io_uring
已经支持socket和文件的读写,未来会支持更多场景。
当前linux aio接口,有很多限制:
- 只支持
O_DIRECT
模式的异步io。开启O_DIRECT
后,会绕过kernel's cache,并需要进行字节对齐。需要在用户程序自行管理缓存,以保证性能。一般仅用于数据库这一细分领域。 - 不稳定的非阻塞机制。虽然接口是非阻塞,但仍有很多case会导致程序在提交io任务时阻塞,且这些场景很难预料。
- 接口设计存在局限。每次io提交都需要至少两次系统调用(submit + wait),这不可避免会造成内存拷贝、系统中断、上下文切换。
社区在优化linux aio未果后,开始考虑做一套新的接口,并将一些设计原则纳入其中:
- 易用。接口容易理解和使用。
- 泛用。不仅局限在io的场景,网络等其他场景也可以复用这套新接口。
- 足够强大。功能足够丰富,上层使用者不必在应用层实现一些逻辑来弥补接口的功能缺失。
- 高性能。足够快,并节约资源(CPU)。避免不必要的拷贝、切换开销。
- 灵活。提供灵活的扩展性,上层使用者可以通过接口控制接口底层的行为。
1 总览
与dpdk、spdk不同,io_uring
作为内核的一部分,它并没有让程序绕过内核。它通过mmap的方式让用户程序和内核共享一块内存,并基于memory barrier在这块内存上实现了两个无锁环形队列: submission queue (sq) 和 completion queue (cq). sq用于用户程序向内核提交IO任务,内核执行完成的任务会放入cq,用户程序从cq获取结果。在提交任务和返回任务结果时,用户程序和内核共用环形队列中的数据,不再需要额外的数据拷贝;除此之外,io_uring
还提供了两种轮询模式,可以避免提交任务时的系统调用,以及io完成后的中断通知。
libaio与io_uring
的性能对比。
早期一份spdk与io_uring
的性能对比
一些概念:
- sqring - submission queue ring: 用户程序向内核提交任务的无锁环形队列
- cqring - completion queue ring: 内核向用户程序传递结果的无锁环形队列
- sqe - submission queue event : sqring中的一项,用来描述一个任务
- cqe - completion queue event : cqring中的一项,用来描述一个任务的结果
- sqes - submission queue events : sqe数组
- cqes - completion queue events : cqe数组
sqring和cqring本身没有提供锁等同步机制,从cqring中取出cqe,向sqring中放入sqe,都需要通过memory barrier来实现。
-
read_barrier()
: 保证之前的写入操作对后续的读取操作都可见 -
write_barrier()
: 保证后续的写操作一定排在之前的写操作之后
2 数据结构
2.1 cqe
用于描述一个任务的结果。作为响应,直觉上来讲,需要包含操作的返回码,还包含某种标识,将其与sq中的请求对应起来。
struct io_uring_cqe {
__u64 user_data;
__s32 res;
__u32 flags;
};
-
user_data
拷贝自io_uring_sqe.user_data
,linux内核不会修改这个字段。用户可以填入任何想填的内容,一般用来标识这个结果属于哪个请求 -
res
包含本次操作的返回码。比如,对于read/write类型的请求,成功时,该字段包含本次读写的字节数,失败时则为具体的错误码(比如-EIO) -
flags
用于返回有关本次操作的一些元数据,目前尚未使
2.2 sqe
用于描述一个任务的请求信息。考虑到io_uring
的泛用性,相比cqe,sqe会复杂些。
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];
};
};
-
opcode
描述本次操作的类型(比如IORING_OP_READV
,IORING_OP_WRITEV
) -
flags
用于传递一些控制接口行为的参数,以IOSQE_
开头(比如IOSQE_IO_LINK
) -
ioprio
指定本次请求的优先级 -
fd
要执行IO的文件描述符 -
off
执行IO操作的位置,fd文件内的偏移量 -
addr
指向iovec数组 或者 buffer的指针 -
len
iovecs的iovcnt 或者 buffer的size -
*_flags
各种操作的flags -
user_data
传递到io_uring_cqe.user_data
的数据 -
buf_index
在 fixed buffers 数组中的索引,详见io_uring_register
-
__pad2
64bytes对齐
2.3 cqring
cqring比较简单,cqe直接放在cqring->cqes
中,cqes这个数组即为环形队列的数组,cqring->head != cqring->tail
即代表队列非空。当一个任务被完成时,kernel将cqe放在cqring的队尾,然后让cqring->tail++
。应用程序则从cqring->head
处取出事件,进行消费,消费完成后,更新cqring->head++
。cqring->head
和 cqring->tail
是32bit的无符号整数,它们的值可以在[0,UINT32_MAX]
范围内流动,再配合一个cqring->ring_mask
来得到具体的index(cqring->head & cqring->ring_mask
即为真实的index)。这也意味着数组的长度必须是2的n次方。
仅用于示意的结构定义:
struct io_uring_cqring {
struct io_uring_cqe *cqes;
__u32 *head;
__u32 *tail;
__u32 *ring_mask; // ring_entries - 1
__u32 *ring_entries;
};
从cqring消费一个cqe的流程大概是这样:
auto head = cqring->head;
read_barrier();
if (head != cqring->tail) {
struct io_uring_cqe *cqe;
auto index = head & (cqring->ring_mask);
cqe = cqring->cqes[index];
// process cqe
head++;
}
cqring->head = head;
write_barrier();
2.4 sqring
相比cqring直接使用cqes作为环形数组,sqring 引入了一个新的数组 sqring->array
。sqe存放在sqring->sqes
中,sqring->array
中存储sqe在sqring->sqes
中的index。sqring->array
是环形队列的底层数组,sqring->sqes
则用于存放数据。默认情况下,sqring->array
和sqring->array
的长度是一样的,一个sqe对应在两者中的index也是一样的,换句话说,相当于只有一个数组,就像不存在两个数组。那你可能想问,这样实现的意义是什么?
考虑这样一个场景,应用程序维护了自己的sqe数组,当需要提交io时,才将该数组其中部分的sqe提交到内核执行。如果sqring没有双数组的概念,这时,只能将应用程序的sqe数组中要提交任务的sqe逐一拷贝到sqring->sqes
中,多了内存拷贝的开销。有了间接数组,应用程序可以直接将sqring->sqes
指向用户维护的数组,这样提交任务时,只需要把sqes数组的index传递给sqring->array
,即可完成提交,无需任何拷贝操作。
cqring返回cqe的顺序与任务放入sqring的顺序没有任何关系,两个队列是独立运行的。但cqe和sqe一定是一一对应的。
仅用于示意的结构定义:
struct io_uring_sqring {
struct io_uring_sqe *sqes;
__u32 *array;
__u32 *head;
__u32 *tail;
__u32 *ring_mask; // ring_entries - 1
__u32 *ring_entries;
}
向sqring放入一个sqe的流程大概是这样(默认情形):
struct io_uring_sqe *sqe;
auto tail = sqring->tail;
auto array_index = tail & (sqring->ring_mask);
auto sqes_index = array_index;
sqe = &sqring->sqes[seqs_index];
// fill sqe
init(sqe);
sqring->array[array_index] = sqes_index;
tail++;
write_barrier();
sqring->tail = tail;
write_barrier();
3 接口
3.1 io_uring_setup
初始化一个io_uring
实例。
int io_uring_setup(unsigned int entries, struct io_uring_params *params);
-
entries
期望的sqring->sqes
长度,必须为2的幂。默认情况,cqring->cqes
的长度为sqring->sqes
的两倍 -
params
kernel会从这读取一部分参数,并填充一部分参数进来
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
由内核写入,sqring->sqes
的实际最大长度 -
cq_entries
由内核写入,cqring->cqes
的实际最大长度 -
flags
由用户程序写入,控制io_uring
的行为 -
sq_thread_cpu
由用户程序写入,设置sq polling线程所在的cpu,需同时设置IORING_SETUP_SQ_AFF
才生效,需要root权限,否则返回-EPERM
-
sq_thread_idle
由用户程序写入,设置sq polling线程在空闲多久后,陷入sleep,单位为ms,如果不设置,默认为1000ms resv
-
sq_off
由内核写入,包含sqring各成员的内存地址偏移 -
cq_off
由内核写入,包含cqring各成员的内存地址偏移
io_uring_setup
成功返回时,返回一个fd,用于访问这个io_uring
实例。用户程序需要使用返回的fd配合下面的off来调用mmap,来映射对应内存到应用程序的内存空间。
#define IORING_OFF_SQ_RING 0ULL
#define IORING_OFF_CQ_RING 0x8000000ULL
#define IORING_OFF_SQES 0x10000000ULL
-
IORING_OFF_SQ_RING
映射sqring到程序内存空间 -
IORING_OFF_CQ_RING
映射cqring到程序内存空间 -
IORING_OFF_SQES
映射sqes数组到程序内存空间
通过mmap映射完成后,再使用params中内核填充的偏移量sq_off
和cq_off
来访问具体的属性。
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;
};
应用程序可以定义自己的结构,将内存偏移转化为可以直接使用的指针,便于后续使用。
struct app_sq_ring {
unsigned int *head;
unsigned int *tail;
unsigned int *ring_mask;
unsigned int *ring_entries;
unsigned int *flags;
unsigned int *dropped;
unsigned int *array;
};
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);
sqring->head = ptr + p->sq_off.head;
sqring->tail = ptr + p->sq_off.tail;
sqring->ring_mask = ptr + p->sq_off.ring_mask;
sqring->ring_entries = ptr + p->sq_off.ring_entries;
sqring->flags = ptr + p->sq_off.flags;
sqring->dropped = ptr + p->sq_off.dropped;
sqring->array = ptr + p->sq_off.array;
return sqring;
}
3.2 io_uring_enter
默认情形下,提交任务的流程是
- 把sqe放入sqring
- 调用
io_uring_enter
通知内核 - 轮询cqring等待结果
或者
通过带IORING_ENTER_GETEVENTS
和min_complete
参数的io_uring_enter
阻塞等待指定数目的任务完成,再去cqring中检查结果
int io_uring_enter(unsigned int fd, unsigned int to_submit,
unsigned int min_complete, unsigned int flags,
sigset_t *sig);
-
fd
io_uring_setup
返回的fd -
to_submit
本次有多少个sqe需要提交到内核 -
min_complete
要求内核至少等待min_complete
个任务完成再返回
需要flags设置IORING_ENTER_GETEVENTS
才会进行等待 -
flags
可以传递一些flags控制接口行为,比如IORING_ENTER_GETEVENTS
3.2.1 一个操作等待一系列操作完成
存在一个操作,要等待前面一系列操作完成后,才能执行(比如多个 write 后接一个 fsync)的场景,可以通过设置IOSQE_IO_DRAIN
到sqe->flags
来实现;设置了该flag的sqe,会等待前面所有的sqe都完成后,才会执行。同时,在该sqe之后提交的sqe,也会等待这个sqe执行完之后,才会执行。这会对性能产生比较大的影响,可以通过分配一个新的io_uring
实例来执行这类同步操作。
3.2.2 一系列有序的操作
当有一系列操作,需要有序执行时,可以通过设置IOSQE_IO_LINK
到sqe->flags
来实现;设置了该flag的sqe会和下一个sqe形成一个链接,下一个sqe会在本sqe执行完之后才会执行。连续提交带IOSQE_IO_LINK
的sqe可以形成一个链表,链表的尾节点是第一个不带IOSQE_IO_LINK
flag的sqe。不同的链表间以及链表与普通单sqe间没有限定,可以并发被执行,只有链表内部的节点会按顺序执行。如果链表中某个操作执行失败,这个操作后的其他sqe都会被取消执行,返回 ECANCELED 错误码。
3.2.3 定时返回
可以通过设置IORING_OP_TIMEOUT
到io_uring_enter
的flags参数,来使用定时功能。启用后,可以通过sqe->addr
和sqe->offset
设置两个触发条件,任一条件满足后,就会返回一个cqe到用户程序。
可将sqe->addr
设为一个struct timespec64
的地址,来设置定时触发,当超时后,函数会返回。
可将sqe->offset
设为期望完成的任务数,当有指定个任务完成后,函数会返回。
3.3 io_uring_register
一般情况,只用setup和enter两个系统调用就能完成任务。io_uring_register
用于一些特定场景的性能优化。
int io_uring_register(unsigned int fd, unsigned int opcode,
void *arg, unsigned int nr_args);
-
fd
io_uring_setup
返回的fd -
opcode
registration的类型,对于文件,用IORING_REGISTER_FILES
-
arg
对于文件,指向一个fd数组,其中的fd为这个进程已经打开的文件 -
nr_args
arg数组的长度
3.3.1 预注册文件
每次通过sqe向内核传递一个fd,内核都需要通过fd去进程描述符中找到对应的文件引用,完成该sqe处理后,则将该引用释放。对于高iops的场景,这个开销会拖慢请求的速度。通过register可以预先注册一组已经打开的文件。一旦注册成功,在通过sqe传递fd时,就可以在fd字段填入该文件在fd数组中的index,并设置IOSQE_FIXED_FILE
到sqe->flags
。程序可以混合使用已注册的文件和未注册的文件。已注册的文件可以通过再次调用register接口,并传入IORING_UNREGISTER_FILES
来取消注册,也可以等待io_uring
实例销毁时,被自动释放。
3.3.2 预注册buffer
除了文件,也可以映射一系列fixed IO buffer。在设置了O_DIRECT
的读写场景,内核需要在读写前进行page map,读写完成后,执行unmap。类似的,通过预先注册,来避免多次的map和unmap。在这个场景,opcode
需要传入IORING_REGISTER_BUFFERS
,arg
为struct iovec
的数组,nr_args
为数组长度。一旦buffer注册成功,程序可以使用IORING_OP_READ_FIXED
和IORING_OP_WRITE_FIXED
来进行读写。提交任务时,sqe->addr
需要指向某个buffer内的地址,sqe->len
包含本次读写的长度(bytes)。
3.4 高级特性
3.4.1 io polling
这里先回顾,默认情形下,提交任务的流程,以及获取结果的方式:
- 把sqe放入sqring
- 调用
io_uring_enter
通知内核 - 可以轮询cqring等待结果
或者
通过带IORING_ENTER_GETEVENTS
和min_complete
参数的io_uring_enter
阻塞等待指定数目的任务完成,再去cqring中检查结果
默认情形下,底层设备通过中断通知内核执行io_uring
对应的处理函数,构造结果并封装成cqe丢到cqring中,用户程序可以通过轮询cqring获取结果。当开启了io polling之后,不再有中断触发回调这一过程,不可以通过轮询cqring获取结果,而是需要应用程序主动调用带IORING_ENTER_GETEVENTS
和min_complete
参数的io_uring_enter
来下发轮询任务给内核,内核会轮询检查是否有结果产生,如果有,则将结果放入cqring。当有min_complete
个结果返回后,函数会返回。这期间,用户程序会阻塞在io_uring_enter
的调用上。这还有一个特殊用法,可以把min_complete
设为0,这时,仅会触发内核进行一次结果检查,而不会轮询并阻塞住程序,函数会立刻返回。对于低延迟的存储设备,io polling可以获得很大的性能提升。
在io_uring_setup
的params->flags
传入IORING_SETUP_IOPOLL
可以开启io polling。只有部分opcode支持io polling模式: IORING_OP_READV
,IORING_OP_WRITEV
,IORING_OP_READ_FIXED
,IORING_OP_WRITE_FIXED
.通过io_uring_enter
提交不支持io polling的任务,会返回-EINVAL
。
3.4.2 sq polling
默认情形下,我们每次提交任务,都需要调用一次io_uring_enter
通知内核,这会导致中断和切换开销,通过sq polling可以避免这部分开销。开启sq polling之后,用户程序只需要将sqe放入sqring之后,不再需要调用io_uring_enter
,内核侧会启动一个线程,主动去轮询sqring,并处理提交的sqe。
在io_uring_setup
的params->flags
传入IORING_SETUP_SQPOLL
可以开启sq polling。
同时,为了避免轮询线程占用cpu过多影响其他程序的执行,可以通过io_uring_setup
的params->sq_thread_cpu
和params->sq_thread_idle
设置线程亲和的cpu和空闲超时事件。
-
sq_thread_cpu
由用户程序写入,设置sq polling线程所在的cpu,需同时设置IORING_SETUP_SQ_AFF
才生效,需要root权限,否则返回-EPERM
-
sq_thread_idle
由用户程序写入,设置sq polling线程在空闲多久后,陷入sleep,单位为ms,如果不设置,默认为1000ms
当sq polling线程陷入sleep后,内核会设置IORING_SQ_NEED_WAKEUP
到sqring的flags成员。当用户程序在提交sqe到sqring后,发现该flag被设置,则需要调用一次io_uring_enter
并带上IORING_ENTER_SQ_WAKEUP
flag.
4 使用
如前所说,直接使用io_uring
接口,需要处理很多细节(比如io_uring
实例的初始化和mmap,cqring和sqring的带memory barrier读写等)。作者基于io_uring
封装了一个更上层的库liburing,屏蔽了很多细节。可以直接通过liburing来使用io_uring
。
参考:
- https://unixism.net/loti/low_level.html
- https://kernel.dk/io_uring.pdf
- https://medium.com/oracledevs/an-introduction-to-the-io-uring-asynchronous-i-o-framework-fad002d7dfc1
- https://thenewstack.io/how-io_uring-and-ebpf-will-revolutionize-programming-in-linux
- https://www.snia.org/sites/default/files/SDC/2019/presentations/Storage_Performance/Kariuki_John_Verma_Vishal_Improved_Storage_Performance_Using_the_New_Linux_Kernel_I.O_Interface.pdf
- https://zhuanlan.zhihu.com/p/380726590