Bootstrap

io_uring技术的分析与思考

io_uring是Linux 5.1中引入的一套新的syscall接口,用于支持异步IO。近来这套机制颇受关注,很多人认为它代表了与内核实现高性能交互的一种模式。本文将对io_uring的原理和实现进行分析,了解其相对于原有IO机制的优势,并尝试预测其应用场景和发展趋势。

异步IO机制

在介绍io_uring之前,需要先了解一下其实现的异步IO机制,以及io_uring之前的异步IO是如何实现的。

在linux下,目前绝大部分程序中的IO操作都是同步的,最后通过read/write系列的系统调用实现对文件的读写。同步IO的性能受限于文件类型和底层的设备性能,还有可能造成线程阻塞,在要求实时性的场景下显然不能满足需求。为此,出现了多种异步IO的实现机制,来解决实时IO的问题。异步IO是一种IO接口类型,调用这种类型的接口进行IO操作,可以在IO操作实际完成之前就返回,而不会阻塞等待。常见的异步IO实现方式有3种:

  • POSIX AIO。这种方案的实现可在linux手册中看到:https://www.man7.org/linux/man-pages/man7/aio.7.html。这套方案在glibc中提供,通过使用pthread库创建用户态多线程的方式实现异步IO的接口,最后调用的IO syscall仍然是内核的同步接口。在手册中写道,“这套机制局限性很大,最显著的一点就是使用多线程实现异步IO的效率和可扩展性太差”。因此,手册中还提到了下一种异步IO方案。
  • LINUX AIO。这套方案是linux内核提供的接口,名字也叫AIO。这套方案是io_uring出现前Linux内核支持异步IO的唯一接口,由于glibc中已经提供了同样名为AIO的机制,因此没有为其提供封装,需要通过专门的libaio库进行调用。io_uring的开发者,也是Linux内核IO部分的主要maintainer,Jens Axboe在Efficient IO with io_uring这篇介绍io_uring的文章中分析了Linux AIO的缺陷:AIO最大的局限性是只支持direct IO,这不仅导致程序无法使用cache,更让程序无法使用普通的malloc/new等方式分配的内存用于IO,而必须使用mmap方式直接分配4K对齐的页。在绝大部分情况下,这显然是无法实现的,因为即使开发者在自己的代码中管理内存分配,却无法控制引用的各类库和包的内存管理方式,因此在大部分情况下AIO没有实用价值。此外AIO在某些场景下仍然会退化为阻塞调用,其接口传递的参数过大因此效率也不高。因此linux社区一直认为“这套AIO机制不够成熟,还不能将glibc中的POSIX AIO接口改用这套机制实现”。
  • 事实上,当前主流的异步IO模型,大多是程序直接根据程序逻辑实现的。例如异步日志,就是在主要的工作线程中将需要写的日志内容写入一个专门的buffer队列中,再由另一个专门的日志写入线程将队列内容写入文件。也有一些库能够实现这样的功能,例如log4j2。本质上这种实现与POSIX AIO的原理是一致的,但通过结合程序的实现功能需求和配置,就避免了通用的AIO机制带来的过多线程创建管理开销。

IO_URING

现有的内核AIO机制经过多年的开发,仍然存在大量的问题。内核维护者认为已有机制问题太多,使用场景太少,早期设计时没有考虑到异步IO的实际需求,在其基础上开发新特性已经没有意义。因此决定针对AIO机制开发中发现的一系列问题,从头开发一套新的异步IO机制,称作io_uring。根据笔者的理解,uring这个朴实的名字就是user和ring的意思,这两者也是io_uring机制的核心。io_uring的高效性就是建立在使用用户态(user-space)可访问的无锁环形队列(ring)的基础之上的。开发者Jens Axboe在Efficient IO with io_uring中详细介绍了io_uring的设计思想和使用方式,在Linux异步IO新时代:io_uring中对其内容做了概括,【译】高性能异步 IO — io_uring(Effecient IO with io_uring)是其中文翻译(建议参考原文阅读)。

