[译] Efficient IO with io_uring

nxdong October 05, 2022 [linux] #linux #io_uring

Efficient IO with io_uring 的翻译

本文翻译自:Efficient IO with io_uring

使用io_uring 进行高效IO

本文的目的是对linux新的io接口 io_uring 做一个介绍,并且与现有方案做比较。

我们将会介绍它存在的原因,内部工作原理,以及用户可见的接口。

本文不会介绍具体命令的细节或其他类似的信息,因为他可以在相关命令的man 手册中查询。

但是,我们将提供io_uring 的入门指导并且期望读者可以通过本文对它的工作方式有更深入的理解。

也就是说,本文与man 手册的内容会有一部分重叠。针对io_uring 做介绍而绕开man 手册。

1.0 介绍

linux 中,有很多基于文件的输入输出的方法.

最老最基础的方式就是 read(2)write(2) 系统调用.

这俩接口随后有了允许传递偏移参数的增强版本 pread(2)pwrite(2) .

接着我们也得到了前面函数基于向量的版本 preadv(2)pwritev(2).

因为这些也不是很够用,Linux 也有 preadv2(2)pwritev2(2) 的系统调用, 它们进一步增加了可修改的flag来扩展api.

先不考虑这些接口各种各样的不同点,它们都有一个共同的特征,就是他们都是同步接口.

这意味着系统调用会在读或者写数据完成后返回.

在一些使用场景中, 这不是最优解, 我们需要一个异步接口.

POSIX 已经有 aio_read(3)aio_write(3) 来满足这个需求,但是他们的实现没什么灵性并且性能不好.

Linux 已经有一个叫aio 的原生异步IO接口了, 不幸的是,它有以下几个限制:

过去的几年里,人们为了解决刚才提到的第一个限制做了很多尝试(我也在2010年做了尝试), 但是没一个成功的.

同时满足低于10usec(微秒) 和极高IOPS的设备出现后,这个接口在效率方面就有一点捉襟见肘. 对这些设备来说,慢并且非确定性的提交延迟是个很大的问题,因为这就是你能从单个核心可以压榨性能的缺失部分.

总的来说,由于刚才提到的种种限制,说原生linux aio 接口的使用场景不多是合理的. 它在一些细分的应用中伴随着这些问题(长期没发现的bug等等)降级使用.

此外,事实上一般的应用没有使用aio意味着Linux仍然缺少一个满足需要的功能的接口. 显而易见,应用或者库依然需要实现一个私有的IO工作线程池来获取一个类似的异步IO功能,尤其是那些由内核处理的更高效的操作.

2.0 改善现状

一开始的努力集中在提高aio的接口,而且在这条路被放弃之前取得了相当大的进展.

这有一些一开始选择这个方向的原因:

现有的aio接口由三个主要的系统调用构成: 一个设置aio上下文的io_setup(2)系统调用. 一个提交IO的io_submit(2),以及一个获取或者等待IO完成的io_getevents(2).

因为多个这样的系统调用需要改变表现行为,所以我们需要新的系统调用来传递这些信息. 这就导致同样的代码有多个入口,并且别的地方也有他们的片段.

最终的结果不是很好,因为代码复杂性提高,可维护性变差,而且它最终也只修复了上一节提到的一个不足.除此之外,它实际上还让他们中的某个更糟,因为现在API甚至更复杂而难以理解和使用.

虽然为了从头开始而放弃一系列工作总是很难的,但很明显我们需要一些全新的东西. 这将使我们能够在所有方面实现目标。我们需要它具有高性能和可伸缩性,同时还要使它易于使用,并具有现有接口所缺乏的功能。

3.0 新接口的设计目标

虽然从头开始的决定很难下,但是它确实可以让我们有充分的接受一些新东西的自由.

粗略的按照重要性排序,主要的设计目标如下:

上面的一些目标可能看起来相互独立.一个高效并且高扩展性的接口往往难以使用,更重要的是,难以正确使用.丰富的功能和高效率也很难实现.

尽管如此,这就是我们设立的目标.

4.0 进入 io_uring

尽管设计目标有个优先级,但是一开始仍然围绕着效率来设计。 效率并不是一个可以在事后考虑的问题,它必须在一开始设计的的时候就考虑-你不能在接口固定之后再从里面挤出一点效率来。

我知道我不想要在提交事件和完成事件时的内存副本和这方面的指引内存。在上一个基于aio的设计中,aio为了处理IO的两端而必须进行的多个不同的拷贝明显损害了效率和可伸缩性。

由于不想要拷贝,很明显内核和应用程序必须优雅地共享定义IO本身的结构和完成事件。

如果你想这样共享数据,自然而然的就需要一个控制内存在内核与应用之间配合的扩展。

一旦你做了这样的决定,显然它们之间需要需要做某种方式的同步管理。

一个应用程序不能在不使用系统调用的情况下与内核共享锁,而系统调用会降低与内核的通信效率。

