异步IO引擎——io_uring设计与实现

异步IO引擎——io_uring设计与实现

转载自 https://zhuanlan.zhihu.com/p/334658432 有改动

Linux原来的异步IO有缺陷,现在修复了,在5.x里面引入了io_uring

同步IO接口

当前Linux对文件的操作有很多种方式,过往同步IO接口,从功能上划分,大体分为以下几种。 - 原始版本 - offset版本 - 向量版本 - offset+向量版本

原始版本就是read write,Offset版本是pthread/pwrite(线程安全),向量版本是readv和writev,offset+向量版本是preadv和writev。

后来还出现了变种函数preadv2和pwritev2,相比较preadv,pwritev,v2版本还能设置本次IO的标志,比如RWF_DSYNC、RWF_HIPRI、RWF_SYNC、RWF_NOWAIT、RWF_APPEND。

这类接口,尽管形式多种多样,但它们都有一个共同的特征,就是同步,即在读写IO时,系统调用会阻塞住等待,在数据读取或写入后才返回结果。

对于传统的普通的编程模型,这类同步接口编程简单,且结果可以预测,倒也无妨。但是在要求高效的场景下,同步导致的后果就是caller 在阻塞的同时无法继续执行其他的操作,只能等待IO结果返回,其实caller本可以利用这段时间继续往后执行。例如,一个 ftp 服务器,当接收到客户机上传的文件,然后将文件写入到本机的过程中,若ftp服务程序忙于等待文件读写结果的返回,则会拒绝其他此刻正需要连接的客户机请求。显然,在这种场景下,更好的方式是采用异步编程模型,如在上述例子中,当服务器接收到某个客户机上传文件后,直接、无阻塞地将写入IO的buffer 提交给内核,然后caller继续接受下一个客户请求,内核处理完IO之后,主动调用某种通知机制,告诉caller该IO已完成,完成状态保存在某位置,请查看。

所以,我们需要异步IO。

AIO

由上可见,仅同步IO有一定的局限性,我们还需要异步IO。后来应这类场景的诉求,产生了异步IO接口,即Linux Native异步IO——AIO。

历史上,一些项目通过使用kernel的这一新接口,获得了不菲的收益。

高性能服务器nginx就使用了这样的机制,nginx把读取文件的操作异步地提交给内核后,内核会通知IO设备独立地执行操作,这样,nginx进程可以继续充分地占用CPU。而且,当大量读事件堆积到IO设备的队列中时,将会发挥出内核中“电梯算法”的优势,从而降低随机读取磁盘扇区的成本。

但是它仍然不够完美,同样存在很多缺陷,还是以nginx为例,目前,nginx仅支持在读取文件时使用AIO,因为正常写入文件往往是写入内存就立刻返回,效率很高,如果替换成AIO写入速度会明显下降。

这是因为AIO不支持缓存操作,即使需要操作的文件块在linux文件缓存中存在,也不会通过操作缓存中的文件块来代替实际对磁盘的操作,这可能降低实际处理的性能。需要看具体的使用场景,如果大部分用户请求对文件操作都会落到文件缓存中,那么使用AIO可能不是一个好的选择,需要实际测试。

  • 仅支持direct IO。在采用AIO的时候,只能使用O_DIRECT,不能借助文件系统缓存来缓存当前的IO请求,还存在size对齐(直接操作磁盘,所有写入内存块数量必须是文件系统块大小的倍数,而且要与内存页大小对齐。)等限制,直接影响了aio在很多场景的使用。
  • 仍然可能被阻塞。语义不完备。即使应用层主观上,希望系统层采用异步IO,但是客观上,有时候还是可能会被阻塞。io_getevents(2)调用read_events读取AIO的完成events,read_events中的wait_event_interruptible_hrtimeout等待aio_read_events,如果条件不成立(events未完成)则调用__wait_event_hrtimeout进入睡眠(当然,支持用户态设置最大等待时间)。
  • 拷贝开销大。每个IO提交需要拷贝64+8字节,每个IO完成需要拷贝32字节,总共104字节的拷贝。这个拷贝开销是否可以承受,和单次IO大小有关:如果需要发送的IO本身就很大,相较之下,这点消耗可以忽略,而在大量小IO的场景下,这样的拷贝影响比较大。
  • API不友好。每一个IO至少需要两次系统调用才能完成(submit和wait-for-completion),需要非常小心地使用完成事件以避免丢事件。
  • 系统调用开销大。也正是因为上一条,io_submit/io_getevents造成了较大的系统调用开销,在存在spectre/meltdown(CVE-2017-5754)的机器上,若如果要避免漏洞问题,系统调用性能则会大幅下降。在存储场景下,高频系统调用的性能影响较大。

