【译】高性能异步 IO —— io_uring (Effecient IO with io_uring)

写完 Go 与异步 IO - io_uring 的思考 后还是决定将学习 io_uring 时粗略翻译的两篇文章发出来
尽量可以帮助到对 io_uring 感兴趣的朋友
由于英文水平有限,可能有些地方有一些问题,遇到语句不通顺的地方(记得联系我),可以结合原文对照查看
英文原文:
Efficient IO with io_uring
What’s new with io_uring
本文并没有将两篇文章内容进行整合,可以跳转至 What’s new with io_uring


推荐阅读:
liburing
Lord of the io_uring

本文的目的是介绍最新的 Linux 异步 IO 接口 io_uring ,并将其与现有产品进行比较。
我们将探讨其存在的原因,它的内部工作原理以及开放给用户的接口。
本文不会讨论特定命令之类的细节,这些都可以查看相关 man 文档或者 lord of the io_uring,我们会介绍 io_uring 及其工作原理,希望读者可以更深刻的理解。
本文和 man 之间会有一些重叠,如果不提供这些细节就无法提供对 io_uring 的描述

介绍

Linux 中有很多方法可以执行基于文件的 IO,最古老和基本的是 readwrite 系统调用。后来又添加了允许传入偏移量的 preadpwrite,然后又引入了他们的矢量版本 preadvpwritev,但是这依然无法满足,所以进一步扩展,出现了允许修饰符标志的系统调用 preadv2pwritev2
这些系统调用存在一些差异,但是他们相同的特征是都是同步接口。这意味着只有当数据准备就绪或者写入完成了,这些系统调用才会返回。在一些使用场景中需要使用异步接口。POSIX 提供了 aio_readaio_write 来满足异步需求,但是,他们的实现通常都乏善可陈,性能也很差。

linux 有一个原生的异步 IO 接口,简称 AIO,但是它收到了许多的限制:

  • 最大的限制是它仅支持 O_DIRECT(无缓冲)访问,由于O_DIRECT(绕过缓冲和大小/对齐限制)的限制,导致原生 AIO 接口在大多数情况下都不可行,对于正常(缓冲) IO 来说,接口依然以同步的方式运行
  • 即使满足了 IO 异步的所有限制,有时它依然不是异步的。有很多方式会导致 IO 提交时被阻塞,如果需要元数据来执行 IO,那么提交就会被阻塞等待。对于存储设备,有固定数量的可用请求槽,如果这些插槽都在使用中,提交将阻塞等待一个可用的。这些不确定性意味着依赖于异步提交的程序依然被迫阻塞
  • API 不好,每个 IO 提交需要复制 64 + 8 个字节,每次 IO 完成复制 32 个字节。而对于一些不需要内存拷贝的 IO 来说,依然会带来 104 字节的内存拷贝。根据 IO 的大小,带来的损耗可能会很明显。公开的完成事件缓冲区导致 IO 完成变慢,并且在应用中很难使用。而且 IO 总是需要两次系统调用才能完成提交和等待完成,而且在内核修复了 Intel 漏洞(spectre/meltdown)后,系统调用带来的代价更大了

多年来,人们为了消除上述第一个限制做出了各种努力,但是依然没有成功。就效率而言,支持 10 毫秒以下延迟和非常高 IOPS 的设备的出现,AIO接口开始显得有些力不从心 了,对于这些设备来说,缓慢和不确定的提交延迟是一个很大的问题,因为无法从单个核心中获取足够的性能。最重要的是由于上述的限制,可以肯定的说原生的 Linux AIO 无法在很多场景下使用。他被丢到了应用的角落,同样也伴随着所有随之而来的问题(长期未发现的 bug 等等)

此外,普通应用不使用 AIO 也意味着 Linux 仍然没有提供给他们想要的功能。绝对没有理由让应用或者库去使用私有的 IO 线程池来模仿异步 IO, 特别是当这些事情可以在内核中更加高效的完成

改善现状

最初的努力集中在改进 AIO 接口上,并且进行了相当长的时间,选择这个最初的方向有很多个原因

  • 如果可以扩展和改进现有的接口,肯定要比提供一个新的接口更好,采用新的接口需要花费时间,并且审核和批准新接口是一项漫长而艰巨的任务
  • 一般来说,工作量会少很多,作为开发人员,总是希望用最少的代价完成最多的工作,扩展现有接口在已有的测试基础架构上会带来很多优势

现有的 AIO 接口主要有三个系统调用

由于需要对多个系统调用的行为进行修改,所以我们需要添加新的系统调用来传递这些信息。这样就为相同的代码创建了多个入口点,并在其他地方新建快捷接口。最终在代码的复杂性和可维护性上来说结果并不好,而且只是修复了原有 AIO 的一个比较突出问题而已。最重要的事,他实际上使另外的问题变得更糟了,因为现有 API 会变的更加复杂,难以理解和使用

放弃一项工作,然后从头开始总是很难的,不过很明显我们需要一个全新的东西,能够提供我们所需要的东西,需要他具有高性能和可扩展性,而且方便使用,并具有现有接口所没有的特性

新接口设计目标

