高性能异步IO机制:IO_URING

一、前言

1.1 异步IO机制

Linux内核提供的IO机制大都是同步实现的,如常规的read/write/send/recv等系统调用。同步IO机制存在着一定的弊端,例如:(1)IO的实现都是在当前进程上下文的系统调用中完成的,会阻塞当前进程,降低系统的实时性;(2)性能较低。

异步IO指的是用户程序将IO请求提交后,无需等待IO操作的完成,而是可以继续处理别的事情。当IO操作完成后,会以某种方式通知用户程序。Linux系统下现有的异步IO机制的实现主要为两种:

  1. POSIX AIO。这种方案是用户态实现的异步IO机制,其核心思想为:创建一个专门用来处理IO的线程,用户程序将IO操作交给该线程来进行。这种方式实现的异步IO效率和扩展性都比较差。
  2. LINUX AIO。Linux内核里也实现了一套异步IO机制,被称为AIO。该机制的使用限制比较大,比如只支持direct IO而无法使用cache,且扩展性比较差。

在此背景下,Linux社区在5.1版本中引入了一种全新的异步IO机制:io_uringio_uring提供了一套全新的系统调用接口,凭借着其高性能、高扩展性等优势脱颖而出,成为了目前Linux下主流的异步IO方案。

1.2 优势

相比于现有的异步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原理

3.1 ringBuf

从名字uring我们就可以看出来,该机制的核心即userring:其申请了一块用户态和内核态共享的内存,并在共享内存中通过ringBuf环形缓存队列的方式来实现内核态和用户态的通信,这里简单说一下该机制的使用和实现过程。

img

每一个io_uring实例,都会被分配一个fd,该过程是通过io_uring_setup()系统调用实现的。该系统调用会根据用户提供的参数,分配一块共享内存,该内存可以通过mmap()的方式映射到用户态。这块共享内存中,包含了一个SQ(提交队列)、一个CQ(完成队列)和一个SQE(提交实体)数组。其中,SQCQ是两个环形队列,队列中的元素是SQESQE数组中的偏移量,使用这种方式可以使得提交实体能够被随机访问,提高灵活性。用户从CQ的头部获取SEQ,将想要执行的操作(如文件的读写)初始化到其中,并添加到SQ队列的尾部,然后使用io_uring_enter()系统调用来进行提交队列的处理。

在这里插入图片描述

内核会从SQ中依次取出对应的提交实体,并根据提交实体中定义的动作来执行对应的操作。由于用户只操作SQ尾部,而内核只操作头部,因此两者对于共享队列的访问并不会产生冲突,节省了锁的开销。上图中为内核的处理流程简图,为了提高性能、降低时延,内核并不是一定会采用异步的方式来处理提交实体,而是会检查该实体所对应的文件系统是否支持非阻塞式的操作。对于块设备的读写,可能并不会支持非阻塞式操作,但是对于其他的一些文件系统,如网络套接字,是支持非阻塞式报文接收的。显而易见,当socket接收队列中存在报文时,进行异步报文读取无疑是不明智的,不仅会增加开销,还会导致收包的延迟。因此,内核会首先采用非阻塞的方式进行报文的读取,当收报队列中不存在报文时(会返回EAGAIN错误),才会将提交实体添加到异步队列,等待报文的到来。

在操作完成后,内核会将完成了的提交实体放到CQ队列的尾部,方便用户继续进行操作的提交。通过ringBuf的使用,io_uring获得了以下几点收益:

  • 能够以批量的方式进行IO的提交,减少了系统调用的次数,节省了开销;
  • 通过共享内存的使用,避免了用户态与内核态频繁的系统调用参数拷贝,提升了性能。

3.2 poll模式

为了进一步提高性能,io_uring还提供了两种轮询模式,即块设备层的iopoll轮询模式和提交sqpoll轮询模式。

3.2.1 iopoll

什么是IO轮询(poll)模式?轮询模式是相对于中断模式的。常规的块设备IO使用的都是中断模式,即进程将IO请求提交给块设备后会进入睡眠(D)状态,块设备在处理完IO请求后会触发硬中断,硬中断中会唤醒进程并通知其IO的完成。io_uring提供了一种block层的轮询模式,即IO请求提交后不进入睡眠,而是循环检查硬件设备的完成状态。该模式下,io_uring会额外启动一个内核进程来循环检查IO的完成。由于不需要等待硬件设备的通知,因此可以更快地获取到IO请求的完成,这对于延迟非常低以及IOPS很高的设备,能够显著提高性能,同时避免了高频的中断所带来的性能开销。

3.2.2 sqpoll

通过ringBuf的使用,我们现在可以批量地进行IO操作的提交,降低了系统调用次数。io_uring还提供了另一种机制用于进一步降低系统调用次数、提高IO效率,即:提交队列轮询SQPOLL模式。

在这里插入图片描述