源码查看

简单,易用,可扩展,高效,特性丰富。

解决“系统调用开销大”的问题,batch提交系统调用。

解决“拷贝开销大”的问题,一个共享内存的ringbuffer。

共享ring buffer的设计主要带来以下几个好处:

  • 提交、完成请求时节省应用和内核之间的内存拷贝
  • 使用 SQPOLL 高级特性时,应用程序无需调用系统调用
  • 无锁操作,用memory ordering实现同步,通过几个简单的头尾指针的移动就可以实现快速交互。

一块用于核外传递数据给内核,一块是内核传递数据给核外,一方只读,一方只写。 提交队列SQ(submission queue)中,应用是IO提交的生产者(producer),内核是消费者(consumer)。 完成队列CQ(completion queue)中,内核是完成事件的生产者,应用是消费者。

内核控制SQ ring的head和CQ ring的tail,应用程序控制SQ ring的tail和CQ ring的head

那么他们分别需要保存的是什么数据呢?

假设A缓存区为应用写,内核读,就是将IO数据写到这个缓存区,然后通知内核来读;再假设B缓存区为内核写,应用读,他所承担的责任就是返回完成状态,标记A缓存区的其中一个entry的完成状态为成功或者失败等信息。

io_uring、io_rings

https://github.com/torvalds/linux/blob/c9194f32bfd932e976a158d1af97a63be68a2aab/fs/io_uring.c#L130 查看代码

其中io_rings结构中sq, cq成员,分别代表了提交的请求的ring和已经完成的请求返回结构的ring。io_uring结构中是head和tail,用于控制队列中的头尾索引。即前文提到的,内核控制SQ ring的head和CQ ring的tail,应用程序控制SQ ring的tail和CQ ring的head。

struct io_uring {
    u32 head ____cacheline_aligned_in_smp;
    u32 tail ____cacheline_aligned_in_smp;
};
struct io_rings {
    struct io_uring     sq, cq;
    u32         sq_ring_mask, cq_ring_mask;
    u32         sq_ring_entries, cq_ring_entries;
    u32         sq_dropped;
    u32         sq_flags;
    u32         cq_flags;
    u32         cq_overflow;
    struct io_uring_cqe cqes[] ____cacheline_aligned_in_smp;
};

io_uring_sqe

https://github.com/torvalds/linux/blob/c9194f32bfd932e976a158d1af97a63be68a2aab/include/uapi/linux/io_uring.h#L17 查看代码

Submission Queue(下称SQ)是提交队列,核外写内核读的地方。Submission Queue Entry(下称SQE),即提交队列中的条目,队列由一个个条目组成。

描述一个SQE会复杂很多,不仅是因为要描述更多的信息,也是因为可扩展性这一设计原则。

我们需要操作码、标志集合、关联文件描述符、地址、偏移量,另外地,可能还需要表示优先级。