这与追求效率的目标相悖。

一个可以满足我们需求的数据结构会是个单生产者单消费者的环型缓冲区。有了共享的环形缓冲区,我们就不需要在应用程序和内核之间共享锁,而是巧妙地使用内存顺序和屏障。

有两个跟异步接口有关的基本操作:提交请求的操作 和 该请求完成时的事件。

对于提交IO来说,应用是生产者,内核是消费者。完成时刚好相反,内核是生产者,应用是消费者。

因此,我们需要一对儿环缓冲区来提供应用与内核之间的高效通信。

这对儿环就是新的io_uring 接口的核心。

它们理所当然的叫做提交队列submission queue (SQ) 和完成队列completion queue (CQ)。它们构成了新接口的基础。

4.1 数据结构

交流的背景已经说完了,现在是时候看看我们将要用到的描述请求和完成事件的数据结构的定义了。

完成端的结构比较直观。他需要携带跟操作结果有关的信息,跟一个可以把他跟原始请求联系起来的方式。

io_uring 选择了下面的布局:


struct io_uring_cqe {
   __u64 user_data;
   __s32 res;
   __u32 flags;
};

// 以下代码来自linux repo的include/uapi/linux/io_uring.h
/*
 * 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;

  /*
   * If the ring is initialized with IORING_SETUP_CQE32, then this field
   * contains 16-bytes of padding, doubling the size of the CQE.
   */
  __u64 big_cqe[];
};

io_uring这个名字现在已经认识了,_ cqe的后缀是Completion Queue Event 的缩写。对于本文的其余部分,通常称为cqe。

cqe包含一个user_data 字段。这个字段在初始化提交请求的时候被携带,并且可以包含应用程序识别所述请求所需的任何信息。一个常见的用法是将其作为其原始请求的指针。内核不会触碰这个字段,它只是从提交到完成事件的透传。

res 保存了这个请求的结果。可以将其视为系统调用的返回值。对于通常的 read/write 操作,这类似read(2)write(2) 的返回值。如果操作成功,它将是传递成功的字节数。如果发生错误,它将是个负数的错误码。比如,如果I/O 发生错误,res 将保存-EIO

flags 字段可以携带跟这个操作有关的元信息。目前为止,这个字段还没用。

请求类型的定义要复杂一些。它不仅需要比完成事件描述更多的信息,也是io_uring可以扩展到未来的请求类型的设计目标。

我们得出的结果如下:

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];
   };
};
// 以下代码来自linux repo的include/uapi/linux/io_uring.h
/*
 * IO submission data structure (Submission Queue Entry)
 */
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;
    struct {
      __u32  cmd_op;
      __u32  __pad1;
    };
  };
  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;
    __u32    rename_flags;
    __u32    unlink_flags;
    __u32    hardlink_flags;
    __u32    xattr_flags;
  };
  __u64  user_data;  /* data to be passed back at completion time */
  /* 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;
  union {
    __s32  splice_fd_in;
    __u32  file_index;
  };
  union {
    struct {
      __u64  addr3;
      __u64  __pad2[1];
    };
    /*
     * If the ring is initialized with IORING_SETUP_SQE128, then
     * this field is used for 80 bytes of arbitrary command data
     */
    __u8  cmd[0];
  };
};

跟完成事件同理,提交侧的结构叫做 Submission Queue Entry ,缩写为sqe

opcode 字段描述这个特定请求的操作码(operation code or op-code)。一个可能的操作码是IORING_OP_READV ,是向量读。

flags 字段是通用命令类型的可变标志。我们稍后会在高级用法示例一节介绍。

ioprio 是这个请求的优先级。对于一般的读写操作,他大概跟ioprio_set(2) 系统调用的定义差不多。

fd 是跟这个请求有关的文件描述符,off 是操作发生时的偏移。

addr 保存了操作执行IO时的地址,如果op-code 描述了数据转移操作的话。如果是某个向量化的读写操作,他得是个指向 iovec 数组结构的指针,跟preadv(2) 一样。 对于非向量化的IO转移,addr 必须是个直接地址。

len 是非向量化IO传输的字节数,或者向量化IO的向量数。

接下来是一些跟特定op-code 相关标志联合体。

举个例子,对于刚提到的向量化读操作(IORING_OP_READV),flags 跟preadv2(2)中描述的一样。user_data 是一般的透传数据,内核不会触碰它,它只是当这个请求的完成事件触发的时候简单的复制到完成事件(cqe)。

buf_index 会在高级用例一节介绍。

最后,在结构体的末尾有一些padding。他是为了保证sqe在内存中以64字节的大小对齐,并且以后可以为新用例请求提供更多的描述信息。 一些可以想到的用法之一是一些命令的key/value 存储,还有就是端到端的写数据校验保护。

4.2 通信通道

数据结构已经介绍完了,让我们来看看环工作的细节。

虽然直觉上提交侧跟完成侧是对称的,但是他们的索引是不同的。

