Linux内核提供的IO机制大都是同步实现的,如常规的read/write/send/recv
等系统调用。同步IO机制存在着一定的弊端,例如:(1)IO的实现都是在当前进程上下文的系统调用中完成的,会阻塞当前进程,降低系统的实时性;(2)性能较低。
异步IO指的是用户程序将IO请求提交后,无需等待IO操作的完成,而是可以继续处理别的事情。当IO操作完成后,会以某种方式通知用户程序。Linux系统下现有的异步IO机制的实现主要为两种:
POSIX AIO
。这种方案是用户态实现的异步IO机制,其核心思想为:创建一个专门用来处理IO的线程,用户程序将IO操作交给该线程来进行。这种方式实现的异步IO效率和扩展性都比较差。LINUX AIO
。Linux内核里也实现了一套异步IO机制,被称为AIO
。该机制的使用限制比较大,比如只支持direct IO
而无法使用cache
,且扩展性比较差。在此背景下,Linux社区在5.1版本中引入了一种全新的异步IO机制:io_uring
。io_uring
提供了一套全新的系统调用接口,凭借着其高性能、高扩展性等优势脱颖而出,成为了目前Linux
下主流的异步IO
方案。
相比于现有的异步IO
机制,io_uring
的优势主要体现在以下几方面:
io_uring
采用了共享内存的方式来传递参数,减少了数据拷贝;另一方面,其采用ringBuf
的方式来实现批量的IO
请求,减少了系统调用的次数。io_uring
具有超强的可扩展性,具体表现在:(1)其支持的IO设备类型多样化,不仅支持块设备的IO,还支持任何基于文件的IO,例如套接口、字符设备等;(2)其支持的IO操作多样化,不仅支持常规的read/write
,还支持send/recv/sendmsg/recvmsg/close/sync
等大量的操作,而且能够很灵活地进行扩充。io_uring
提供了配套的liburing
库,其对io_uring
的系统调用进行了大量的封装,使得接口变得简单易用。io_uring
提供了poll
模式,对于IO性能要求较高的场景,允许用户牺牲一定的CPU来获得更高的IO性能:低延迟、高IOPS
。经测试,相比于libaio
,在poll
模式下io_uring
性能提升将近150%,堪比SPDK
。特别是在高QD
的情况下,更是有赶超SPDK
的趋势。
io_uring
整体框架io_uring
的整体框架如下图所示,用户态程序在开发时只需要调用liburing
提供的接口来进行异步IO请求的提交,liburing
会根据用户的请求在共享内存中设置对应的参数。异步IO请求可以同时设置多个,这个过程中不发生系统调用,当用户设置好后再调用liburing
的接口通知内核处理共享内存中的IO请求,从而实现操作的批量进行。
内核的io_uring
模块在处理共享内存中的请求时,会根据请求的类型以及操作的文件所属的文件系统类型来调用不同的IO接口。具体异步程序的编写以及io_uring
的实现原理将在下文详细介绍。
io_uring
原理ringBuf
从名字uring
我们就可以看出来,该机制的核心即user
和ring
:其申请了一块用户态和内核态共享的内存,并在共享内存中通过ringBuf
环形缓存队列的方式来实现内核态和用户态的通信,这里简单说一下该机制的使用和实现过程。
每一个io_uring
实例,都会被分配一个fd
,该过程是通过io_uring_setup()
系统调用实现的。该系统调用会根据用户提供的参数,分配一块共享内存,该内存可以通过mmap()
的方式映射到用户态。这块共享内存中,包含了一个SQ
(提交队列)、一个CQ
(完成队列)和一个SQE
(提交实体)数组。其中,SQ
和CQ
是两个环形队列,队列中的元素是SQE
在SQE
数组中的偏移量,使用这种方式可以使得提交实体能够被随机访问,提高灵活性。用户从CQ
的头部获取SEQ
,将想要执行的操作(如文件的读写)初始化到其中,并添加到SQ
队列的尾部,然后使用io_uring_enter()
系统调用来进行提交队列的处理。
内核会从SQ
中依次取出对应的提交实体,并根据提交实体中定义的动作来执行对应的操作。由于用户只操作SQ
尾部,而内核只操作头部,因此两者对于共享队列的访问并不会产生冲突,节省了锁的开销。上图中为内核的处理流程简图,为了提高性能、降低时延,内核并不是一定会采用异步的方式来处理提交实体,而是会检查该实体所对应的文件系统是否支持非阻塞式的操作。对于块设备的读写,可能并不会支持非阻塞式操作,但是对于其他的一些文件系统,如网络套接字,是支持非阻塞式报文接收的。显而易见,当socket
接收队列中存在报文时,进行异步报文读取无疑是不明智的,不仅会增加开销,还会导致收包的延迟。因此,内核会首先采用非阻塞的方式进行报文的读取,当收报队列中不存在报文时(会返回EAGAIN
错误),才会将提交实体添加到异步队列,等待报文的到来。
在操作完成后,内核会将完成了的提交实体放到CQ
队列的尾部,方便用户继续进行操作的提交。通过ringBuf
的使用,io_uring
获得了以下几点收益:
poll
模式为了进一步提高性能,io_uring
还提供了两种轮询模式,即块设备层的iopoll
轮询模式和提交sqpoll
轮询模式。
iopoll
什么是IO轮询(poll)模式?轮询模式是相对于中断模式的。常规的块设备IO使用的都是中断模式,即进程将IO请求提交给块设备后会进入睡眠(D)状态,块设备在处理完IO请求后会触发硬中断,硬中断中会唤醒进程并通知其IO的完成。io_uring
提供了一种block
层的轮询模式,即IO请求提交后不进入睡眠,而是循环检查硬件设备的完成状态。该模式下,io_uring
会额外启动一个内核进程来循环检查IO的完成。由于不需要等待硬件设备的通知,因此可以更快地获取到IO请求的完成,这对于延迟非常低以及IOPS
很高的设备,能够显著提高性能,同时避免了高频的中断所带来的性能开销。
sqpoll
通过ringBuf
的使用,我们现在可以批量地进行IO操作的提交,降低了系统调用次数。io_uring
还提供了另一种机制用于进一步降低系统调用次数、提高IO效率,即:提交队列轮询SQPOLL
模式。
该模式下,内核会启动一个内核进程专门用于SQE
提交实体的处理,该进程会循环检查提交队列中是否存在实体。用户态程序只需要取出完成队列中的SEQ
,进行初始化并添加到提交队列中即可,整个过程都不需要产生系统调用。为了降低开销,内核进程会有一个超时时间,在该时间段内如果都没有检测到提交队列中存在实体,就会进入睡眠状态,同时将进程的状态更新到共享内存中。用户进程在提交SQE
之后,会通过该标志位检查poll
进程是否在运行。若未运行,则通过io_uring_enter
系统调用唤醒poll
进程。
可以看出,在高IO频率的情况下,使用该模式可以大幅降低系统调用的次数,同时减少由于系统调用而带来的IO延迟。
每次将文件描述符
填充到 sqe
,然后提交给内核时,内核都必须检索对文件描述符
的引用
当 IO 完成后,会再次删除文件引用,由于文件引用的原子性,这样对高 IOPS 的工作场景而言,速度会明显下降。
为了缓解此问题,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
。arg
必须指向应用准备打开的文件描述符数组nr_args
便是数组的大小一旦 io_uring_register
成功将文件集注册后,应用就可以将文件集数组的索引
(而不是使用实际的文件描述符)赋值给 sqe->fd
了,并设置 sqe->flags
字段为 IOSQE_FIXED_FILE
来标记 sqe->fd
是一个文件集索引
应用可以继续使用未注册的文件,即使是注册过的文件也可以通过文件描述符
赋值 sqe->fd
,sqe->flags
不设置 IO_FIXED_FILE
来正常使用文件描述符
当 io_uring 实例
被移除后,注册的文件集会自动释放,或者使用 IORING_UNREGISTER_FILES
opcode 来调用 io_uring_register
不仅仅可以注册文件集,还可以注册一组固定 IO 缓冲区(fixed IO buffers)
使用 O_DIRECT
时,内核在真正执行 IO 前,必须映射应用内存页(pages) 到内核中,并且当 IO 完成后取消对这些页的映射。
这些操作的开销可能是昂贵的。如果应用可以复用 IO 缓冲区,那么总共只需要进行一次映射和取消映射,而不是每次 IO 操作都需要
要注册一组固定缓存区,io_uring_register
必须使用 IORING_REGISTER_BUFFERS
的 opcode
来调用,args
必须包含填充好每个 iovec
的地址和长度字段的 iovec
数组,nr_args
则是 iovec
数组的大小
成功注册固定缓冲区
后,应用可以使用 IORING_OP_READ_FIXED
和 IORING_OP_WRITE_FIXED
在 IO 中利用这些缓冲区。
当使用 固定操作码(fixed op-codes)
时,sqe->addr
必须包含了那些固定缓冲区之一的索引,并且 sqe->len
为请求的字节长度。
应用可能会注册大于 IO 操作的缓冲区,一个固定的读/写只是一个固定缓冲区的子集是完全合法的。
在快速的存储设备上,如3D xpoint
、SATA
等,io_uring
取得了可观的性能提升。以下为Linux-AIO
、io_uring
以及spdk
的性能对比数据,该数据由io_uring
的作者Jens Axboe
提供。其中spdk
是intel
提出的从驱动层面实现的高性能存储设备框架,可以说是高性能存储方案中的标杆。该数据的测试方式为:在3D xpoint
存储设备上进行4k
大小的随机读取操作,衡量的指标为:
Latency
:读取数据的延迟IOPS
:每秒内IO操作的次数,这里为每秒能够读取4k
数据的次数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 |
从数据中可以看出,在非poll
模式下,io_uring
在IO
延迟和IOPS
上都有了些许的提升,提升效果似乎并不大。当开启了iopoll
和sqpoll
的情况下,内核进程会同时对提交队列和块设备驱动做轮询,此时能够达到最佳的IO性能。从数据中我们可以看出,当开启了poll
模式后,io_uring
在延迟和IOPS
上的表现已经远远超越了AIO
,并且已经可以媲美spdk
了,特别是在高QD
(Queue depth
,一次性向设备发送多个IO
请求)的情况下,甚至有赶超的趋势。
阿里云的团队对io_uring
的性能也做了测评,其测评环境为:ecs.i2.2xlarge
,8 vCPU 64 GiB
,I2
本地存储 1788 GiB
。测评结果显示,在进行顺序读写的情况下,io_uring
性能提升明显,可达到160% ~ 170%
;在进行随机读写时,性能提升可达到30% ~ 150%
。测评数据如下所示:
4k
顺序读取:4k
顺序写入:4k
随机读取:4k
随机写入:在速度比较慢的机械硬盘上,io_uring
性能提升不明显。笔者在generic X86
的PC下使用fio
测试组件对io_uring
的性能进行了测试,测试中所使用到的存储器为普通的机械硬盘,测试方式为进行4k
的随机读操作,测试结果如下所示:
Interface | QD | Polled | Latency | IOPS |
---|---|---|---|---|
sync | 1 | 0 | 10.1msec | 131 |
sync | 4 | 0 | 9.8msec | 132 |
sync | 16 | 0 | 10.3msec | 130 |
io_uring | 1 | 1 | 9.7msec | 133 |
io_uring | 4 | 1 | 20.1msec | 201 |
io_uring | 16 | 1 | 55.7msec | 286 |
io_uring | 1 | 0 | 9.9msec | 132 |
io_uring | 4 | 0 | 20.2msec | 205 |
io_uring | 16 | 0 | 55.5msec | 290 |
从结果中可以看出,由于同步IO
方式并不支持多请求队列,因此延迟和IOPS
基本没什么变化。而io_uring
随着QD
的增加,IOPS
得到了显著的提升,在QD
为16的时候更是提升了一倍以上,但是延迟也上升到了50毫秒。同时,是否处于poll
模式对于硬盘IO
基本上没有产生影响。
在对慢速的机械硬盘进行IO的时候,性能的瓶颈是低速的磁盘IO。因此这种情况下,相比于磁盘的IO
速度,系统调用以及中断对性能造成的影响基本上可以忽略不计,所以在QD
为1的时候io_uring
与普通模式的IO
性能没有什么差别。在QD
提高的情况下,硬盘的IOPS
得到了提升,IO
带宽明显上升,但是由于机械硬盘的硬件性能较差,导致在同时处理多个IO
请求时产生延迟明显升高的问题。这里我们可以看出,对于延迟不敏感的场景,使用io_uring
可以更加充分地发挥机械硬盘的IO性能,获得较高的IO
带宽。
由于io_uring
机制本身所引入的开销,其在网络报文处理方面出现了性能退化的问题。网络IO
相比于存储器IO不太一样,网络IO
本质上应该是属于CPU
密集型场景,即影响网络吞吐量的是CPU
的性能。因此对于网络IO
,io_uring
的目标应该是降低CPU
开销。从上面的分析中我们可以看出,基于io_uring
的报文收发能够降低系统调用次数,从而起到减少CPU
开销的目的。由于poll
模式本质是一种牺牲CPU开销来换取性能的手段,因此这里可能并不适用,这里我们就不考虑该模式。
测试方式:进行UDP
收包,其中每个UDP
报文的大小为1k
。
模式 | 报文量 | 软中断CPU | 用户进程CPU | CPU占用总量 |
---|---|---|---|---|
sync | 10k/s | 37.8% | 31.6% | 69.4% |
io_uring | 10k/s | 44.8% | 54.4% | 99.2% |
从数据中我们可以看出,使用io_uring
方式进行异步报文接收反而造成了CPU
的升高,这是为什么呢?因为io_uring
机制本身也会额外的产生开销。在进行异步报文接收时,由于操作是异步的,因此内核会将接收操作放到异步队列中并启动多个工作队列来处理收包请求,并将每个报文接收请求都链接到套接口的poll
队列上等待报文到达后唤醒。额外的poll
操作是CPU
升高的一个原因,另一个原因是激烈是锁竞争。为了保证操作的一致性,io_uring
使用了大量的自旋锁,在多个异步请求同时进行的情况下,锁竞争消耗了相当多的CPU
。由此可见,在网络IO方面,io_uring
还存在着一定的优化空间。
liburing
直接使用系统调用来进行io_uring
的开发还是比较复杂的,特别是需要对共享内存中的环形队列进行操作。所幸开源社区上提供了封装好的liburing
库,大大简化了其使用。该库正是由io_uring
的作者Jens Axboe
实现的,其主要接口包括:
struct io_uring ring;
int io_uring_queue_init(unsigned entries, struct io_uring *ring, unsigned flags);
该接口用于io_uring
实例的初始化,entries
用于指定提交实例的数量;flags
用于设置标志,比如用于启动iopoll
模式的IORING_SETUP_IOPOLL
,用于启动sqpoll
模式的IORING_SETUP_SQPOLL
等。
struct io_uring_sqe *io_uring_get_sqe(struct io_uring *ring)
获取一个空闲的提交实体用于IO的提交。
static inline void io_uring_prep_read(struct io_uring_sqe *sqe, int fd,
void *buf, unsigned nbytes, off_t offset)
该函数为提交实体初始化的封装,使用提供的参数将提交实体初始化为“读”操作。除此之外,还要write/send/recv/...
等操作的封装函数,简化了代码的编写。
static inline void io_uring_sqe_set_data(struct io_uring_sqe *sqe, void *data)
static inline void *io_uring_cqe_get_data(const struct io_uring_cqe *cqe)
为提交实体设置(获取)私有数据,该数据为自定义数据,用于在提交实体完成后,从完成队列中获取到该对象时的识别等作用。
int io_uring_submit(struct io_uring *ring)
将提交队列中的SQE
提交给内核处理。如果开启了SQPOLL
模式,该函数不一定会陷入系统调用,只有在检查到内核进程没有运行的情况下才会产生系统调用。
static inline int io_uring_peek_cqe(struct io_uring *ring,
struct io_uring_cqe **cqe_ptr)
static inline int io_uring_wait_cqe(struct io_uring *ring,
struct io_uring_cqe **cqe_ptr)
从完成队列中获取完成实例,提供了阻塞和非阻塞两个版本。
下面以UDP
收包为例,来演示如何使用liburing
来进行异步IO的实现。
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "liburing.h"
#define MAX_PKT_SIZE 1500
#define MAX_PKT_COUNT 10
static void submit_recv(struct io_uring *ring, int sockfd, void *data)
{
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
if (!sqe)
return;
io_uring_prep_recv(sqe, sockfd, data, MAX_PKT_SIZE, 0);
io_uring_sqe_set_data(sqe, data);
}
static void submit_all_recv(struct io_uring *ring, int sockfd)
{
struct io_uring_sqe *sqe;
void *data;
//获取空闲的sqe
while ((sqe = io_uring_get_sqe(ring))) {
data = malloc(MAX_PKT_SIZE);
//将sqe初始化为recv操作
io_uring_prep_recv(sqe, sockfd, data, MAX_PKT_SIZE, 0);
//设置sqe的私有数据,方便我们在操作完成后获取到其中的报文数据
io_uring_sqe_set_data(sqe, data);
}
}
static int do_recv(struct io_uring *ring, int sockfd)
{
struct io_uring_cqe *cqe;
void *data;
int count;
//对提交数组中所有空闲的提交实体进行初始化,并放到提交队列
submit_all_recv(ring, sockfd);
//将提交队列中的请求提交给内核处理
io_uring_submit(ring);
count = 0;
while (true) {
//从完成队列中取出一个实例,返回非0的话代表完成队列中没有可取实例
if (io_uring_peek_cqe(ring, &cqe)) {
//进行一次提交操作,将提交队列中的请求批量提交给内核处理
io_uring_submit(ring);
//以阻塞的方式等待完成队列中存在可用实例
io_uring_wait_cqe(ring, &cqe);
}
if (!cqe) {
fprintf(stderr, "io_uring_get_sqe failed\n");
continue;
}
//获取完成实例中之前设置的私有数据
data = io_uring_cqe_get_data(cqe);
count++;
if (!(count % 1000))
printf("recved packet count: %d, queue len:%d\n", count, io_uring_sq_ready(ring));
//将完成实例标识为“完成处理”,其对应的提交实例可以被使用了
io_uring_cqe_seen(ring, cqe);
//继续进行请求的提交。这里使用之前分配好的data,避免重复的内存分配
submit_recv(ring, sockfd, data);
}
return 0;
}
int main()
{
struct sockaddr_in saddr;
struct io_uring ring;
int ret, sockfd;
//初始化uring,设置提交队列长度为10
ret = io_uring_queue_init(10, &ring, 0);
if (ret < 0)
{
perror("queue_init");
goto err;
}
//初始化UDP套接字
memset(&saddr, 0, sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = htonl(INADDR_ANY);
saddr.sin_port = htons(8080);
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
ret = bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (ret < 0)
{
perror("bind");
goto err;
}
//开始报文接收
do_recv(&ring, sockfd);
err:
return -1;
}
可以看出使用还是比较简洁的,用户也可以对liburing
接口进行二次封装以达到更加简洁的目的。
io_uring
的零拷贝展望MSG_ZEROCOPY
MSG_ZEROCOPY
是内核中现有的网络报文零拷贝技术,这个所谓的零拷贝技术,无论是实现还是取得的效果都有些差强人意。该技术是在三年前提出,它可以应用于各种常用网络协议的零拷贝收发,比如UDP
、TCP
、raw
以及packet
等,使用也很方便,只需要指定对应的套接字标志即可。以发包为例,其实现逻辑如下图所示:
首先,在创建套接口的时候,为套接口指定零拷贝的标志SO_ZEROCOPY
,代表后面都用零拷贝的方式进行报文发送:
setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one))
然后,使用我们平时熟悉的send()
或者sendmsg()
进行消息的发送即可,发送的时候要指定MSG_ZEROCOPY
标志,如下所示:
ret = send(fd, buf, sizeof(buf), MSG_ZEROCOPY);
报文的发送过程是异步的,当send()
系统调用返回的时候,我们并不确定当前报文已经被网卡顺利的发送出去了,因此还需要一个机制来完成这项工作。MSG_ZEROCOPY
利用了套接口的“错误队列”来实现这一机制,即当报文发送完成了,内核会往套接口的“错误队列”中放一条消息,用户检测到该消息后才可以重新使用这个报文缓冲区。因此,用户程序需要主动在当前套接口上进行poll()
操作,以等待消息的到来,然后使用recvmsg()
将该消息从错误队列中取出,判断其是我们关注的消息后,再继续报文的发送。这个过程的代码如下所示:
pfd.fd = fd;
pfd.events = 0;
if (poll(&pfd, 1, -1) != 1 || pfd.revents & POLLERR == 0)
error(1, errno, "poll");
ret = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (ret == -1)
error(1, errno, "recvmsg");
read_notification(msg);
可以看出,虽然内核避免了内存拷贝,但是相比于传统的报文发送,该方式多了两次系统调用。综合衡量,不一定会取得性能的提升。根据作者的描述,当报文大小大于10k
的时候,可能才会看到效果。
io_uring
零拷贝从上面的机制我们可以看出来,io_uring
是一种通用的异步IO机制,其不限于块设备的IO,常规的基于文件的IO都可以使用。MSG_ZEROCOPY
零拷贝技术的瓶颈就在于其通知机制引入了不必要的系统调用,如果使用io_uring
来实现零拷贝,那么通知的问题就迎刃而解,因为io_uring
本身就提供了使用完成队列来进行通知的功能。
对于这个思路,社区上的Jonathan Lemon
(MSG_ZEROCOPY
的维护者)已经在邮件系统上提过了(还没实现,不知道有没有在干活):
MSG_ZEROCOPY_FIXED,io_uring-only sendmsg + recvmsg zerocopy
根据社区上的讨论,他们是想基于io_uring
实现一个真·零拷贝技术,能够真正意义上使得网络报文的接收和发送过程中不产生数据拷贝:收包阶段,申请一块用户态共享内存,网卡收到报文后通过DMA
直接将报文传递给用户态;发包阶段,基于io_uring
的ringBuf
,直接将用户态的报文数据传递给网卡硬件,并通过完成队列来实现完成消息的通知。该方案在技术上还存在一定的难点,可能还需要一定的时间才能面向大众。可以想象得到,当该方案实现的时候,网络性能将获得进一步的提升。
本文对io_uring
的实现原理以及其所取得的高性能表现做了简单介绍,可以看出该机制作为一种通用的IO
机制具有强大的潜质,势必将成为日后主流的高性能异步IO
解决方案。特别是CGEL
中作为未来主力版本的Linux v5.4
已经对该机制提供了充分的支持,内核侧无需做任何调整即可使用该特性。同时,本文对io_uring
在网络报文零拷贝方面的研究现状也做了简单介绍,具体能给网络方面带来多大的性能提升,让我们拭目以待!
参考链接:【译】高性能异步 IO — io_uring(Effecient IO with io_uring)