struct io_uring_sqe {
    __u8    opcode;     /* type of operation for this sqe */
    __u8    flags;      /* IOSQE_ flags */
    __u16   ioprio;     /* ioprio for the request */
    __s32   fd;     /* file descriptor to do IO on */
    union {
        __u64   off;    /* offset into file */
        __u64   addr2;
    };
    union {
        __u64   addr;   /* pointer to buffer or iovecs */
        __u64   splice_off_in;
    };
    __u32   len;        /* buffer size or number of iovecs */
    union {
        __kernel_rwf_t  rw_flags;
        __u32       fsync_flags;
        __u16       poll_events;    /* compatibility */
        __u32       poll32_events;  /* word-reversed for BE */
        __u32       sync_range_flags;
        __u32       msg_flags;
        __u32       timeout_flags;
        __u32       accept_flags;
        __u32       cancel_flags;
        __u32       open_flags;
        __u32       statx_flags;
        __u32       fadvise_advice;
        __u32       splice_flags;
    };
    __u64   user_data;  /* data to be passed back at completion time */
    union {
        struct {
            /* pack this to avoid bogus arm OABI complaints */
            union {
                /* index into fixed buffers, if used */
                __u16   buf_index;
                /* for grouped buffer selection */
                __u16   buf_group;
            } __attribute__((packed));
            /* personality to use, if used */
            __u16   personality;
            __s32   splice_fd_in;
        };
        __u64   __pad2[3];
    };
};
  • opcode是操作码,例如IORING_OP_READV,代表向量读。
  • flags是标志位集合。
  • ioprio是请求的优先级,对于普通的读写,具体定义可以参照ioprio_set(2),
  • fd是这个请求相关的文件描述符
  • off是操作的偏移量
  • addr表示这次IO操作执行的地址,如果操作码opcode描述了一个传输数据的操作,这个操作是基于向量的,addr就指向struct iovec的数组首地址,这和前文所说的preadv系统调用是一样的用法;如果不是基于向量的,那么addr必须直接包含一个地址,len这里(非向量场景)就表示这段buffer的长度,而向量场景就表示iovec的数量。
  • 接下来的是一个union,表示一系列针对特定操作码opcode的一些flag。例如,对于上文所提的IORING_OP_READV,随后的flags就遵循preadv2系统调用。
  • user_data是各操作码opcode通用的,内核并未染指,仅仅只是拷贝给完成事件completion event
  • 结构的最后用于内存对齐,对齐到64字节,为了更丰富的特性,未来这个请求结构应该会包含更多的内容。

这就是核外往内核填写的Submission Queue Entry的数据结构,准备好这样的一个数据结构,将它写到对应的sqes所在的内存位置,然后再通知内核去对应的位置取数据,这样就完成了一次数据交接。

io_uring_cqe

https://github.com/torvalds/linux/blob/c9194f32bfd932e976a158d1af97a63be68a2aab/include/uapi/linux/io_uring.h#L176 查看代码

Completion Queue(下称CQ)是完成队列,内核写核外读的地方。Completion Queue Entry(下称CQE),即完成队列中的条目,队列由一个个条目组成。

描述一个CQE就简单得多。

/*
 * IO completion data structure (Completion Queue Entry)
 */
struct io_uring_cqe {
    __u64   user_data;  /* sqe->data submission passed back */
    __s32   res;        /* result code for this event */
    __u32   flags;
};
  • user_data就是sqe发送时核外填写的,只不过在完成时回传而已,一个常见的用例就是作为一个指针,指向原始请求。从submission queue到completion queue,内核不会动这里面的数据

  • res用来保存最终的这个sqe的执行结果,就是这个event的返回码,可以认为是系统调用的返回值,表示成功或失败等。如果接口成功的话返回传输的字节数,如果失败的话,就是错误码。如果错误发生,res就等于-EIO

  • flags是标志位集合。如果flags设置为IORING_CQE_F_BUFFER,则前16位是buffer ID(调用链:io_uring_enter -> io_iopoll_check -> io_iopoll_getevents -> io_do_iopoll -> io_iopoll_complete -> io_put_rw_kbuf -> io_put_kbuf,最终会调用io_put_kbuf,如代码所示)。

https://github.com/torvalds/linux/blob/c9194f32bfd932e976a158d1af97a63be68a2aab/fs/io_uring.c#L2204 查看io_put_kbuf代码

/*
 * cqe->flags
 *
 * IORING_CQE_F_BUFFER  If set, the upper 16 bits are the buffer ID
 */
#define IORING_CQE_F_BUFFER     (1U << 0)

enum {
    IORING_CQE_BUFFER_SHIFT     = 16,
};
static unsigned int io_put_kbuf(struct io_kiocb *req, struct io_buffer *kbuf)
{
    unsigned int cflags;

    cflags = kbuf->bid << IORING_CQE_BUFFER_SHIFT;
    cflags |= IORING_CQE_F_BUFFER;
    req->flags &= ~REQ_F_BUFFER_SELECTED;
    kfree(kbuf);
    return cflags;
}