设计目标

作者在文章中明确列出了io_uring的设计目标:

  • 易用。从笔者的角度来看,与现有的IO接口相比,io_uring相关syscall接口其实并不算易用,甚至理解起来也不算容易。作者自己也说,这些设计目标之间是有冲突的,特性丰富、高效还可伸缩的接口必然是很难用的。为了解决这个问题,作者为io_uring开发了一个配套的库liburing。既然支持全部需求的接口对于一般开发者来说使用难度过高,那就对其中最常用的部分再封装一层,提供一个更简单易用的接口。使用liburing无法使用io_uring全部的功能,特别是一些为高性能目标设计的功能,但能够使用一套风格与io_uring类似,但简单的多的接口来使用io_uring的基本功能,这对于大部分开发者来说也已经足够了。对于需要高级特性的开发者来说,也可以在使用liburing的基础上调用io_uring syscall接口来获取自己需要的特性,因为这类开发者一般也不会同时需要所有高级特性,而只是使用其中很小的一部分。这个设计方式值得我们学习,如何解决功能强大和接口易用之间的矛盾,“加一个中间层”永远是一个有效的思路。
  • 可扩展。这里的可扩展指的是io_uring操作的IO设备类型是可扩展的,io_uring实现的异步接口不止能够用于块设备,也能够支持socket网络IO等非块设备,后续还可能支持更多fd类型,从接口语义方面没有限制。
  • 特性丰富。这一项是针对linux aio机制的局限性而来的。aio机制由于支持的特性不多,使用限制却很多,因此使用场景非常有限。作者的目标是在所有需要异步IO的场景都能够使用io_uring接口,并且不需要程序本身做架构级别的调整。
  • 高效。这里的高效主要体现在两个方面:一是每次调用io_uring系统调用接口的开销要小,这里主要是和aio相比减少了每次调度传递的参数大小;二是减少io_uring系统调用的次数,这是io_uring最重要的设计之一,通过一次系统调用提交多个IO请求的方式,io_uring可以大幅减少系统调用次数,这在spectre/meltdown修复导致系统调用开销显著上涨的背景下更加重要。
  • 可伸缩。作者对这个目标的解释是允许使用者获得尽可能好的IO性能。一般对伸缩性的理解是能够使用更多的CPU核心或其他资源来提升性能,但从io_uring的接口来看,似乎是不能支持多核并发操作的,而且并发调用异步IO接口也没有什么意义。笔者理解这里的伸缩性可能是指可以通过不同的使用模式来获得更好的IO性能。io_uring在用户态和内核态都支持轮询(polling)模式,可以在不调用syscall的情况下直接处理IO请求和结果,代价是在IO请求不需要全部CPU资源时会浪费一些计算资源。这就相当于是使用更多的CPU资源来获取最佳的IO性能,也可以理解为是一种伸缩性了。

原理与结构

io_uring的原理是让用户态进程与内核通过一个共享内存的无锁环形队列进行高效交互。相关的技术原理其实与DPDK/SPDK中的rte_ring以及virtio的vring是差不多的,只是这些技术不涉及用户态和内核态的共享内存。高性能网络IO框架netmap与io_uring技术原理更加接近,都是通过共享内存和无锁队列技术实现用户态和内核态高效交互。但上述的这些技术都是在特定场景或设备上应用的,io_uring第一次将这类技术应用到了通用的系统调用上。

共享内存

为了最大程度的减少系统调用过程中的参数内存拷贝,io_uring采用了将内核态地址空间映射到用户态的方式。通过在用户态对io_uring fd进行mmap,可以获得io_uring相关的两个内核队列(IO请求和IO完成事件)的用户态地址。用户态程序可以直接操作这两个队列来向内核发送IO请求,接收内核完成IO的事件通知。IO请求和完成事件不需要通过系统调用传递,也就完全避免了copy_to_user/copy_from_user的开销。

