写完 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,最古老和基本的是 read
和 write
系统调用。后来又添加了允许传入偏移量的 pread
和 pwrite
,然后又引入了他们的矢量版本 preadv
和 pwritev
,但是这依然无法满足,所以进一步扩展,出现了允许修饰符标志的系统调用 preadv2
和 pwritev2
。
这些系统调用存在一些差异,但是他们相同的特征是都是同步接口。这意味着只有当数据准备就绪或者写入完成了,这些系统调用才会返回。在一些使用场景中需要使用异步接口。POSIX 提供了 aio_read
和 aio_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
接口主要有三个系统调用
-
io_setup
用于设置 aio 上下文 -
io_submit
提交 IO -
io_getevents
获取或者等待 IO 完成
由于需要对多个系统调用的行为进行修改,所以我们需要添加新的系统调用来传递这些信息。这样就为相同的代码创建了多个入口点,并在其他地方新建快捷接口。最终在代码的复杂性和可维护性上来说结果并不好,而且只是修复了原有 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
保留了请求的结果,可以认为他就像系统调用返回的值,对于正常的读写操作,会类似于read
和write
的返回值,对于成功的操作,他会包含传输的字节数,如果出现异常,他会包含一个负的错误值
例如,发生了 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_off
和cq_off
是通过 io_uring 和内核建立基本通信所必须的
成功调用 io_uring_setup
后,内核会返回一个指向 io_uring 实例的文件描述符
这时 sq_off
和 cq_off
便会排上用场。
鉴于 sqe
和 cqe
结构是内核和应用程序共享的,应用程序需要一种访问这个内存的方法
这会通过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_RING
和 cq_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);
-
fd
指io_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
支持将 sqe
的 flags
字段设置 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
,并通知内核可以消费,这个可以分成两个阶段来做
- 首先填充
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
字段的以任何顺序写入都没有关系,只要他们在环尾更新前写入完成就行
这样写入顺序就会变成下边这样
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 并不意味着不能将这两种混合使用
在底层他们都是使用相同的结构来操作,即使应用是使用原始的系统调用接口,也推荐使用 liburing 的 setup
帮助函数
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
,有缓冲的文件 IO
,socket 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);
-
fd
是io_uring 实例
的文件描述符 -
opcode
执行的注册类型。
对于注册文件集来说,必须是IORING_REGISTER_FILES
。 -
arg
必须指向应用准备打开的文件描述符数组 -
nr_args
便是数组的大小
一旦 io_uring_register
成功将文件集注册后,应用就可以将文件集数组的索引
(而不是使用实际的文件描述符)赋值给 sqe->fd
了,并设置 sqe->flags
字段为 IOSQE_FIXED_FILE
来标记 sqe->fd
是一个文件集索引
应用可以继续使用未注册的文件,即使是注册过的文件也可以通过文件描述符
赋值 sqe->fd
,sqe->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_BUFFERS
的 opcode
来调用,args
必须包含填充好每个 iovec
的地址和长度字段的 iovec
数组,nr_args
则是 iovec
数组的大小
成功注册固定缓冲区
后,应用可以使用 IORING_OP_READ_FIXED
和 IORING_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
,或者使用 liburing 的 io_uring_queue_init
。
使用轮询后,应用不能通过 CQ 环尾
来检查可用的完成事件
了,因为不会自动触发异步硬件的完成事件了。
相反,应用必须主动去查询,通过设置 IORING_ENTER_GETEVENTS
和 min_complete
来调用 io_uring_enter
获取到完成事件。可以设置 IORING_ENTER_GETEVENTS
和 min_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_WAKEUP
到 SQ 环
的 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_SENDMSG
和IORING_OP_RECVMSG
。之前可以在套接字上常规的执行IORING_OP_READV
和IORING_OP_WRITEV
,而且这也是使用io_uring
来做网络 IO 的唯 一方法。
现在我们支持了sendmsg
recvmsg
的异步版本。如果可能的话,他们会内联执行,如果他们阻塞了提交的应用,那在后台运行 -
IORING_OP_ACCEPT
和send/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
网友评论