io_ring_ctx

https://github.com/torvalds/linux/blob/c9194f32bfd932e976a158d1af97a63be68a2aab/fs/io_uring.c#L346 查看代码

前面介绍了SQE/CQE等关键的数据结构,他们是用来承载数据流的关键部分,有了数据流的关键数据结构我们还需要一个上下文数据结构,用于整个io_uring控制流。这就是io_ring_ctx,贯穿整个io_uring所有过程的数据结构,基本上在任何位置只需要你能持有该结构就可以找到任何数据所在的位置,例如,sq_sqes就是指向io_uring_sqe结构的指针,指向SQEs的首地址。

数据结构定义好了,逻辑实现具体是如何驱动这些数据结构的呢?使用上,大体分为准备、提交、收割过程。

有几个io_uring相关的系统调用:在io_uring.c

#include <linux/io_uring.h>
int io_uring_setup(u32 entries, struct io_uring_params *p);

int io_uring_enter(unsigned int fd, unsigned int to_submit,
                   unsigned int min_complete, unsigned int flags,
                   sigset_t *sig);

int io_uring_register(unsigned int fd, unsigned int opcode,
                      void *arg, unsigned int nr_args);

下面分析关键流程。

io_uring通过io_uring_setup完成准备阶段。

int io_uring_setup(u32 entries, struct io_uring_params *p);

io_uring_setup系统调用的过程就是初始化相关数据结构,建立好对应的缓存区,然后通过系统调用的参数io_uring_params结构传递回去,告诉核外ring buffer内存地址在哪,起始指针的地址在哪等关键的信息。

需要初始化内存的内存分为三个区域,分别是SQ,CQ,SQEs。内核初始化SQ和CQ,此外,提交请求在SQ,CQ之间有一个间接数组,即内核提供了一个Submission Queue Entries(SQEs)数组。之所以额外采用了一个数组保存SQEs,是为了方便通过环形缓冲区提交内存上不连续的请求。SQ和CQ中每个节点保存的都是SQEs数组的索引,而不是实际的请求,实际的请求只保存在SQEs数组中。这样在提交请求时,就可以批量提交一组SQEs上不连续的请求。

通常,SQE被独立地使用,意味着它的执行不影响在ring中的连续SQE条目。它允许全面、灵活的操作,并且使它们最高性能地并行执行完成。一个顺序的使用案例就是数据的整体写入。它的一个通常的例子就是一系列写,随之的是fsync/fdatasync,应用通常转变成程序同步-等待操作。

io_uring_params

在io_uring.h里面可以查看参数定义

https://github.com/torvalds/linux/blob/c9194f32bfd932e976a158d1af97a63be68a2aab/include/uapi/linux/io_uring.h#L253

/*
 * Passed in for io_uring_setup(2). Copied back with updated info on success
 */
struct io_uring_params {
    __u32 sq_entries;
    __u32 cq_entries;
    __u32 flags;
    __u32 sq_thread_cpu;
    __u32 sq_thread_idle;
    __u32 features;
    __u32 wq_fd;
    __u32 resv[3];
    struct io_sqring_offsets sq_off;
    struct io_cqring_offsets cq_off;
};

/*
 * io_uring_params->features flags
 */
#define IORING_FEAT_SINGLE_MMAP     (1U << 0)
#define IORING_FEAT_NODROP      (1U << 1)
#define IORING_FEAT_SUBMIT_STABLE   (1U << 2)
#define IORING_FEAT_RW_CUR_POS      (1U << 3)
#define IORING_FEAT_CUR_PERSONALITY (1U << 4)
#define IORING_FEAT_FAST_POLL       (1U << 5)
#define IORING_FEAT_POLL_32BITS     (1U << 6)