无锁环形队列

io_uring使用了单生产者单消费者的无锁队列来实现用户态程序与内核对共享内存的高效并发访问,生产者只修改队尾指针,消费者只修改队头指针,不会互相阻塞。对于IO请求队列来说,用户态程序是生产者内核是消费者,完成事件队列则相反。需要注意的是由于队列是单生产者单消费者的,因此如果用户态程序需要并发访问队列,需要自己保证一致性(锁/CAS)。

内存屏障与保序

使用共享内存和无锁队列最需要注意的就是保证内存操作的顺序和一致性。这部分内容在Efficient IO with io_uring中做了简单的介绍。简单的说就是要保证两点:

1. 修改队列状态时,必须保证对队列元素的写入已经完成。这时需要调用write barrier来保证之前的写入已经完成。在x86架构上这一点其实是针对编译器优化的,防止编译器将修改队列状态的指令放到队列元素写入完成之前。

2. 读取队列状态时,需要获取到最新写入和修改的值。这时需要调用read barrier来保证之前的写入都能被读取到,这主要是对缓存一致性的刷新。

内存屏障在不同架构的CPU上有不同的实现和效果,要正确使用需要对CPU和编译器有一定了解。在liburing中已经屏蔽了这些细节,因此建议一般情况下使用liburing来实现对队列的操作。

轮询模式

io_uring提供了io_uring_enter这个系统调用接口,用于通知内核IO请求的产生以及等待内核完成请求。但这种方式仍然需要反复调用系统调用,进行上下文切换,并在内核中唤醒异步处理逻辑去处理请求。显然这种方式会产生额外的开销,而且受限于系统调用速率,无法发挥IO设备的极限性能。为了在追求极致IO性能的场景下获得最高性能,io_uring还支持了轮询模式。轮询模式在DPDK/SPDK中有广泛应用,这种模式下会有一个线程循环访问队列,一旦发现新的请求和事件就立即处理。

对于用户态程序来说,轮询只需要一个线程持续访问请求完成事件队列即可。但这个层次的轮询只是轮询了io_uring的队列,但内核从IO设备获取完成情况仍然是基于设备通知的。通过在初始化时设置IORING_SETUP_IOPOLL标志,可以将io_uring配置为IO设备轮询模式。在这种模式下,调用io_uring_enter获取完成事件时,内核会使用轮询方式不断检查IO设备是否已经完成请求,而非等待设备通知。通过这种方式,能够尽可能快的获取设备IO完成情况,开始后续的IO操作。

同时,在内核中还支持了一个内核IO模式,通过IORING_SETUP_SQPOLL标志设置。在这个模式下,io_uring会启动一个内核线程,循环访问和处理请求队列。内核线程与用户态线程不同,不能在没有工作时无条件的无限循环等待,因此当内核线程持续运行一段时间没有发现IO请求时,就会进入睡眠。这段时间默认为1秒,可以通过参数sq_thread_idle设置。如果内核线程进入睡眠,会通过IO请求队列的flag字段IORING_SQ_NEED_WAKEUP通知用户态程序,用户态程序需要在有新的IO请求时通过带IORING_ENTER_SQ_WAKEUP标识的io_uring_enter调用来唤醒内核线程继续工作。

需要注意的是,如果IORING_SETUP_IOPOLL和IORING_SETUP_SQPOLL同时设置,内核线程会同时对io_uring的队列和设备驱动队列做轮询。在这种情况下,用户态程序又不需要调用io_uring_enter来触发内核的设备轮询了,只需要在用户态轮询完成事件队列即可,这样就可以做到对请求队列、完成事件队列、设备驱动队列全部使用轮询模式,达到最优的IO性能。当然,这种模式会产生更多的CPU开销。

调用接口

上文提到,io_uring使用两个队列来传递IO请求和完成情况,这两个队列中的元素结构如下:

// Submission Queue Entry
struct io_uring_sqe {
   __u8 opcode;        //请求类型,例如IORING_OP_READV
   __u8 flags;         //
   __u16 ioprio;       //优先级,和ioprio_set系统调用的作用类似
   __s32 fd;           //需要操作的文件fd
   __u64 off;          //文件偏移位置
   __u64 addr;         //读写数据地址,如果是readv/writev请求则是iovec数组地址
   __u32 len;          //读写数据长度,如果是readv/writev请求则是iovec数组长度
   union {
     __kernel_rwf_t rw_flags;    //请求相关的选项,其含义与对应的blocking syscall相同,例如preadv2
     __u32 fsync_flags;
     __u16 poll_events;
     __u32 sync_range_flags;
     __u32 msg_flags;   
   };
   __u64 user_data;    //使用者任意指定的字段,在复制到对应的cqe中,一般用于标识cqe与sqe的对应关系。
   union {
     __u16 buf_index;
     __u64 __pad2[3];
   };
};

// Completion Queue Event
struct io_uring_cqe {
   __u64 user_data;    //来自对应的sqe中的user_data字段
   __s32 res;          //请求处理结果,和普通IO操作的返回值差不多。一般成功时返回字节数,处理失败时返回-errno。
   __u32 flags;        //暂未使用
};

io_uring有3个系统调用接口,分别是:

  • io_uring_setup,创建io_uring。
  • io_uring_enter,通知内核有IO请求待处理,并根据参数等待请求完成。
  • io_uring_register,注册fd和buffer为常用对象,避免内核反复操作。

io_uring_setup

原型为:

int io_uring_setup(unsigned entries, struct io_uring_params *params);

这个函数返回一个io_uring的fd,后续通过这个fd来操作io_uring。entries是创建出的io_uring中包含的sqe(请求)数量,必须是1-4096间的2的幂级数。io_uring_params是一个与内核的交互参数,用户态调用者在其中指定需要的参数,内核也在其中反馈实际创建的情况,其定义和解释如下:

struct io_uring_params {
    __u32 sq_entries;                    /* IO请求sqe数量,内核输出 */
    __u32 cq_entries;                    /* IO完成事件cqe数量,内核输出 */
    __u32 flags;                         /* io_uring运行模式和配置,调用者输入 */
    __u32 sq_thread_cpu;
    __u32 sq_thread_idle;
    __u32 resv[5];                       /* 预留空间,用于对其cacheline,同时为将来扩展留下空间 */
    struct io_sqring_offsets sq_off;     /* sqe队列的偏移地址 */
    struct io_cqring_offsets cq_off;     /* cqe队列的偏移地址 */
};

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;
};

这里需要关注的是io_sqring_offsets和io_cqring_offsets。这是内核分配的ring结构中需要用户态操作部分的相对偏移,用户态程序需要使用mmap将ring结构的内存映射到用户态来供后续交互:

struct app_sq_ring {
    unsigned *head;
    unsigned *tail;
    unsigned *ring_mask;
    unsigned *ring_entries;
    unsigned *flags;
    unsigned *dropped;
    unsigned *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);
    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;
}

这里的一个疑问是为什么要让用户态程序再调用一次mmap,而不是在内核中就将地址映射好直接返回,这显然让接口的使用复杂度上升了很多。笔者认为这么做唯一的意义在于尽量保留了用户态程序对地址空间的控制力,可能在一些特殊场景下程序会需要特定的地址空间用于特殊用途,内核直接映射可能引入难以发现的问题。为了解决接口易用性的问题,liburing中封装了io_uring_queue_init接口,对于没有上述特殊需求的程序,直接使用这个接口即可。

io_uring_enter

原型为:

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

在程序向sqring,即请求队列中插入了IO请求后,需要通知内核开始处理,这时就需要调用io_uring_enter。参数中的fd是io_uring的fd,to_submit是提交的IO请求数。