尽管从头开始设计不是容易的事情,但确实使我们在创作是有了充分的艺术自由来创造新的东西
按照重要性从高到低的顺序,主要的设计如下:

  • 易于使用,难以滥用(Easy to use, hard to misuse)。任何用户/应用可见的接口都以此为目标,接口应该易于理解和直观实用
  • 可扩展的(Extandable)。虽然我的背景更多的与存储相关,但我希望该接口不仅仅用于面向块的 IO。这意味着 io_uring 很快会添加网络和非块存储接口​。
  • 功能丰富(Feature rich)。Linux AIO 满足应用需求的子集,我不想再创建一个接口仅覆盖某些应用的需求,或者需要应用自己来一次次创建相同的接口功能(例如 IO 线程池)
  • 效率(Efficiency)。尽管存储 IO 大部分依然是基于块的,因此大小至少为 512b 或者 4 kb,但在这些大小上的效率对于某些应用仍然是至关重要的。此外,某些请求甚至没有携带数据(有效荷载),对于每次请求的开销而言,新接口必须高效,这一点很重要
  • 可扩展性(Scalability)。尽管效率和低延迟非常重要,但是在峰值端提供最佳的性能也很关键。特别是对于存储,我们一直努力提供可扩展的基础架构,一个新的接口能够将这种可扩展性公开给应用

上述某些目标似乎是互斥的。高效和可扩展性的接口通常很难使用,并且更重要的是,很难被正确使用
丰富又高效的功能也很难实现,不过这些就是我们设定的目标

io_uring

尽管设计目标定的很高,但是最初的设计还是围绕效率进行的
效率不能是以后才想要去做的事情,它必须从一开始就进行设计,一旦接口被固定,将无法再把一些东西剔除掉

无论是操作请求的提交还是完成,我都不想有任何的内存副本和间接的内存访问
之前基于 AIO 的设计时,效率和可扩展性都受到了明显的危害

协调应用与内核的共享内存

由于不需要副本,因此内核和程序必须优雅的共享定义 IO 自身的结构和完成的事件。
如果打算采用这种共享方式,那么拥有共享数据的协调也应该驻留在程序和内核之间的共享内存中

一旦实现了这种方式,就必须以某种方式来管理两者的同步
一个程序在不调用系统调用的情况下无法和内核共享锁定,并且系统调用肯定会降低与内核通信的速度。这与实现效率的目标不符

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

与异步接口相关的基本操作有两个:提交请求的操作请求完成后的完成事件

  • 对于提交 IO 请求,应用是生产者,内核是消费者
  • 对于完成请求而言,情况恰恰相反,内核会生成完成事件,而应用会使用完成事件

因此我们需要一对环来提供程序和内核之间的有效的通信通道
这对环(ring) 便是新接口 io_uring 的核心,并构成了新接口的基础,他们被适当的命名为 提交队列(SQ,SubmissionQueue)完成队列 (CQ, CompletionQueue)

数据结构 (Data Structures)

有了适当的通信基础后,就该着手定义用于描述请求完成事件的数据结构了
完成事件比较直观,他需要携带与操作结果相关的信息,以及以某种方式将该完成链接回请求的来源

对于 io_uring 使用以下结构:

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

_cqe 的后缀代表着这个结构是完成队列事件(Completion Queue Event),本文其余部分统称为 cqe

  • user_data 字段来自提交的请求 并且可以包含程序识别该请求所需的任何信息
    一种常见的使用场景是使其成为指向请求的指针
    内核不会修改这个字段,只是简单的直接从提交(submission)传递给 完成事件(completion event)
  • res 保留了请求的结果,可以认为他就像系统调用返回的值,对于正常的读写操作,会类似于 readwrite 的返回值,对于成功的操作,他会包含传输的字节数,如果出现异常,他会包含一个负的错误值
    例如,发生了 IO error,res 将会包含 -EIO
  • flags 可以携带此操作相关的元数据,现在这个字段还未使用

请求(requst) 的类型定义会更加复杂, 不仅需要描述比 完成事件 更多的信息,他还需要考虑到 io_uring 的未来对请求类型的扩展

struct io_uring_seq {
    __u8 opcode;
    __u8 flags;
    __u16 ioprio;
    __u32 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];
    };
};

该结构已经更新,可以查看 Submission Queue Entry

类似 完成事件,提交侧的结构被称为提交队列项/条目(Submission Queue Entry) 简称 sqe

  • opcode 字段描述了本次请求的操作码,表示当前的请求的操作,例如一个矢量读取的操作码 IORING_OP_READV
  • flags 包含了跨命令类型的常见修饰符标志
  • ioprio 代表请求的优先级,对于正常的读写,这个字段遵循了 ioprio_set 系统调用的定义
  • fd 与请求相关联的描述符,并且 off 保存了操作的偏移量
  • addr 有很多意义:
    如果 操作码描述的是传输数据的操作,addr 包含了该操作执行相应IO 的地址
    如果操作是某种向量读写,addr 就是 preadv系统调用 所使用的指向 iovec 数组结构的指针
    对于非向量 IO 传输,addr必须直接包含地址
  • len 表示非向量 IO 传输的字节数或者对于向量 IO 传输,表示 addr 指向的向量个数 (iovecs 数组的长度)
  • 下边的 union 是针对特定操作码的 flags 集合,例如对于矢量读取(IORING_OP_READV), 这些描述符跟随 preadv2 系统调用
  • user_data 可以适用于所有操作码,并且不会被内核修改。当该请求的完成事件发布时(请求完成),复制到完成事件 cqe
  • __pad2[3] 的目的是确保 seq 在内存中以 64 个字节大小来对齐,也用与将来需要包含更多数据来描述请求的场景