跟上一节一样,我们从简单一点的完成环开始介绍。

完成队列事件们被组织进一个数组,这个数组的内存可以同时被内核和应用见到和修改。

不过,因为完成队列事件是内核产生的,所以只有内核实际上操作完成队列的入口。

这个通信又一个环状缓冲区管理。

当一个新事件由内核放进完成队列环的时候,这个环更新对应的尾部标记。

当应用消耗一个实体的时候,环更新它的头部标记。

因此,只要环的头尾标记不同,应用就知道至少有一个可以消费的事件。

环的计数器是个自然流动的32位整数,它依赖 natural wrapping 当数量超过环容量的时候。

这个方法的一个好处就是不必管理一个提高管理复杂度的ring is full 的标记就可以使用环的满大小。

因此,环的大小必须是2的幂。

为了找到事件的索引,应用需要把当前的尾索引与环的大小做掩码。

它一般看起来是这样的:

unsigned head;
head = cqring→head;
read_barrier();
if (head != cqring→tail) {
     struct io_uring_cqe *cqe;
     unsigned index;
     index = head & (cqring→mask);
     cqe = &cqring→cqes[index];
     /* process completed cqe here */
      ...
     /* we've now consumed this entry */
     head++;
}
cqring→head = head;
write_barrier();

ring→cqes[] 就是 io_uring_cqe 结构的共享数组。 在下一节,我们会介绍这个共享内存(以及io_uring实例本身)的设置与管理的细节,以及这里的读写屏障做了什么其妙的事情。

在提交侧,角色翻转了。应用更新尾部,而内核消费实体并且更新头部。

一个重要的不同点是CQ环是个可以直接索引的cqe共享数组,而提交侧在他们之间有个间接数组。

因此,提交侧的环缓冲是一个指向保存了指向sqes的数组的索引。

这一开始看起来有点奇怪跟令人费解,但是是有原因的。这样可以允许一些应用他们灵活的把请求单元嵌入到内部的数据结构中(如果想的话),同时也保留了在一个操作中提交多个sqe的能力。这反过来又使上述应用程序更容易转换为io_uring接口。

添加一个供内核使用的sqe基本上是从内核中获取cqe的相反操作。

典型实例如下:

struct io_uring_sqe *sqe;
unsigned tail, index;
tail = sqring->tail;
index = tail & (*sqring->ring_mask);
sqe = &sqring->sqes[index];
/* this call fills in the sqe entries for this IO */
init_io(sqe);
/* fill the sqe index into the SQ ring array */
sqring->array[index] = index;
tail++;
write_barrier();
sqring->tail = tail;
write_barrier();

跟完成队列环那边一样,这里的读写屏障也会在稍后解释。上面是一个简单的示例,它假设sq环当前是空的,或者至少有一个空位。

一旦内核消耗一个sqe,应用就可以重新利用这个sqe对象。即使在内核还没有完全完成给定sqe的情况下也是如此。

如果内核在实体被使用后确实需要访问它,那么它将制作一个稳定的副本。

为什么会发生这种情况并不一定重要,但它对应用程序有重要的副作用。

通常,应用程序会要求一个给定大小的环,并且假设这个大小直接对应于应用程序在内核中可以挂起的请求数。然而,由于sqe生存期仅为实际提交的生存期,因此应用程序可能会产生比SQ环大小所指示的更高的挂起请求数。 应用必须小心发生这样的情况,这样可能导致完成队列环的溢出。

默认情况下,CQ环是SQ环的两倍大小。这样让应用程序在管理方面有一定的灵活性,但它并不能完全代替管理程序。如果应用程序确实违反了此限制,它将作为CQ环中的溢出条件进行跟踪。稍后介绍更多细节

完成事件可能以任何顺序返回,请求提交的顺序跟与他对应的完成事件没有顺序关系。

SQ环跟CQ环都独立运行,没有依赖。

然而,完成事件总是对应于给定的提交请求。因此,完成事件总是与特定的提交请求相关联。

5.0 io_uring 接口

跟aio一样,io_uring 也有一些定义它所需操作的系统调用。

第一个就是设置io_uring实例的系统调用:

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

应用必须提供它所需要的实体数目,跟一些有关的参数。

entries 表示将与此iouring实例关联的sqe数。它必须是2的幂,范围为(1..4096]。

params 结构体既被内核读,也被内核写。它的定义如下:

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 由内核传出,告诉应用这个环支持多少个sqe实体。

cqe实体同理,cq_entries 字段告诉应用CQ环的大小。

这个结构的其余部分的讨论推迟到高级用例部分,但sq_offcq_off字段除外,因为它们是通过io_uring设置基本通信所必需的。

如果io_uring_setup(2) 调用成功,内核会返回一个执行这个io_uring实例的文件描述符。

这就是sql_off和cq_off结构派上用场的地方。考虑到内核和应用程序共享sqe和cqe结构,应用程序需要一种方法来访问这个内存。这个是通过mmap(2)映射进应用程序的内存空间的。