min_complete可以用来阻塞等待内核完成特定数量的请求,前提是flags中设置IORING_ENTER_GETEVENTS。这个功能可以单独调用来等待内核处理完成。需要注意的是由于采用共享内存队列的方式来同步请求完成情况,因此程序也可以不使用这个接口而是直接判断cqring的状态来获取IO完成情况并处理cqring中的完成事件。

io_uring_register

原型为:

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

这个syscall用于支持一些高级的优化用法,主要有两种模式,opcode分别为:

  • IORING_REGISTER_FILES。内核异步处理sqe请求时,需要保证fd不会在处理过程中被关闭,因此需要在开始处理前增加fd引用计数,结束后再减少。而调用这个接口后就可以避免这种反复的引用计数操作。在调用后指定的文件fd的引用计数会增加,后续提交请求时只要在sqe的flags中指定IOSQE_FIXED_FILE就不会再修改引用计数。如果不再需要操作这个fd,可以用IORING_UNREGISTER_FILES这个opcode解除注册。
  • IORING_REGISTER_BUFFERS。在使用O_DIRECT模式时,内核需要先映射用户态的页面,处理完后再解除映射,这也是一种重复开销。使用这个opcode后,就可以把指定的buffer页面固定映射到内核中,处理请求时就不需要反复映射、解除映射。

liburing

上文中提到,由于io_uring要实现强大的功能和最优的效率,因此其接口和使用方式会比较复杂。但对于大部分不需要极致IO性能的场景和开发者来说,只使用io_uring的基本功能就能获得大部分的性能收益。当只需要基本功能时,io_uring的复杂接口中很大一部分是不会使用的,同时一部分初始化操作也是基本不变的。因此,io_uring的作者又开发了liburing来简化一般场景下io_uring的使用。使用liburing后,io_uring初始化时的大部分参数都不再需要填写,也不需要自己再做内存映射,内存屏障和队列管理等复杂易错的逻辑也都封装在liburing提供的简单接口中,大幅降低了使用难度。

io_uring的创建与销毁

在liburing中封装了io_uring的创建与销毁操作接口,几乎不需要指定任何参数即可完成创建:

struct io_uring ring;
io_uring_queue_init(ENTRIES, &ring, 0);

io_uring_queue_exit(&ring);

提交请求与处理完成事件

struct io_uring_sqe sqe;
struct io_uring_cqe cqe;
/* get an sqe and fill in a READV operation */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, fd, &iovec, 1, offset);
/* tell the kernel we have an sqe ready for consumption */
io_uring_submit(&ring);
/* wait for the sqe to complete */
io_uring_wait_cqe(&ring, &cqe);
/* read and process cqe event */
app_handle_cqe(cqe);
io_uring_cqe_seen(&ring, cqe);

上面这段代码是io_uring作者提供的样例,其中体现了几个关键操作接口:

  • io_uring_get_sqe(&ring),从请求队列中获取一个空闲的请求结构。
  • io_uring_prep_readv(sqe, fd, &iovec, 1, offset),构造一个readv读请求。
  • io_uring_submit(&ring),将请求提交给内核。
  • io_uring_wait_cqe(&ring, &cqe),等待请求完成。这是一个阻塞操作,还有一个非阻塞版本:io_uring_peek_cqe。
  • io_uring_cqe_seen(&ring, cqe),通知内核已经完成对完成事件的处理。

通过这些接口,开发者已经能够很容易的写出异步IO的代码。

具体实现

上文已经介绍了io_uring的接口和基本使用方法,以及io_uring的技术原理。这里再讨论一下io_uring在内核中的实现。笔者认为,以前没有在内核中支持这类接口是有原因的,让用户态程序与内核共享内存、并发访问和修改同一数据结构,是一种危险行为。如果不能妥善的控制共享内存的操作权限,完整覆盖各种并发操作、特别是用户态异常操作的内核处理逻辑,就很可能让用户态程序能够通过这个机制破坏内核状态,造成严重错误和漏洞。因此,与技术原理相比,io_uring的具体实现更加重要,我们需要了解io_uring是如何保障其可靠性和安全性的。