先从参数上来解析

  • 核外需要告诉io_uring_setup提交的整个缓存区数组的大小。这里就是u32 entries参数。
  • params这个参数从IO的角度看有两种,一种是输入参数,一种是输出参数。 一部分属于输入参数,是用户设置、核外传递给核外的,用于定义io_uring在内核中的行为,这些都是在创建阶段就决定了的。 比如params->flags,这个成员变量是用来设置当前整个io_uring 的标志的,它将决定是否启动sq_thread,是否采用iopoll模式等等 。sq_thread_cpu、sq_thread_idle也由用户设置,用来指定io_sq_thread内核线程CPU、idle时间。
  • 还有一部分属于输出参数,由内核设置(io_uring_create)、传递数据到核外的,核外根据这些数据来使用mmap分配内存,初始化一些数据结构。 sq_entries是输出参数,由内核填充,让应用程序知道这个ring支持多少SQE。 类似地,cq_entries告诉应用程序,CQ ring有多大。 - sq_off和cq_off分别是io_sqring_offsets和io_cqring_offsets结构,是内核与核外的约定,分别描述了SQ和CQ的指针在mmap中的offset

io_uring_setup

https://github.com/torvalds/linux/blob/c9194f32bfd932e976a158d1af97a63be68a2aab/fs/io_uring.c#L9767 查看io_uring_set_up

再从实现上来解析,如下为io_uring_setup代码。

/*
 * Sets up an aio uring context, and returns the fd. Applications asks for a
 * ring size, we return the actual sq/cq ring sizes (among other things) in the
 * params structure passed in.
 */
static long io_uring_setup(u32 entries, struct io_uring_params __user *params)
{
    struct io_uring_params p;
    int i;

    if (copy_from_user(&p, params, sizeof(p)))
        return -EFAULT;
    for (i = 0; i < ARRAY_SIZE(p.resv); i++) {
        if (p.resv[i])
            return -EINVAL;
    }

    if (p.flags & ~(IORING_SETUP_IOPOLL | IORING_SETUP_SQPOLL |
            IORING_SETUP_SQ_AFF | IORING_SETUP_CQSIZE |
            IORING_SETUP_CLAMP | IORING_SETUP_ATTACH_WQ |
            IORING_SETUP_R_DISABLED))
        return -EINVAL;

    return  io_uring_create(entries, &p, params);
}

经过标志位非法检查之后,关键是调用内部函数io_uring_create实现实例创建过程。这里相当于000000去和flag做并,肯定是0。resv个人猜测是否完成过。

io_uring_create

https://github.com/torvalds/linux/blob/c9194f32bfd932e976a158d1af97a63be68a2aab/fs/io_uring.c#L9635

  • 首先需要创建一个上下文结构io_ring_ctx用来管理整个会话。
  • 其余的是一些错误检查、权限检查、资源配额检查等检查逻辑。并设置io_sqring_offsets、io_cqring_offsets等相关结构、标志位集合。

io_uring_enter

https://github.com/torvalds/linux/blob/c9194f32bfd932e976a158d1af97a63be68a2aab/tools/io_uring/syscall.c#L47 查看代码片段1

在初始化完成之后,应用程序就可以使用这些队列来添加IO请求,即填充SQE。当请求都加入SQ后,应用程序还需要某种方式告诉内核,生产的请求待消费,这就是提交IO请求,可以通过io_uring_enter系统调用。

int io_uring_enter(unsigned int fd, unsigned int to_submit,
                   unsigned int min_complete, unsigned int flags,
                   sigset_t *sig);

内核将SQ中的请求提交给Block层。这个系统调用既能提交,也能等待。

具体的实现是找到一个空闲的SQE,根据请求设置SQE,并将这个SQE的索引放到SQ中。SQ是一个典型的ring buffer,有head,tail两个成员,如果head == tail,意味着队列为空。SQE设置完成后,需要修改SQ的tail,以表示向ring buffer中插入了一个请求。

https://github.com/torvalds/linux/blob/c9194f32bfd932e976a158d1af97a63be68a2aab/fs/io_uring.c#L9332 查看代码片段2