应用使用sq_off 字段来获取各种的环成员的偏移。

io_sqring_offsets 结构体看起来是这样的:

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_uring的文件描述符和跟SQ环绑定的内存偏移调用mmap(2)

io_uring 的API 定义了mmap的offsets给应用程序用:

#define IORING_OFF_SQ_RING 0ULL
#define IORING_OFF_CQ_RING 0x8000000ULL
#define IORING_OFF_SQES 0x10000000ULL

IORING_OFF_SQ_RING 用于映射 SQ环到应用程序的内存空间。

IORING_OFF_CQ_RING 用于CQ 环同上。

IORING_OFF_SQES 用于映射 sqe 数组.

对于完成队列环(CQ ring),cqe的数组是完成队列环(CQ ring)的一部分。

因为提交队列环(SQ ring)是sqe数组的索引,所以应用程序必须单独映射sqe数组。

应用程序将定义自己的结构来保存这些偏移。下面是个可能出现的例子:

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

完成队列环(CQ ring)的映射方式跟这个类似,使用 IORING_OFF_CQ_RING跟io_cqring_offsets的cq_off 字段定义的偏移。

最后,使用IORING_OFF_SQES offset 映射 sqe数组。

因为这些代码可以在不同的应用程序中复用,所以liburing 库提供了一些辅助程序来简化设置过程跟内存映射。

详细信息可以参考liburing

这些都做完之后,就可以通过io_uring 的实例进行通信了。

应用也需要一个告诉内核它已经生产了让内核消费的请求的方法。

它通过另一个系统调用完成:

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(2)返回。

to_submit告诉内核最多有这么多的sqe可以消费和提交。

min_complete要求内核等待该数量的请求完成。

单个调用可以同时用于提交和等待完成意味着应用程序可以通过单个系统调用提交和等待请求完成。

flags 是可以改变调用行为的标志。最重要的一个是:

#define IORING_ENTER_GETEVENTS (1U << 0)

如果flags 设置了IORING_ENTER_GETEVENTS ,内核就开始等min_complete 个事件可用。精明的读者可能想知道,如果我们有了min_complete,那么我们需要这个标志是为了什么。有些情况下,区别很重要,稍后将介绍。现在,如果你希望等待事件完成,IORING_ENTER_GETEVENTS 必须设置。

这基本上涵盖了io_uring的基本API。io_uring_setup(2) 会创建一个指定大小的 io_uring实例。在设置之后,应用可以开始往sqes里放数据,并且通过io_uring_enter(2) 提交他们. 可以用同一个调用等待完成,也可以稍后单独完成。除非应用程序希望等待完成,否则它也可以只检查cq环尾是否有可用事件。

内核将直接修改CQ环尾,因此应用程序可以消费完成事件,而无需使用IORING_ENTER_GETEVENTS 调用io_uring_enter(2)

有关可用命令的类型和使用方法,请参阅io_uring_enter(2)手册页。

5.1 SQE 顺序

通常sqe是独立使用的,这意味着其中一个的执行不会影响环中后续sqe项的执行或排序。

这使操作具有充分的灵活性,并使它们能够并行执行和完成,以获得最大的效率和性能。

可能需要排序的一个场景是数据完整性写入。

一个常见的例子是一系列写操作,后面是fsync/fdatasync。只要我们允许以任何顺序完成写入,我们只关心在所有写入完成后执行的数据同步。

应用程序通常将其转换为写入并等待操作,然后在底层存储确认所有写入后发出同步。

io_uring支持清空提交端队列,直到所有以前的完成操作完成。这允许应用程序对上述同步操作进行排队,并在得知所有之前的命令完成之前不会启动。

这可以通过在sqe标志字段中设置IOSQE_IO_DRAIN来实现。注意,这会暂停整个提交队列。

根据io_uring用于特定应用的方式不同,这可能会引入比预期更大的管道气泡。

如果这些类型的排出操作经常发生,应用程序可以仅使用独立的io_uring上下文进行完整性写入,以更好地同时执行不相关的命令。

5.2 链接的SQES

虽然IOSQE_IO_DRAIN包含完整的管道屏障,但io_uring还支持更细粒度的sqe序列控制。

链接的sqe提供了一种方法来描述更大提交环中的一系列sqe之间的依赖关系,其中每个sqe执行都取决于前一个sqe的成功完成。

此类用例的示例可能包括一系列必须按顺序执行的写入操作,或者可能是一种类似于复制的操作,从一个文件读取后再写入另一个文件,共享两个sqe的缓冲区。

要使用此功能,应用程序必须在sqe标志字段中设置 IOSQE_IO_LINK。如果设置,则在前一个sqe成功完成之前,不会启动下一个sq。如果前一个sqe未完全完成,则链将断开,链接的sqe将被取消,错误代码为-ECANCELED