通讯通道(Communication Channel)

通过数据结构的描述,让我们来详细介绍一下环(rings)的工作原理吧
提交(submission)完成(completion) 虽然是对称的,但是两者的使用却有些不同

先从结构简单的 completion ring 开始
cqes 被组织成一个数组,该数组的内存可以被内核和应用看到和修改
但是由于 cqes 是由内核生成的,因此实际上只有内核在修改 cqes 的条目(entries).

通信由环形缓冲区(ring buffer)管理。
每当内核将新事件发布到 CQ 环,他就会更新与之相关的环尾(ring tail), 当程序消费一个条目时,就会更新环头(ring head)
因此,如果环头和环尾不同,应用就知道有一个或多个事件可以消费

环行计数器(ring counter) 是自由流动的 32 位整数,并且当完成事件数超过环的容量时会自然计算环项索引
这种方法的优势之一是我们可以利用环的完整大小,而无需另外管理环已满的标志,因此要求环必须是 2 的幂等

为了找到完成事件在数组中的索引,应用必须使用环的大小掩码来标记当前的尾部索引

unsigned head;
head = cqring->head;

read_barrier();

// 头不等于尾,环未满
if (head != cqring->tail) {
    struct io_uring_cqe *cqe;
    unsigned index;

    // 使用掩码来计算出正确的索引位置
    index = head & (cqring->mask);
    cqe = &cqing->cqes[index];

    head++;
}
cqring->had = head;
write_barrier();

ring->cqes[] 是 io_uring_cqe 结构的共享数组,后续我们深入探讨如何设置和管理此共享内存以及神奇的读写屏障调用

对于提交请求,扮演的角色正好相反。应用添加条目到环尾,内核从环头消耗条目
有一个重要的区别是,尽管 CQ 环直接索引共享数组 cqes,但是提交请求在他们直接有一个间接数组,因此提交操作的环形缓冲区是此数组的索引,而该数组又包含 sqes 的索引

刚开始可能看起来很奇怪而且令人困惑,但是这背后是有原因的
一些应用可能将请求单元嵌到他们的内部数据结构中,这样可以使他们可以灵活的在一次操作中即可提交多个 sqes,继而使程序更容易转换成 io_uring 的接口

增加一个供内核消费的 sqe 差不多和从内核中获取 cqe 是相反的操作

struct io_uring_seq *sqe;
unsigned tail, index;

tail = sqring->tail;
index = tail & (*sqring->ring_mask);
sqe = &sqring->specs[index];

init_io(sqe);

sqring->array[index] = index;
tail++

write_barrier();
sqring->tail = tail;
write_barrier();

CQ ring 一样,后续会说明读写屏障
上边简化的例子,假设 SQ 环当前为空或者至少有空间可以再添加一个

一旦一个 sqe 被内核消费了,应用就可以自由复用该 sqe 条目,即使相应的 sqe 尚未完全完成
如果内核在消费该条目后确实需要再次访问它,它会一个稳定的副本。为什么会发生这种情况并不重要,重要的是会对应用产生一些副作用。
通常,应用会要求指定大小的环,并且会假设改大小会直接对应于应用在内核中有多少个等待的请求。但是,由于 sqe 生命周期仅是其实际提交,所以应用可能使用比SQ环 尺寸更高的等待请求数量

应用应该尽量不要这么做,因为可能会有 CQ 环溢出的风险。
默认情况下,CQ 环的大小是 SQ 环的两倍。这允许应用程序在管理这个方面时具有一定的灵活性,但并不能完全消除这样做的需要

现在内核提供了保证 CQ 环 中事件不丢失的能力 CQ 环大小

完成事件可以随机到达,在请求提交和相应的完成之间没有排序。SQ 环和 CQ 环相互独立运行
但是完成事件始终对应给定的提交请求,因此完成时间始终与相应的提交请求相关联

io_uring 接口

AIO 一样,io_uring 具有相应的多个系统调用,这些系统调用定义了它们的操作

io_uring_setup

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

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

应用程序必须提供条目的数量entries给 io_uring 实例,并且提供相关的参数 params

  • entries 表示与 io_uring 相关联的 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 条目。
  • cq_entries 会告诉应用程序,CQ 环的大小
  • sq_offcq_off 是通过 io_uring 和内核建立基本通信所必须的

成功调用 io_uring_setup 后,内核会返回一个指向 io_uring 实例的文件描述符
这时 sq_offcq_off 便会排上用场。
鉴于 sqecqe 结构是内核和应用程序共享的,应用程序需要一种访问这个内存的方法

这会通过mmap的方式映射到应用的内存空间,应用程序使用 sq_off 来找出各个环成员的偏移量

struct io_sqing_offsets {
    __u32 head;    // 环头的偏移量
    __u32 tail;    // 环尾的偏移量
    __u32 ring_mask;      // 环 mask 值
    __u32 ring_entries;   // 环的 entries 值
    __u32 flags;   // 环 flags
    __u32 dropped; // 没有提交的 sqe 数量
    __u32 array;  // sqe 索引数组
    __u32 resv1;
    __u32 resv2;
}