先从参数上来解析

  • fd即由io_uring_setup(2)返回的文件描述符,to_submit告诉内核待消费和提交的SQE的数量,表示一次提交多少个 IO, min_complete请求完成请求的个数。 flags是修饰接口行为的标志集合,这里主要例举两个flags 。
  • 如果在io_uring_setup的时候flag设置了IORING_SETUP_SQPOLL,内核会额外启动一个特定的内核线程来执行轮询的操作,称作SQ线程,这里使用的轮询结构会最终对应到struct file_operations中的iopoll操作,这个操作作为一个新的接口在最近才添加到这里,Linux native aio的新功能也使用了这个iopoll。这里io _uring实际上只有vfs层的改动,其它的都是使用已经存在的东西,而且几个核心的东西和aio使用的相同/类似。直接通过访问相关的队列就可以获取到执行完的任务,不需要经过系统调用。关于这个线程,通过io_uring_params结构中的sq_thread_cpu配置,这个内核线程可以运行在某个指定的 CPU核心 上。这个内核线程会不停的 Poll SQ,直到在通过sq_thread_idle配置的时间内没有Poll到任何请求为止。 -
  • 如果flags中设置了IORING_ENTER_GETEVENTS,并且min_complete > 0,这个系统调用会一直 block,直到 min_complete 个 IO 已经完成才返回。这个系统调用会同时处理 IO 收割。 - 另外的,IORING_SQ_NEED_WAKEUP可以表示在一些时候唤醒休眠中的轮询线程。static int io_sq_thread(void *data)即内核轮询线程。同样地,可以用这个系统调用等待完成。除非应用程序,内核会直接修改CQ,因此调用io_uring_enter系统调用时不必使用IORING_ENTER_GETEVENTS,完成就可以被应用程序消费。
  • io_uring提供了submission offload模式,使得提交过程完全不需要进行系统调用。当程序在用户态设置完SQE,并通过修改SQ的tail完成一次插入时,如果此时SQ线程处于唤醒状态,那么可以立刻捕获到这次提交,这样就避免了用户程序调用io_uring_enter。如上所说,如果SQ线程处于休眠状态,则需要通过使用IORING_SQ_NEED_WAKEUP标志位调用io_uring_enter来唤醒SQ线程。
  • 以io_iopoll_check为例,正常情况下执行路线是io_iopoll_check -> io_iopoll_getevents -> io_do_iopoll -> (kiocb->ki_filp->f_op->iopoll). 在完成请求的操作之后,会调用下面这个函数提交结果到cqe数组中,这样应用就能看到结果了。这里的io_cqring_fill_event就是获取一个目前可以写入到cqe,写入数据。这里最终调用的会是io_get_cqring,可以见就是返回目前tail的后面的一个。

更详细的内容可以直接参考io_uring_enter(2)的man page。

io_get_cqring实现

static struct io_uring_cqe *io_get_cqring(struct io_ring_ctx *ctx)
{
    struct io_rings *rings = ctx->rings;
    unsigned tail;

    tail = ctx->cached_cq_tail;
    /*
     * writes to the cq entry need to come after reading head; the
     * control dependency is enough as we're using WRITE_ONCE to
     * fill the cq entry
     */
    if (tail - READ_ONCE(rings->cq.head) == rings->cq_ring_entries)
        return NULL;

    ctx->cached_cq_tail++;
    return &rings->cqes[tail & ctx->cq_mask];
}

来都来了,搞点事情吧,在我们提交IO的同时,使用同一个io_uring_enter系统调用就可以回收完成状态,这样的好处就是一次系统调用接口就完成了原本需要两次系统调用的工作,大大的减少了系统调用的次数,也就是减少了内核核外的切换,这是一个很明显的优化,内核与核外的切换极其耗时。

当IO完成时,内核负责将完成IO在SQEs中的index放到CQ中。由于IO在提交的时候可以顺便返回完成的IO,所以收割IO不需要额外系统调用。

如果使用了IORING_SETUP_SQPOLL参数,IO收割也不需要系统调用的参与。由于内核和用户态共享内存,所以收割的时候,用户态遍历[cring->head, cring->tail)区间,即已经完成的IO队列,然后找到相应的CQE并进行处理,最后移动head指针到tail,IO收割至此而终。

所以,在最理想的情况下,IO提交和收割都不需要使用系统调用。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值