在这种情况下,完全完成是指请求完全成功完成。任何错误或可能的短读/写都将中止链,请求必须完全完成。

只要在flags字段中设置了IOSQE_IO_LINK,链接的sqe链就会继续

因此,链被定义为从第一个设置了IOSQE_IO_LINK的sqe开始,到第一个没有设置它的后续sqe结束。

支持任意长的链。

链独立于提交环中的其他sqe执行。链是独立的执行单元,多个链可以并行执行和完成。这包括不属于任何链的sqe。

5.3 超时命令

虽然io_uring支持的大多数命令都是处理数据的,可以直接使用读/写操作,也可以间接使用fsync样式的命令,但timeout命令略有不同。

IORING_OP_TIMEOUT不是处理数据,而是帮助处理完成环上的等待。

timeout命令支持两种不同的触发器类型,可以在单个命令中一起使用。

一种触发器类型是典型的超时,调用方传入一个具有非零秒/纳秒值的timespec结构体(变体)。

为了保持32位与64位应用程序和内核空间之间的兼容性,使用的类型必须为以下格式:

struct __kernel_timespec {
   int64_t tv_sec;
   long long tv_nsec;
};

在某些时候,用户空间应该有一个符合此描述的struct timespec64可用。在此之前,必须使用上述类型。如果需要超时,则sqe的addr字段必须指向此类型的结构。超时命令完成事件将在指定的时间后发生。

第二个触发器类型是完成计数。如果启用,应将完成计数值填充到sqe的偏移字段中。

自超时命令入队后,一旦发生指定的完成次数,超时完成事件将触发。

可以在单个超时命令中指定两个触发器事件。如果两个超时触发器同时排队,那么要触发的第一个条件将生成超时完成事件。当发布超时完成事件时,所有等待完成的等待者都会被唤醒,无论他们要求的完成量是否已满足。

6.0 内存排序

通过io_uring实例进行安全高效通信的一个重要方面是正确使用内存排序原语。

详细介绍各种体系结构的内存顺序超出了本文的范围。

如果您喜欢使用通过liburing库公开的简化的io_uring API,那么您可以放心地忽略此部分,而直接跳到liburing 库的对应部分。

如果您对使用原始接口感兴趣,了解这一部分很重要。

为了保持简单,我们将其简化为两个简单的内存排序操作。为了简短起见,解释有些简化。

read_barrier() :在执行后续内存读取之前,请确保以前的写入可见。

write_barrier() : 在前一个写入成功之后执行此次写入。

根据所讨论的架构不同,其中一个或者两个都可能空操作。

使用iouring时,这无关紧要。

重要的是,我们在某些架构上需要它们,因此应用程序编写者应该了解如何做到这一点

需要write_barrier()来确保写入顺序。

假设一个应用程序想要填写一个sqe,并通知内核可以使用它。

这是一个分为两个阶段的过程-首先填充各个sqe成员,并将sqe索引放入SQ环数组中,然后更新SQ环尾以向内核显示有新条目可用。

在没有任何隐含的顺序的情况下,处理器以其认为最理想的任何顺序重新排序这些写操作是完全合法的。

让我们看一下下面的示例,每个数字表示一个内存操作:

1: sqe->opcode = IORING_OP_READV;
2: sqe->fd = fd;
3: sqe->off = 0;
4: sqe->addr = &iovec;
5: sqe->len = 1;
6: sqe->user_data = some_value;
7: sqring->tail = sqring->tail + 1;

无法保证写入7(使sqe对内核可见)作为序列中的最后一次写入发生。至关重要的是,在写7之前的所有写操作在写7之后都是可见的,否则内核可能会看到写了一半的sqe。

从应用程序的角度来看,在通知内核新的sqe之前,您需要一个写屏障来确保写操作的正确顺序

由于实际sqe存储的顺序无关紧要,只要它们在尾部写入之前可见,我们就可以在写入6之后和写入7之前使用排序原语。

因此,序列如下所示:

1: sqe->opcode = IORING_OP_READV;
2: sqe->fd = fd;
3: sqe->off = 0;
4: sqe->addr = &iovec;
5: sqe->len = 1;
6: sqe->user_data = some_value;
 write_barrier(); /* ensure previous writes are seen before tail write */
7: sqring->tail = sqring->tail + 1;
 write_barrier(); /* ensure tail write is seen */

在读取SQ环尾之前,内核将包含一个read_barrier(),以确保应用程序的尾写是可见的。

从CQ环端来看,由于消费者/生产者角色是相反的,因此应用程序只需在读取CQ环尾之前发出read_barrier(),以确保它看到内核进行的任何写入。

虽然内存排序类型被精简为两种特定类型,但体系结构实现当然会因代码运行在哪台机器上而有所不同。

即使应用程序直接使用io_uring接口(而不是liburing接口),它仍然需要特定于体系结构的屏障。

liburing库提供了这些定义,建议应用程序使用这些定义。