为了访问这块内存,应用程序必须使用 io_uring 文件描述符SQ ring 关联的内存偏移量来调用 mmap
io_uring API 定义了下列 mmap 偏移量,以供应用使用

#define IORING_OFF_SQ_RING OULL
#define IORING_OFF_CQ_RING 0x8000000ULL
#define IORING_OFF_SQES 0x10000000ULL
  • IORING_OFF_SQ_RING 用于将 SQ 环映射到应用程序空间
  • IORING_OFF_CQ_RING 用于 CQ 环
  • IORING_OFF_SQES 映射 sqes 数组

对于 CQ 环cqes 数组CQ 环的一部分,而 SQ 环记录了 sqes 数组的索引值,所以 sqes 数组必须应用单独映射进来

应用程序可以自己定义指向这些变量的结构,比如

// 自己定义的结构
struct app_sq_ring {
    unsinged *head;
    unsigned *tail;
    unsigend *ring_mask;
    unsigned *ring_entries;
    unsigned *flags;
    unsinged *dropped;
    unsigned *array;
};

一个典型的安装案例:

struct app_sq_ring app_setup_sq_ring(int ring_fd, struct io_uring_param *p){
    struct app_sq_ring sqing;
    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);

    sqring->head = ptr + p->sq_off.head;
    sqring->tail = ptr + p->sq_off.tail;
    sqring->ring_mask = ptr + p->sq_off.ring_mask;
    sqring->ring_entries = ptr + p->sq_off.ring_entries;
    sqring->flags = ptr + p->sq_off.flags;
    sqring->dropped = ptr + p->sq_off.dropped;
    sqring->array = ptr + p->sq_off.array;

    return sqring
}

使用 IORING_OFF_CQ_RINGcq_offset 可以同样映射 CQ 环
最后使用 IORING_OFF_SQES 映射 sqe 数组

由于这些是可以在应用之间复用的代码,所以 liburing 库提供了一组帮助函数完成安装和内存映射
完成以上操作后,应用程序就可以通过 io_uring 实例进行通信了

io_uring_enter

应用程序需要一种方式来通知内核,有请求需要处理

int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t sig);
  • fdio_ursing_setup 返回 io_uring的文件描述符
  • to_submit 告诉内核准备消费的提交的sqe数量
  • min_complete 要求内核等待请求完成数量
    只需要一次调用就完成了提交和等待完成,也就是说应用程序可以通过一个系统调用来提交并等待指定数量的请求完成
  • flags包含用来修改调用行为的标识符

最重要的一个 flags: IORING_ENTER_GETEVENTS

#define IORING_ENTER_GETEVENTS (1U << 0)

如果 flags 设置了 IORING_ENTER_GETEVENTS, 那么内核将主动等待 min_completes完成事件
一般如果设置 min_completes,并且需要等待完成时,那么就也要设置 IORING_ENTER_GETEVENTS

需要注意,并发情况下,如果有多个 IORING_ENTER_GETEVENTS 在等待,同时满足等待数量条件的话,只有一个会返回,其他的会继续等待
超时请求除外,超时请求会唤醒所有的 IORING_ENTER_GETEVENTS 等待者

基本上覆盖了 io_uring 的基本 API
io_uring_setup 用来根据提供的 size 来创建 io_uring 实例
然后,应用程序可以向 sqes 填充并使用 io_uring_enter 来提交请求,同时也可以等待完成,或者稍后单独调用 io_uring_enter 来等待完成

除非应用想要等待有请求完成,否则也可以去检查 CQ 环是否有可用的事件
内核可用改变 CQ 环尾,因此应用程序可以直接使用环中的完成事件,而不需要调用 io_uring_enter + IORING_ENTER_GETEVENTS

可以通过 io_uring_enter man page 或者 lord of the io_uring 来查看可用的命令和如何使用

sqe 排序

通常 sqe 会被单独的异步执行,也就是说一个sqe的相关执行不会影响环中后续sqe的执行和顺序
这使操作具有充分的灵活性,并且能够并行的执行和完成以获得最大效率和性能

可能需要排序的场景是为了保证数据完整写入。
一个常见的例子是一系列写操作,然后是调用 fsync/fdatasync
只要我们允许写入以任意顺序完成,我们只关心在所有写入完成后执行数据同步

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

io_uring 支持将 sqeflags 字段设置 IOSQE_IO_DRAIN,然后将 sqe 提交到 io_uring 中,可以保证在所有之前的请求完成前是不会开始执行的

需要注意,IOSQE_IO_DRAIN 相当于添加了一个请求屏障,这会暂停后续请求的执行
根据特定的应用来选择如何使用这个功能, 因为这可能引入比预期更大的执行管道(不会并发执行请求)

如果这种类型的消耗很常见,那么应用程序应该针对完整性写入使用独立的 io_uring 实例,以允许更好的执行其他命令

链式 sqes

虽然 IOSQE_IO_DRAIN 包括了完整的流水线屏障,io_uring 还支持更精细的 sqe 序列控制
链式sqes提供了一种描述提交环中一些 sqes 之间的依赖性。其中每个 sqe 执行都依赖于前一个 sqe 成功完成

使用链式 sqes 可以实现必须按照顺序执行一系列写操作或者是类似的复制操作,比如其中一个文件中读取,然后写入另一个文件,共享两个 sqes 的缓冲区