风险分析与解决思路

在分析代码之前,我们先分析一下io_uring机制存在的风险以及可能的规避方式。

使用共享内存的ring进行同步,逻辑上并不复杂,但共享内存的结构如果损坏,内核在处理这个结构时就可能出现错误,造成内核崩溃等问题。结构损坏可能有几种:

1. ring的head/tail指针错误。这会导致内核处理没有设置过的请求sqe。由于sqe是内核预分配的内存,因此这个操作不会造成内核访问非法内存地址。如果sqe中的参数是非法的,内核会直接生成错误事件,如果参数合法,则会按参数执行IO。这种错误的影响和使用了错误的syscall参数是差不多的,对内核不会产生严重影响。

2. ring的mask/entries/flags被错误修改。由于对ring内的entry数组访问以及一些特性实现和这些元素是强相关的,如果这些内存被错误修改,就可能造成内核严重异常。但这些元素在创建io_uring时就已经确定了,内核可以单独为每个io_uring单独保存一份用于实际处理逻辑,而不使用共享内存中的部分。这样就可以避免用户态修改对内核产生影响。

3. sqe的内容在内核处理时被修改。这个情况比较复杂,理论上有可能做到对sqe的元素都只做单次访问,从而避免元素值变化造成的逻辑异常。但由于io_uring支持的操作类型和特性很丰富,可能很难实现这一点。因此更可行的方式是在处理sqe前直接复制一份,之后只访问复制的sqe参数。

内核实现

io_uring的实现在fs/io_uring.c中,这是一个长达9300行的c文件。。。

SYSCALL_DEFINE2(io_uring_setup),即sys_io_uring_setup是io_uring_setup系统调用的实现函数。可以看到,在创建io_uring时,内核创建了两个数据结构io_ring_ctx和io_rings。其中io_rings就是和用户态共享内存的ring结构,包含了ring的head、tail指针,mask、entries以及sq_off、cq_off中的其他字段。而io_ring_ctx中则包含了那些不能被修改的参数。和我们在上一节分析的一样,内核将不应被修改的部分参数在内核专用的io_ring_ctx结构中保存了一份,内核实际访问的是io_ring_ctx中的元素,而不是和用户态共享的io_rings中的元素。这就避免了共享的关键参数被修改可能导致的问题。事实上,io_ring_ctx中还包含了sq_ring的head和cq_ring的tail这两个应该由内核控制的ring参数,内核只会根据io_ring_ctx中的这两个值来访问ring,并将修改后的值写回到io_rings中。因此用户态修改这些值不会对内核逻辑产生影响,而是会被内核不断修正。

SYSCALL_DEFINE6(io_uring_enter),即sys_io_uring_enter是io_uring_enter系统调用的实现函数。在主要实现逻辑io_submit_sqes函数中,会为每一个待处理的sqe分配一个io_kiocb结构,并将sqe中的请求参数逐个复制到io_kiocb中,后续真正实现io请求时访问的是io_kiocb,因此sqe被修改也不会对io处理逻辑产生影响。

值得注意的是,io_uring_enter中并不一定是完全异步化的处理sqe中的IO请求,只有当sqe中设置了REQ_F_FORCE_ASYNC时才会立即将对应的io_kiocb加入异步队列,否则会尝试启动文件的异步IO操作,只有当文件不支持异步模式时才会将其加入异步队列。这部分逻辑理解的不是很透彻,需要进一步阅读代码。

小结

本文分析了io_uring的原理、接口、用法和实现。io_uring提供了复杂而强大的异步IO接口,同时又实现了liburing来屏蔽高级特性带来的接口复杂性和使用难度。io_uring展示了一种新的可能性,即通过共享内存的方式与内核进行高性能的交互,而避免大量的syscall带来的性能开销和限制。未来,这种模式也可以被用于加速其他实时性要求不高的系统调用。

;