通过对内存顺序的基本解释,以及liburing提供的帮助程序来管理内存顺序,返回并阅读前面引用了read_barrier()write_barrier()的示例。

如果以前对他们没有感觉,希望现在有了。

7.0 liburing 库

了解了io_uring的内部细节后,您现在可以放心了,因为有一种更简单的方法可以完成以上大部分工作。

这个库有两个目的:

后者确保应用程序根本不必担心内存障碍,也不用自行进行任何环缓冲区管理。

这使得API更易于使用和理解,事实上,不需要了解它如何工作的所有细节。

如果我们只专注于提供基于liburing的示例,那么本文可能会短得多,但至少了解一下从应用程序中提取最大性能的内部工作原理通常是有益的。

此外,liburing目前专注于减少无聊代码,并为标准用法提供基本的帮助函数。

一些更高级的功能还没有通过liburing提供。

但是这并不意味着不能混用这两者。

在实现底层,它们都在相同的结构上操作。通常鼓励应用程序使用liburing中的setup helper,即使它们使用的是原始接口。

7.1 使用liburing设置io_uring

让我们从一个例子开始。liburing没有手动调用io_uring_setup(2)并随后执行三个必要区域的mmap(2),而是提供了以下基本助手来完成相同的任务:

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

io_uring结构保存SQ和CQ环的信息,io_uring_queue_init(3)调用为您处理所有设置逻辑。

对于这个特定的示例,我们为flags参数传入0。

应用程序使用iouring实例结束后,只需调用:

io_uring_queue_exit(&ring);

来把它销毁。

与应用程序分配的其他资源类似,应用程序退出后,内核会自动回收这些资源。对于应用程序可能创建的io_uring实例也是如此。

7.2 使用liburing进行提交和完成操作

一个非常基本的用法是提交请求,然后等待它完成。

使用liburing辅助函数,这看起来像这样:

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_wait_cqe(3)将返回我们刚刚提交的sqe的完成事件,前提是没有其他在运行中的sqe。如果有,完成事件可能是另一个sqe。

如果应用程序只想查看完成情况,而不想等待事件可用,调用io_uring_peek_cqe(3)

对于这两种用例,一旦完成此完成事件,应用程序必须调用io_uring_cqe_seen(3)。重复调用io_uring_peek_cqe(3)io_uriing_wait_cqe(3)将继续返回相同的事件。

为了避免内核在应用程序处理完成之前就可能覆盖现有的完成事件,这种拆分是必要的。

io_uring_cqe_seen(3)增加了CQ环头,使内核能够在同一插槽中填充新事件。

有各种用于填充sqe的辅助函数,io_uring_prep_readv(3)只是一个示例。

我鼓励申请者尽可能地利用liburing提供的辅助。

liburing库仍处于初级阶段,正在不断开发以扩展支持的特性和可用的辅助程序。

8.0 高级用法与功能

上述示例和用例适用于各种类型的IO,无论是基于O_DIRECT文件的IO、缓冲IO、套接字IO等等。

无需特别注意确保它们的正确操作或异步性质。然而,io_uring确实提供了应用程序需要选择的许多功能。以下小节将介绍其中的大部分功能。

8.1 固定文件和缓冲区

每次将文件描述符填入sqe并提交给内核时,内核必须获取对所述文件的引用。

IO完成后,再删除文件引用。

由于此文件引用的原子特性,对于高IOPS工作负载来说,这可能是一个明显的减速点。

为了缓解这个问题,io_uring提供了一种为io_urig实例提供预注册文件集的方法。

这是通过第三个系统调用完成的:

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

fd是io_uring实例环文件描述符。

opcode指正在进行的注册类型。要注册文件集,必须使用IORING_REGISTER_FILES

arg必须指向应用程序已经打开的文件描述符数组。

nrargs必须包含数组的大小。

一旦io_uring_register(2)成功完成文件集注册,应用程序就可以使用这些文件,方法是将数组中文件描述符的索引(而不是实际的文件描述符)分配给sqe→fd字段,并通过在sqe的sqe→flags中设置IOSQE_FIXED_FILE将其标记为文件集。

即使设置了文件集,应用也可以通过给sqe→flags 不设置IOSQE_FIXED_FILE 来使用非注册的文件描述符。

当io_uring实例被删除时,注册的文件集会自动释放,也可以通过在io_uring_register(2)opcode中设置IORING_UNREGISTER_FILES手动完成。

还可以注册一组固定IO缓冲区。

当使用O_DIRECT时,内核必须将应用程序页面映射到内核中,然后才能对其进行IO操作,并在完成IO操作后取消映射这些相同的页面。

这可能是一项成本高昂的操作。

如果应用程序重用IO缓冲区,那么可以只进行一次映射和取消映射,而不是每次IO操作。

要为IO注册一组固定的缓冲区,必须使用IORING_REGISTER_BUFFERSop_code调用IO_URING_REGISTER(2)