通过 sqe->flags 字段中设置 IOSQE_IO_LINK 来使用链式请求功能,设置后,下一个 sqe 将不会在前一个 sqe执行成功之前启动

如果前一个 sqe 没有完全完成(执行失败),那么链就会断开,链中的 sqe 会被取消,-ECANCELED 作为错误码
链式请求中,完全完成是指请求完成成功完成。任何错误或者潜在的读写问题都会中断链,请求必须完全完成

只要在 flags字段中设置了 IO_SQE_LINK, sqes 链会一直继续,直到第一个没有设置 IO_SQE_LINK 的 sqe,支持任意长度的链

超时命令

尽管 io_uring 支持的大部分命令都与数据相关,例如 read/write 这类直接操作或者 fsync 这类间接操作,但是 timeout 命令却略有不同

IORING_OP_TIME 会按照触发方式在完成环上的生成相应的 完成事件,而不是对数据进行操作
超时命令支持两种不同的触发方式,他们可以一起在单个命令中使用

一种触发方式是经典超时,调用者传递一个具有非零秒/纳秒值的 timespec
为了保持 32 位和 64 位的兼容性,必须使用以下格式

struct __kernel_timespec {
    int64_t tv_sec;
    long long tv_nsec;
}

用户空间也应该有一个 timespec64 的结构来匹配内核中的描述(__kernel_timespec)

如果超时触发,sqe->addr 字段必须指向该类型的结构,到达指定的时间后超时命令将会完成

第二种触发方式对完成计数,将完成计数值(completion count value)应该填充到 seq->offset 字段中,完成事件到达指定次数后就会完成超时命令

可以在一个超时命令中指定两种触发方式。如果一个请求中有两个超时,那么最先触发的条件将生成超时完成时间

发布超时完成事件时,所有完成事件的等待者都会被唤醒,无论他们要求的完成量是否满足

内存排序

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

本文并不会介绍各种体系结构的内存排序,如果愿意使用 liburing 库公开的简化 io_uring API,那么就可以安全的进行通信,可以忽略 该章节
如果对使用原始接口感兴趣,那么了解本章是很重要的

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

  • read_barrier() 确保在进行后续的内存读取之前,先前的写入是可见的
  • write_barrier() 在之前的写入之后再执行写入

根据不同的体系架构,这两个函数之一或者两个都是无操作(no-ops,没有任何操作)的。
使用 io_uring 时这没关系,重要的是我们在某些体系机构上将需要他们,所以应用开发这需要了解如何做到这一点
需要 write_barrier() 来确保写入的顺序

比如应用需要填充一个 sqe,并通知内核可以消费,这个可以分成两个阶段来做

  1. 首先填充 sqe 中的字段,然后将 sqe 的索引放到 SQ 环型数组中
  2. 然后更新 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 字段的以任何顺序写入都没有关系,只要他们在环尾更新前写入完成就行

这样写入顺序就会变成下边这样

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() // 确保更新环尾前,之前的操作都已经写入
7: sqring->tail = sqring->tail + 1
write_barrier() // 确保队尾更新成功

内核在读取 SQ 环尾前,也会使用 read_barrier(),来保证可以读取应用程序对环尾的更新

CQ 环来看,由于消费者/生产者是相反的,因此应用只需要在读取 CQ 环尾前执行一次 read_barrier(),来确保 看到内核的任何写操作

尽管内存排序类型已经被简短成两种特定类型了(读屏障和写屏障),但是架构的实现还是会有所不同,具体取决于正在运行代码的机器
事实上应用直接使用 io_uring 而不是 liburing 帮助函数,依然需要体系架构特定的屏障类型
liburing 库中提供这些屏障函数

liburing

了解了 io_uring 的内部细节后,现在可以学习一种更简单的方式来完成上边的大部分操作了

liburing 有两个目的

  • 为基本的使用场景提供了简化的 api
  • 不需要用重复代码来创建 io_uring 实例

简化的 api 确保了应用程序不需要担心内存屏障,也不需要自己去管理环形缓冲区。这使 API 更易于理解和使用,并且不需要去了解内部工作细节
如果只是提供 liburing 的实例,那么本文会短很多,但应该了解一些内部工作原理,这样可以让应用获得更大的性能

liburing 当前的目的是较少重复代码,并为标准场景提供基本的帮助,暂时还无法通过 liburing 暂时还没有提供一些更高级的功能
使用 liburing 并不意味着不能将这两种混合使用
在底层他们都是使用相同的结构来操作,即使应用是使用原始的系统调用接口,也推荐使用 liburingsetup 帮助函数

liburing setup

让我们从一个例子开始,liburing 提供了一个基本的帮助函数,来完成 io_uring_setup的调用和三个必须的 mmap

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

io_uring 结构保存了 SQ 环CQ 环,并且调用了他们的设置逻辑,对于这个例子我们将 flags 设置成了 0

应用使用完 io_uring 结构后,可以调用 io_uring_queue_exit

io_uring_queue_exit(&ring)

拆卸 ring,和应用分配的其他资源一样,一旦一样用退出,他们就会自动被内核回收。
对于应用已经创建的任何 io_uring 实例都是这样的

liburing 提交和完成

一个非常基本的使用场景就是提交一个请求然后等待他完成,使用 liburing 就是下边这个样子