该模式下,内核会启动一个内核进程专门用于SQE提交实体的处理,该进程会循环检查提交队列中是否存在实体。用户态程序只需要取出完成队列中的SEQ,进行初始化并添加到提交队列中即可,整个过程都不需要产生系统调用。为了降低开销,内核进程会有一个超时时间,在该时间段内如果都没有检测到提交队列中存在实体,就会进入睡眠状态,同时将进程的状态更新到共享内存中。用户进程在提交SQE之后,会通过该标志位检查poll进程是否在运行。若未运行,则通过io_uring_enter系统调用唤醒poll进程。

可以看出,在高IO频率的情况下,使用该模式可以大幅降低系统调用的次数,同时减少由于系统调用而带来的IO延迟。

3.3 固定文件和固定缓冲区

3.3.1 注册文件

每次将文件描述符填充到 sqe,然后提交给内核时,内核都必须检索对文件描述符的引用
当 IO 完成后,会再次删除文件引用,由于文件引用的原子性,这样对高 IOPS 的工作场景而言,速度会明显下降。
为了缓解此问题,io_uring 提供了一种对 io_uring 实例预注册文件集的方法

int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args);
  • fdio_uring 实例的文件描述符
  • opcode 执行的注册类型。
    对于注册文件集来说,必须是 IORING_REGISTER_FILES
  • arg 必须指向应用准备打开的文件描述符数组
  • nr_args 便是数组的大小

一旦 io_uring_register 成功将文件集注册后,应用就可以将文件集数组的索引(而不是使用实际的文件描述符)赋值给 sqe->fd 了,并设置 sqe->flags 字段为 IOSQE_FIXED_FILE 来标记 sqe->fd 是一个文件集索引

应用可以继续使用未注册的文件,即使是注册过的文件也可以通过文件描述符赋值 sqe->fdsqe->flags不设置 IO_FIXED_FILE 来正常使用文件描述符

io_uring 实例被移除后,注册的文件集会自动释放,或者使用 IORING_UNREGISTER_FILES opcode 来调用 io_uring_register

3.3.2 注册缓冲区

不仅仅可以注册文件集,还可以注册一组固定 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_FIXEDIORING_OP_WRITE_FIXED 在 IO 中利用这些缓冲区。
当使用 固定操作码(fixed op-codes) 时,sqe->addr 必须包含了那些固定缓冲区之一的索引,并且 sqe->len为请求的字节长度。
应用可能会注册大于 IO 操作的缓冲区,一个固定的读/写只是一个固定缓冲区的子集是完全合法的。

四、性能测试及分析

4.1 存储设备性能分析

4.1.1 快速存储芯片

在快速的存储设备上,如3D xpointSATA等,io_uring取得了可观的性能提升。以下为Linux-AIOio_uring以及spdk的性能对比数据,该数据由io_uring的作者Jens Axboe提供。其中spdkintel提出的从驱动层面实现的高性能存储设备框架,可以说是高性能存储方案中的标杆。该数据的测试方式为:在3D xpoint存储设备上进行4k大小的随机读取操作,衡量的指标为:

  • Latency:读取数据的延迟
  • IOPS:每秒内IO操作的次数,这里为每秒能够读取4k数据的次数
InterfaceQDPolledLatencyIOPS
io_uring109.5usec77K
io_uring208.2usec183K
io_uring408.4usec383K
io_uring8013.3usec449K
libaio109.7usec74K
libaio208.5usec181K
libaio408.5usec373K
libaio8015.4usec402K
io_uring116.1usec139K
io_uring216.1usec272K
io_uring416.3usec519K
io_uring8111.5usec592K
spdk116.1usec151K
spdk216.2usec293K
spdk416.7usec536K
spdk8112.6usec586K

从数据中可以看出,在非poll模式下,io_uringIO延迟和IOPS上都有了些许的提升,提升效果似乎并不大。当开启了iopollsqpoll的情况下,内核进程会同时对提交队列和块设备驱动做轮询,此时能够达到最佳的IO性能。从数据中我们可以看出,当开启了poll模式后,io_uring在延迟和IOPS上的表现已经远远超越了AIO,并且已经可以媲美spdk了,特别是在高QDQueue depth,一次性向设备发送多个IO请求)的情况下,甚至有赶超的趋势。

阿里云的团队对io_uring的性能也做了测评,其测评环境为:ecs.i2.2xlarge8 vCPU 64 GiBI2 本地存储 1788 GiB。测评结果显示,在进行顺序读写的情况下,io_uring性能提升明显,可达到160% ~ 170%;在进行随机读写时,性能提升可达到30% ~ 150%。测评数据如下所示:

  • 4k顺序读取:

在这里插入图片描述

  • 4k顺序写入:

在这里插入图片描述

  • 4k随机读取:

在这里插入图片描述

  • 4k随机写入:

在这里插入图片描述

4.1.2 普通机械硬盘

在速度比较慢的机械硬盘上,io_uring性能提升不明显。笔者在generic X86的PC下使用fio测试组件对io_uring的性能进行了测试,测试中所使用到的存储器为普通的机械硬盘,测试方式为进行4k的随机读操作,测试结果如下所示:

InterfaceQDPolledLatencyIOPS
sync1010.1msec131
sync409.8msec132
sync16010.3msec130
io_uring119.7msec133
io_uring4120.1msec201
io_uring16155.7msec286
io_uring109.9msec132
io_uring4020.2msec205
io_uring16055.5msec290

从结果中可以看出,由于同步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带宽。

4.2 网络性能测试

由于io_uring机制本身所引入的开销,其在网络报文处理方面出现了性能退化的问题。网络IO相比于存储器IO不太一样,网络IO本质上应该是属于CPU密集型场景,即影响网络吞吐量的是CPU的性能。因此对于网络IOio_uring的目标应该是降低CPU开销。从上面的分析中我们可以看出,基于io_uring的报文收发能够降低系统调用次数,从而起到减少CPU开销的目的。由于poll模式本质是一种牺牲CPU开销来换取性能的手段,因此这里可能并不适用,这里我们就不考虑该模式。

测试方式:进行UDP收包,其中每个UDP报文的大小为1k

模式报文量软中断CPU用户进程CPUCPU占用总量
sync10k/s37.8%31.6%69.4%
io_uring10k/s44.8%54.4%99.2%

从数据中我们可以看出,使用io_uring方式进行异步报文接收反而造成了CPU的升高,这是为什么呢?因为io_uring机制本身也会额外的产生开销。在进行异步报文接收时,由于操作是异步的,因此内核会将接收操作放到异步队列中并启动多个工作队列来处理收包请求,并将每个报文接收请求都链接到套接口的poll队列上等待报文到达后唤醒。额外的poll操作是CPU升高的一个原因,另一个原因是激烈是锁竞争。为了保证操作的一致性,io_uring使用了大量的自旋锁,在多个异步请求同时进行的情况下,锁竞争消耗了相当多的CPU。由此可见,在网络IO方面,io_uring还存在着一定的优化空间。

五、应用编程示例

5.1 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)

从完成队列中获取完成实例,提供了阻塞和非阻塞两个版本。

5.2 编程示例

下面以UDP收包为例,来演示如何使用liburing来进行异步IO的实现。

#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <assert.h>
#include <errno.h>
#include <inttypes.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/types.h>

#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的零拷贝展望

6.1 MSG_ZEROCOPY

MSG_ZEROCOPY是内核中现有的网络报文零拷贝技术,这个所谓的零拷贝技术,无论是实现还是取得的效果都有些差强人意。该技术是在三年前提出,它可以应用于各种常用网络协议的零拷贝收发,比如UDPTCPraw以及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的时候,可能才会看到效果。

6.2 io_uring零拷贝

从上面的机制我们可以看出来,io_uring是一种通用的异步IO机制,其不限于块设备的IO,常规的基于文件的IO都可以使用。MSG_ZEROCOPY零拷贝技术的瓶颈就在于其通知机制引入了不必要的系统调用,如果使用io_uring来实现零拷贝,那么通知的问题就迎刃而解,因为io_uring本身就提供了使用完成队列来进行通知的功能。

对于这个思路,社区上的Jonathan LemonMSG_ZEROCOPY的维护者)已经在邮件系统上提过了(还没实现,不知道有没有在干活):

MSG_ZEROCOPY_FIXEDio_uring-only sendmsg + recvmsg zerocopy

根据社区上的讨论,他们是想基于io_uring实现一个真·零拷贝技术,能够真正意义上使得网络报文的接收和发送过程中不产生数据拷贝:收包阶段,申请一块用户态共享内存,网卡收到报文后通过DMA直接将报文传递给用户态;发包阶段,基于io_uringringBuf,直接将用户态的报文数据传递给网卡硬件,并通过完成队列来实现完成消息的通知。该方案在技术上还存在一定的难点,可能还需要一定的时间才能面向大众。可以想象得到,当该方案实现的时候,网络性能将获得进一步的提升。

七、总结

本文对io_uring的实现原理以及其所取得的高性能表现做了简单介绍,可以看出该机制作为一种通用的IO机制具有强大的潜质,势必将成为日后主流的高性能异步IO解决方案。特别是CGEL中作为未来主力版本的Linux v5.4已经对该机制提供了充分的支持,内核侧无需做任何调整即可使用该特性。同时,本文对io_uring在网络报文零拷贝方面的研究现状也做了简单介绍,具体能给网络方面带来多大的性能提升,让我们拭目以待!

参考链接:【译】高性能异步 IO — io_uring(Effecient IO with io_uring)

Logo

更多推荐