args必须包含一个struct iovec数组,该数组已填入每个iovec的地址和长度。

nr_args 必须包含iovec数组的大小。

成功注册缓冲区后,应用程序可以使用IORING_OP_READ_FIXEDIORING-OP_WRITE_FIXED执行这些缓冲区之间的IO操作。

使用这些固定op_code时,sqe→addr必须包含位于其中一个缓冲区内的地址,以及sqe→len必须包含请求的长度(以字节为单位)。

应用程序可能会注册大于任何给定IO操作的缓冲区,固定读/写只是单个固定缓冲区的一部分是完全合理的。

8.2 轮询IO(polled io)

对于追求最低延迟的应用程序,io_uring为文件提供了轮询io支持。

在这种情况下,轮询是指在不依赖硬件中断来发出完成事件信号的情况下执行IO。

当IO被轮询时,应用程序将反复询问硬件驱动程序提交的IO请求的状态。

这与非轮询IO不同,在非轮询情况下,应用程序通常会进入睡眠状态,等待硬件中断作为其唤醒源。

对于低延迟设备,轮询可以显著提高性能。

对于IOPS非常高的应用程序也是如此,高中断率使非轮询负载的开销更大。

轮询在延迟或总体IOPS速率方面有意义时的边界应根据应用程序、IO设备和机器的性能而有所不同。

要使用IO轮询,必须在传递给io_uring_setip(2)系统调用或liburing库的 io_uring_queue_init(3)辅助函数的标志中设置IORING_SETUP_IOPOLL

当使用轮询时,应用程序将无法再检查CQ环尾是否有可用的完成时间,因为不会自动触发异步硬件侧完成事件。相反,应用程序必须通过调用io_uring_enter(2)来主动查找和获取这些事件,方法是设置IORING_ENTER_GETEVENTS,并将min_complete设置为所需要的事件数。

设置IORING_ENTER_GETEVENTS的同时把min_complete 设为0 也是允许的。对于轮询IO,这要求内核只检查驱动程序端的完成事件,而不是不断循环。

只有注册了IORING_SETUP_IOPOLL 的io_uring 实例才能使操作在轮询完成事件上生效。

它包含这些读写命令:IORING_OP_READV, IORING_OP_WRITEV,IORING_OP_READ_FIXED,IORING_OP_WRITE_FIXED.

不能注册为轮询的io_uring实例上发出不可轮询的op-code。这样做将导致从io_uring_enter(2)返回-EINVAL

因为内核无法知道对设置了IORING_ENTER_GETEVENTSio_uring_enter(2)的调用是否可以安全休眠去等待事件,还是应该主动轮询事件。

8.3 内核侧轮询

尽管io_uring通常更有效,允许通过更少的系统调用发出和完成更多请求,但仍有一些情况下,我们可以通过进一步减少执行io所需的系统调用数来提高效率。

这个功能就是内核侧轮询。启用该选项后,应用程序不再需要调用io_uring_enter(2)来提交io。

当应用程序更新SQ环并填写新的sqe时,内核端将自动注意到新条目并提交它们。

这是通过特定于该io_uring的内核线程完成的。

要使用此功能,io_uring实例必须使用开启了IORING_SETUP_SQPOLLio_uring_paramsflags注册,或着使用io_uring_queue_init(3)

此外,如果应用程序希望将此线程指定特定的CPU,也可以通过标记IORING_SETUP_SQ_AFF,并将io_uring_paramssq_thread_cpu设置为所需的CPU。

注意,使用IORING_SETUP_SQPOLL设置io_uring实例是一种特权操作。如果用户没有合适的权限,io_uring_queue_init(3) 会失败并返回-EPERM

为了避免在io_uring实例处于非活动状态时浪费太多CPU,内核端线程在空闲一段时间后将自动进入休眠状态。

发生这种情况时,内核线程将在SQ环标志成员中设置IORING_SQ_NEED_WAKEUP。设置该选项后,应用程序无法依赖内核自动查找新条目,然后必须使用IORING_ENTER_SQ_WAKEUP设置调用io_uring_enter(2)

应用程序端逻辑通常如下所示:

/* fills in new sqe entries */
 add_more_io();
/*
* need to call io_uring_enter() to make the kernel notice the new IO
* if polled and the thread is now sleeping.
*/
if ((*sqring→flags) & IORING_SQ_NEED_WAKEUP)
   io_uring_enter(ring_fd, to_submit, to_wait, IORING_ENTER_SQ_WAKEUP);

只要应用程序继续驱动IO,就永远不会设置IORING_SQ_NEED_WAKEUP,我们可以有效地执行IO,而无需执行单个系统调用。

然而,在应用程序中始终保持与上面类似的逻辑很重要,以防线程进入睡眠状态。

可以通过设置io_uring_paramssq_thread_idle成员来配置空闲前的特定宽限期。该值以毫秒为单位。如果未设置此成员,则内核默认为将线程置于睡眠状态之前的1秒空闲时间。