struct io_uring_sqe sqe;
struct io_uring_sqe cqe;

// 获取 sqe,并填充 READV 操作
sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, fd, &iovec, 1, offset);

// 提交请求,通知内核可以消费 sqe
io_uring_submit(&ring);

// 等待完成事件
io_uring_wait_ce(&ring, &cqe);

app_handle_cqe(cqe);
io_uring_cqe_seen(&ring, cqe);

注意,如果还有其他提交的 sqe,那么等待的可能不是刚才提交的 sqe

如果应用仅希望查看完成情况,而不希望等待完成事件,可以调用 io_uring_peek_cqe

对于这两种场景,应用都必须使用完成事件 cqe来调用 io_uring_cqe_seen
否则重复调用 io_uring_peek_cqe 或者 io_uring_wait_cqe 会返回同样的事件
这种函数上的功能分隔是有必要的,以避免内核可能在应用完成之前覆盖现有的完成事件
io_uring_cqe_seen 会增加 CQ 环头,使内核可以在同一槽位可以上填充新的事件

liburing 也提供了很多填充 sqe 的函数,比如 io_uring_prep_readv
我们推荐应用尽量使用 liburing 提供的函数

liburing 仍处于起步阶段,并且正在不断开发以扩展受支持的功能和可用的助手

高级用例和特性

上面的例子和使用场景适用于各种类型的 IO,基于 O_DIRECT 的文件IO有缓冲的文件 IOsocket IO 等等
不需要特别的操作去保证异步性,不过 io_uring 的确给应用提供了更多的功能,以下小节描述了大多数功能

固定文件和固定缓冲区

注册文件

每次将文件描述符填充到 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

注册缓冲区

不仅仅可以注册文件集,还可以注册一组固定 IO 缓冲区(fixed IO buffers)

使用 O_DIRECT 时,内核在真正执行 IO 前,必须映射应用内存页(pages) 到内核中,并且当 IO 完成后取消对这些页的映射。
这些操作的开销可能是昂贵的。如果应用可以复用 IO 缓冲区,那么总共只需要进行一次映射和取消映射,而不是每次 IO 操作都需要

要注册一组固定缓存区,io_uring_register 必须使用 IORING_REGISTER_BUFFERSopcode 来调用,args 必须包含填充好每个 iovec 的地址和长度字段的 iovec 数组,nr_args 则是 iovec 数组的大小

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

轮询 IO

由于对 轮询 IO 不了解,导致本节翻译可能会有一些问题,可以查看 Efficient IO with io_uring

对于低延迟的应用来说,io_uring 提供了对文件轮询的 IO 的支持。
在这种情况下,轮询是指在不依赖硬件中来发出完成事件信号的情况下执行 IO,轮询 IO 后,应用将反复向硬件驱动询问已提交的 IO 请求的状态。
这和应用进入休眠状态然后等待硬件中断来唤醒的非轮询 IO 是不同的。

对于延迟非常低的设备和 IOPS 很高的情况,轮询可以显著提高性能,高中断率会导致非轮询的应用具有更高的开销。
在等待时间和总体 IOPS 速率上,轮询是否有意义取决于应用,IO 设备和机器的性能

要利用 IO 轮询,就必须在调用 io_uring_setup 时将 io_uring_params->flags 设置 IORING_SETUP_IOPOLL,或者使用 liburingio_uring_queue_init

使用轮询后,应用不能通过 CQ 环尾来检查可用的完成事件了,因为不会自动触发异步硬件的完成事件了。
相反,应用必须主动去查询,通过设置 IORING_ENTER_GETEVENTSmin_complete 来调用 io_uring_enter 获取到完成事件。可以设置 IORING_ENTER_GETEVENTSmin_complete=0。

对于轮询 IO,这可以要求内核简单的检查驱动上的完成事件,而不是不断的循环执行

在使用 IORING_SETUP_IOPOLL 注册为轮询 io_uring 实例上,只有对轮询的完成事件有意义的 opcodes 才可以被使用。
这些包括任何的读写命令:IORING_OP_READV, IORING_OP_WRITEV,IORING_OP_READ_FIXED, IORING_OP_WRITE_FIXED

在已注册为轮询的 io_uring 实例上使用非轮询的操作码是不合法的。这样会导致 io_uring_enter 返回 -EINVAL
背后的原因是,当使用 IORING_ENTER_GETEVENTS 来调用 io_uring_enter 时内核无法知道是否可以完全的进入睡眠状态来等待事件或者是否应该主动轮询事件

内核测轮询

虽然 io_uring 可以通过更少的系统调用来高效的发布和完成更多的请求,在某些情况下我们可以通过进一步减少系统调用的数量来提高执行 IO 的效率

这种功能之一就是内核侧轮询,启用该功能后,应用将不再刻意通过 io_uring_enter 来提交 IO 了。当应用更新 SQ 环,并且提交新的 sqe 时,内核会自动发现一个或多个新的 sqe 并且提交他们。
这是通过特定于 io_uring 的内核线程来完成的

使用这个功能,io_uring 实例必须使用 IORING_SETUP_SQPOLL 作为 io_uring_params->flags 来注册 io_uring 实例,或者传递给 io_uring_queue_init 函数。
如果应用希望线程为特定的 cpu,那么使用 IORING_SETUP_SQ_AFF flag,并且设置 io_uring_params->sq_thread_cpu 为所需 cpu。
注意使用 IORING_SETUP_SQPOLL 来设置 io_uring 实例是特权操作,如果用户没有足够权限,那么 io_uring_setup /io_uring_queue_init 会以 -EPERM 失败

为了避免在 io_uring 实例处于非活动状态时浪费过多的 CPU。当内核侧线程空闲一段时间后,它将自动进入睡眠状态。
发生这种情况时,内核线程会设置 IORING_SQ_NEED_WAKEUPSQ 环 的 flags。
设置该值后,应用将无法依赖内核自动查找新条目,并且必须调用使用 IORING_ENTER_SQ_WAKEUP 来调用 io_uring_enter
应用逻辑看起来想下边这样

// 添加新的 sqe 条目
add_more_io();

// 如果可轮询并且线程已经睡眠,需要调用 io_uring_enter() 使内核发现一个新的  IO
if ((*sqring->flags) & IORING_SQ_NEED_wAKEUP)
    io_uring_enter(ring_fd, to_submit, to_wait, IORING_ENTER_SQ_WAKEUP);

只要应用一直提交请求,就永远不会设置 IORING_SQ_NEED_WAKEUP,我们可以有效的执行 IO,无需执行单个系统调用

可以通过设置 io_uring_params->sq_thread_idle 字段来配置空闲前的特定宽限期。
值是以毫秒为单位,如果未设置此值,那么内核默认将线程置为睡眠状态前的空闲时间为1秒

对于正常的中断驱动的 IO,应用可以直接查看 CQ 环来找到完成事件。
如果使用 IORING_SETUP_IOPOLL 设置的 io_uring 实例,内核将会负责获取完成事件
对于这两种情况,除非应用希望等待 IO 发生,否则可以简单的查看 CQ 环来查找事件

性能

最后, io_uring 达到了他的设计目标
我们有了一个内核和应用之间非常有效的交付机制——通过两个不同的环
虽然在应用程序中正确使用原始系统调用接口需要注意一些问题,但主要的复杂性实际上是需要显式内存排序原语。
它们只涉及发布和处理事件的提交和完成方面的一些细节,并且在应用程序之间通常遵循相同的模式。
随着 liburing 的不断成熟,希望大多数应用都可以对 他的接口满意

尽管本文的目的不是详细介绍 io_uring 的性能和可扩展性,但本节会介绍在这方面的一些优势
更多的细节可以看 https://lore.kernel.org/linux-block/20190116175003.17880-1-axboe@kernel.dk/
注意,由于在阻塞方面的进一步改进,这些结果可能有些过时了
例如,在我的测试机中,使用 io_uring 的每核心性能峰值是 1700k 4k IOPS 而不是 1620k。
注意,这些值没有绝对的之意义,不过在衡量相对上的优化还是很有用的
现在,通过 io_uring 可以发现应用和内核之间的通信不再是较低的延迟和较高的峰值性能的瓶颈了

原始性能,真实性能

有很多方法可以查看接口的原始性能。大多是测试都会设计内核的其他部分
上边的数字就是这样的一个例子,我们通过从块设备或文件中随机读取来测量性能。对于最高性能, io_uring 帮助我们通过轮询获得 1.7M 4k IOPS。而 AIO 只能达到 608k 。这里其实有点不太公平,因为 AIO 不支持轮询 IO
如果我们禁用轮询,io_uring 在相同的测试用例中也可以达到 1.2M 的 IOPS。这样看来 AIO 的局限性就非常的明显,在相同工作负载下,io_uring 的 IOPS 是 AIO 的 两倍

io_uring 还支持 no-op 命令,该命令主要检查接口的原始吞吐量。根据所使用的的系统,观察到的消息从每秒 1200 万条到每秒2000 万条不等。实际结果会因具体的测试用 例而异,而且主要受到必须执行的系统调用数量的限制
在其他方面,原始接口是和内存绑定的,并且提交和完成事件消息都很小并且在内存中是线性的,因此每秒实现的消息率可能非常高

缓冲的异步性能

我之前说过,内核内缓存的 AIO 实现可能会比用户空间实现更高效。一个主要原因是和缓存和非缓冲数据有关。
当进行缓冲 IO 时,应用通常严重依赖内核的页缓冲(page cache) 来提供更好的性能。用户空间的应用无法指导他解析奥莱要请求的数据是否已经缓存。当然也可以查询这些信 息,但是这需要更多的系统调用,不过现在缓存的东西可能几秒后就不再缓存了了。因此具有 IO 线程池的应用通常必须将请求交给异步上下文中,从而导致至少两次上下文的切换。如果请求的数据已经在页面缓存中,这将导致性能急剧下降。

io_uring 处理这种情况就像处理其他可能阻塞应用的资源一样。
更重要的是,对于不会阻塞的操作,会以内联的方式提供数据。这时 io_uring 对于页面缓冲中已经存在的 IO 来说,可以和常规同步接口一样高效。
一旦 IO 提交调用返回,应用将在 CQ环中有一个完成事件在等待他并且数据已经被复制了

What’s new with io_uring

距离第一个支持 io_uring 的 内核(5.1) 发布已经 6 个月了
和任何新的 API 和功能特性一样,初始版本只是一个起点
一旦人们开始将现有的应用转换为API,或者开始根据 API 编写新应用时,不可避免的就会产生新的功能需求
本文将尝试介绍一些自推出以来更重要的补充。