对于“正常”IRQ(中断)驱动的IO,可以通过直接查看应用程序中的CQ环来找到完成事件。

如果io_uring实例是用IORING_SETUP_IOPOLL设置的,那么内核线程也将负责获取完成事件。

因此,对于这两种情况,除非应用程序希望等待IO发生,否则它可以简单地查看CQ环以查找完成事件。

9.0 性能

最终,io_uring达到了为其设定的设计目标。

我们在内核和应用程序之间有一个非常有效的传递机制,以两个不同的环的形式。

虽然原始接口需要在应用程序中正确使用,但主要的复杂性实际上是需要显式内存排序原语。

在发布和处理事件的提交和完成方面,这些都属于一些细节,并且在应用程序中通常遵循相同的模式。

随着liburing接口的不断成熟,我预计大多数应用程序都会很乐意使用它提供的API。

虽然本说明的目的不是详细介绍io_uring的性能和可伸缩性,但本节将简要介绍在这方面取得的一些成果。

更多资料参考 https://lore.kernel.org/linux-block/20190116175003.17880-1-axboe@kernel.dk/

注意,由于工程方面的进一步改进,这些结果有点过时。

例如,io_uring的每核峰值性能现在约为1700K 4k IOPS,在我的测试中不是1620K。

注意,这些值没有太多绝对意义,它们在衡量相对改进方面最有用。

既然应用程序和内核之间的通信机制不再是瓶颈,我们将通过使用io_uring继续寻找更低的延迟和更高的峰值性能。

9.1 原始性能(RAW PERFORMANCE)

有很多方法可以查看接口的原始性能。大多数测试也会涉及内核的其他部分。

上面一节中的数字就是一个这样的例子,我们通过随机读取块设备或文件来衡量性能。

追求最高性能,io_uring通过轮询帮助我们达到1.7M 4k IOPS。

aio以608K的成绩达到了比这低得多的性能。

这里的比较不太公平,因为aio不支持轮询IO。

如果我们禁用轮询,io_uring能够为相同(大概)的测试用例驱动大约1.2M IOPS。aio的局限性在这一点上很明显,对于相同的工作负载,io_uring的IOPS是IOPS的两倍。

iouring还支持no-op命令,该命令主要用于测试接口的原始吞吐量。

根据所使用的系统,可以观察到每秒12M条信息(我的笔记本电脑)到每秒20M条消息(用于其他引用结果的测试框架)。

根据具体的测试用例不同,实际结果会有很大变化,并且主要受必须执行的系统调用数量的限制。

原始接口在其他方面的内存限制的,由于提交和完成消息在内存中都很小并且是线性的,因此每秒获得的消息速率可能非常高。

9.2 异步缓冲性能( BUFFERED ASYNC PERFORMANCE )

我之前提到过内核内缓冲的aio实现可能比在用户空间中实现效率更高。这主要与缓存数据与未缓存数据有关。在执行缓冲IO时,应用程序通常严重依赖内核页面缓存来获得良好的性能。

用户空间应用程序无法知道它接下来要请求的数据是否已缓存。它可以查询这些信息,但这需要更多的系统调用,而且得到的信息很快就会变化(几毫秒之后)。

因此,具有IO线程池的应用程序总是必须将请求弹到异步上下文,从而导致至少两个上下文切换。如果请求的数据已经在页面缓存中,这会导致性能急剧下降。

iouring处理这种情况就像处理其他可能会阻塞应用程序的资源一样。

更重要的是,对于不会阻塞的操作,数据是内联的。

这使得io_uring对已经存在在页面缓存中的io与常规同步接口一样高效。

一旦IO提交调用返回,应用程序将在CQ环中已经有一个完成事件等待它,并且数据将已经被复制。

10.0 深入阅读

考虑到这是一个还没有大量使用的全新接口。

在撰写本文时,带有这个接口的内核处于-rc阶段。

即使对接口有了相当完整的描述,研究使用io_uring的程序也有助于充分理解如何最好地使用它

一个例子是fio附带的io_uring引擎。除了注册文件集之外,它还可以使用所描述的所有高级功能。

另一个例子是t/io_uring。c示例基准测试应用程序,也随fio一起提供。它只是对文件或设备进行随机读取,具有可配置的设置,可以探索高级用例的整个功能集。

liburing库有一套完整的系统调用接口手册页,值得一读。它还附带了一些测试程序,包括针对开发过程中发现的问题的单元测试,以及技术演示。

LWN还写了一篇关于早期开发的优秀文章。请注意,在撰写本文之后,对io_uring进行了一些更改,因此我建议在两者之间存在差异的情况下参考本文。

11.0 参考

[1] https://lore.kernel.org/linux-block/20190116175003.17880-1-axboe@kernel.dk/

[2] git://git.kernel.dk/fio

[3] git://git.kernel.dk/liburing

[4] https://lwn.net/Articles/776703/

Version: 0.4, 2019-10-15