新命令

大多数功能都不可避免的使用 io_uring新操作码。增加了新的核心功能,其中大多数只是常规同步系统调用的镜像版本。
对于实际的命令定义,我希望读者使用 [liburing] 的帮助函数来设置这些。
clone 地址:git://git.kernel.dk/liburing

重要性不分先后,新命令为

  • IORING_OP_SYNC_FILE_RANGE 这个命令增加了对异步方式执行 sync_file_range 的支持。它支持同步的系统调用的所有功能

  • IORING_OP_SENDMSGIORING_OP_RECVMSG。之前可以在套接字上常规的执行 IORING_OP_READVIORING_OP_WRITEV,而且这也是使用 io_uring 来做网络 IO 的唯 一方法。
    现在我们支持了 sendmsg recvmsg 的异步版本。如果可能的话,他们会内联执行,如果他们阻塞了提交的应用,那在后台运行

  • IORING_OP_ACCEPTsend/recvmsg 调用一样,为 accept4 系统调用提供了了异步支持。
    这是 io_uring 支持的第一个创建新的文件描述符的系统调用

  • IORING_OP_TIMEOUT 该命令的特殊之处在于他没有参考现有的系统调用,而是增加了对触发CQ环中的超时条件来唤醒在事件上睡眠的应用。
    超时有两种方式,一种是事件完成次数或者特定的超时(绝对或相对)。无论哪种事件先触发,都会将 CQ 中增加一个完成事件,并唤醒等待者
    liburing 使用超时提供了 io_uring_wait_cqe_timeout() ,但是应用也可以根据需要来使用。

  • IORING_OP_TIME_REMOVE 可以删除现有的超时

  • IORING_OP_ASYNC_CANCEL 可以取消已有的异步工作

熟悉的 AIO/libaio 的人可能会说 io_cancel 系统调用已经存在很长时间了,不是过一直都没有实现。而且他仅仅和 AIO 的 poll 命令一起工作。
在 io_uring 中,这适用于任何读写操作,accept ,send/recvmsg 等等。这里使用不同的命令会有一个重要的区别。
读写常规文件时会以不间断状态等待 IO。这意味着它将忽略任何信号或者尝试取消,也就意味着无法取消。
如果他们还没有开始,那么 io_uring 就可以取消他,如果已经启动,那么取消就会失败

网络IO 通常会处于等待的中断等待,因此可以随时取消。
如果成功取消,那么 IORING_OP_ASYNC_CANCEL 请求的完成事件结果(cqe->res)就是 0, -EALREADY 表示取消操作已经在进行中,如果指定的原始请求找不到了就会返回 -ENOENT

对于取消请求的返回 -EALREADY,io_uring 可能会也可能不会导致请求提前终止

  • 对于阻塞 IO,原始请求会按照原先的请求完成
  • 对于可取消的 IO,它会在所有可能的情况下尽早终止

其他

eventfd

现在支持在 io_uring 中支持 eventfd 通知,应用可以使用 eventfd 通知完成事件

文件描述符注册

注册文件集的支持被扩展了很多,现在不在局限于 1024 个文件。
而支持 64k 注册的文件。而且还支持稀疏文件集,也就是说一个巨大的文件集可以有 fd == -1 的 集/文件。
这一点很重要,因为我们现在还支持文件集更新,应用可以在表中特定偏移位置显示的更新大量文件。
在此更改前,更新/更改文件集的唯一方法是取消现有文件集的注册,然后注册一个新文件集

CQ 环大小

默认情况下,io_uring 会将 CQ 环的尺寸设置为 SQ 环的大小的两倍。之所以这样是因为 sqe 的生存周期非常短,一旦内核看到他们们就会被消耗掉。
意味着应用可以使用比SQ环大小更高的请求数量。为了避免轻易溢出 CQ 环,我们将 CQ 环加倍容纳更多的完成事件

有一些用例需要一个比 SQ 环大的多的 CQ 环,以前他们必须使用一个大的 SQ 环 来设置,但这在内存利用方面效率很低。io_uring 现在支持独立调整 CQ 环的大小,这样就可以有一个 128 条目的 SQ 环,而 CQ环 大小是 32k。

如果应用想独立设置 CQ 环 大小,则必须用 IORING_SETUP_CQSIZE设置 io_uring_prarams->flags 来创建 io_ring 实例,并且设置 io_uring_params->cq_entries 来指定大小。
CQ 环 的尺寸必须至少和 SQ 环 相同,和SQ 环`一样也必须是 2 的幂

io_uring 现在也和内核工作队列基础架构脱离了。这是纯粹的内部变化,无法通过 API 看到。
为何有必要这样做,对详细信息感兴趣的人可以看这两个提交 io-wq: small threadpool implementation for io_uring
io-wq: small threadpool implementation for io_uring
重要的是通过它支持了文中提到的几个特性

通过 io_uring_params->features 可以查看是否设置IORING_FEAT_NODROP ,它可以防止 CQ 完成事件的溢出问题,保证不丢消息
稍微高一些的 Linux 版本支持该 feature

过多的创建 io_uring 实例可能导致 ENAOMEM 错误,可以看 https://github.com/spacejam/sled/issues/899

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值