动机
这篇文章尝试仅使用 io_uring_enter 系统调用进行 io_uring 异步编程。尽管这不是 io_uring 作者推荐的做法(axboe:老实用 liburing,别逞强!),但是现实存在的尴尬是,libuv 有部分 standalone 库是仅使用低层接口以尽可能脱离 liburing 的版本依赖,而我看不明白写的啥玩意……
总之打不过就加入!
为什么不要做
前面说了为什么要不得已做这种离谱的事情,这里补充为什么一般情况不要这么做。
io_uring 作者 Jens Axboe 的演讲
从用户实现来看,io_uring 的前置操作非常繁琐,并且起步就要求无锁编程(手动管理内存屏障),更加疯狂的是作者把多到数不清的特性全部放到一个 io_uring_enter 系统调用上,而不同特性的组合在不同内核版本号上是存在细微行为差异的,想要做 portable 库的难度非常高。
3.0 New interface design goals
Easy to use, hard to misuse. Any user/application visible interface should have this as a main goal. The interface should be easy to understand and intuitive to use.
来源:Efficient IO with io_uring(事实上,这不成立)
// 海星的一段代码 static std::optional<::io_uring> try_create_uring(unsigned queue_len, bool throw_on_error) { auto required_features = IORING_FEAT_SUBMIT_STABLE | IORING_FEAT_NODROP; auto err = ::io_uring_queue_init_params(queue_len, &ring, ¶ms); // ... if (~ring.features & required_features) { maybe_throw(/* ... */); return std::nullopt; } }
Seastar 还是用了 liburing 来达成 portable 要求,不过所谓的 portable 也只是不符合要求就强行关掉。
从 man 2 io_uring_enter 也可以看出,io_uring 和以往的系统调用不同,它并没有常见的 glibc syscall wrapper,而是改由 liburing 接管系统调用封装。作者根本就不打算让你直接用。
正文部分
正文主要参考 Efficient IO with io_uring 这份 PDF,因为它是唯一由作者钦定的较为系统全面的文档。但是由于年代问题(已经是 6 年前的资料了!)可能有疏漏,我会尽量参考现有 liburing 源码以及其他资料做对齐。
我们先在本文用词上做对齐:SQ = submission queue; CQ = completion queue; E = entry。
环初始化
liburing 库提供了 io_uring_queue_init()
库函数作为 io_uring 实例的初始化函数,并且还提供了 io_uring_queue_init_params()
和 io_uring_queue_init_mem()
两个变种。除了需要指明 SQ/CQ 环的深度和标记以外,还可以定制更复杂的初始参数和预映射的内存地址。
// 实际上 Linux 头文件不存在这个函数,你需要自己做浅层封装
int io_uring_setup(u32 entries, struct io_uring_params *p);
// https://elixir.bootlin.com/linux/v6.8/source/include/uapi/linux/io_uring.h#L494
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 该怎么办?这需要 io_uring_setup()
系统调用。
io_uring_setup()
(以返回的形式)提供一个表示 io_uring 实例的 fd。默认情况下,这个 fd 就是常规意义下的文件描述符(file descriptor);而在启用 register ring fd 的情况下,它可以是 io_uring 线程私有的注册文件描述符(registered file descriptor),减少了原子 fget/fput 操作带来的开销。
作者在做完 register fd 特性后就反问为啥不给 io_uring 自身也做 register 支持,然后就有了 register ring fd
NOTES:
- 作者在演讲(第 16 页)提到减去原子 fget/fput 操作通常能劲省 3-5% 的运行时开销。
- QEMU 曾经使用了 register ring fd 特性来提高性能,后来因为跨线程限制不得不禁用。
io_uring_setup()
还(以出参的形式)提供 SQ/CQ 环的偏移量信息,需要搭配使用 mmap()
以完成内核地址空间和用户地址空间的共享。io_uring 的共享内存至少有三个特性值得一提:
- 内核 v5.4 版本提供的
IORING_FEAT_SINGLE_MMAP
特性,它可以将共计 3 次的 mmap 频率(SQ 环、CQ 环、SQEs 数组)降低为 2 次(SQ/CQ 环、SQEs 数组)。 - 内核 v6.5 版本提供的
IORING_SETUP_NO_MMAP
特性,环和数组不再由内核分配,而是改为用户预先(在 io_uring_setup 前)分配。通常这么做是为了能让用户主动使用巨页特性。 - 内核 v6.6 版本提供的
IORING_SETUP_NO_SQARRAY
特性,它取消了 SQ 环的 SQEs 数组中间层。也就是说不用再手动声明和映射 SQE 的索引下标了,内核就是按递增顺序帮你搞定。
// 一些琐碎的封装,详见:
// https://github.com/Caturra000/Snippets/blob/master/io_uring/io_uring.hpp
#include "io_uring.hpp"
// 更加 portable(繁琐)的写法可以参考 GitHub 仓库中的代码
// https://github.com/Caturra000/Snippets/blob/master/io_uring/setup.cpp
// 这里简化了相当大量的分支
int main() {
io_uring_params p {};
int fd = io_uring_setup(128, &p) | nofail("setup");
// 更加 portable 的做法是为旧版本内核的 CQ 环再次映射
assert(p.features & IORING_FEAT_SINGLE_MMAP);
// liburing uses sizeof(unsigned).
auto ring_size = p.sq_off.array + p.sq_entries * sizeof(unsigned);
void *ring_addr = mmap(nullptr, ring_size,
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
// offset 是有特定要求的,自行查询 man 手册
fd, IORING_OFF_SQ_RING);
assert(ring_addr != MAP_FAILED);
auto sqe_size = sizeof(io_uring_sqe);
auto sqes_size = sqe_size * p.sq_entries;
void *sqes_addr = mmap(nullptr, sqes_size,
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
fd, IORING_OFF_SQES);
assert(sqes_addr != MAP_FAILED);
// 用户定义的 SQ/CQ 环
// liburing 看不到这些,是因为它藏起来了
SQ_ref sq;
CQ_ref cq;
// 不考虑(玄学)生存期,可以理解为 (T*)(ring_addr + p.sq_off.head)
sq.p_head = mmap_start_lifetime_as<unsigned>(ring_addr, p.sq_off.head);
sq.p_tail = mmap_start_lifetime_as<unsigned>(ring_addr, p.sq_off.tail);
sq.p_flags = mmap_start_lifetime_as<unsigned>(ring_addr, p.sq_off.flags);
sq.p_dropped = mmap_start_lifetime_as<unsigned>(ring_addr, p.sq_off.dropped);
// 接近运行时常量的信息就不必隔一层地址了(注意 io_uring 是可以 resize 的,不是真的固定不变)
sq.ring_mask = *mmap_start_lifetime_as<unsigned>(ring_addr, p.sq_off.ring_mask);
sq.ring_entries = *mmap_start_lifetime_as<unsigned>(ring_addr, p.sq_off.ring_entries);
// 对 SQARRAY 进行映射要看具体情况,详见 GitHub 仓库版本
sq.p_array = mmap_start_lifetime_as_array<unsigned>(ring_addr, p.sq_off.array, sq.ring_entries);
sq.sqes = mmap_start_lifetime_as_array<io_uring_sqe>(sqes_addr, 0, sq.ring_entries);
cq.p_head = mmap_start_lifetime_as<unsigned>(ring_addr, p.cq_off.head);
cq.p_tail = mmap_start_lifetime_as<unsigned>(ring_addr, p.cq_off.tail);
cq.p_flags = mmap_start_lifetime_as<unsigned>(ring_addr, p.cq_off.flags);
cq.p_overflow = mmap_start_lifetime_as<unsigned>(ring_addr, p.cq_off.overflow);
cq.ring_mask = *mmap_start_lifetime_as<unsigned>(ring_addr, p.cq_off.ring_mask);
cq.ring_entries = *mmap_start_lifetime_as<unsigned>(ring_addr, p.cq_off.ring_entries);
cq.cqes = mmap_start_lifetime_as_array<io_uring_cqe>(ring_addr, p.cq_off.cqes,cq.ring_entries);
// ...
}
NOTES:
- 以
IORING_FEAT
前缀命名的特性是由 Linux 内核主动注入的,用户无权干涉。 - Efficient IO with io_uring 当中为 mmap 使用的
void*
算术运算在 C++ 标准中是非法行为。 - 个人理解,SQEs 中间层的设计是为了支持
侵入式容器以及乱序提交,但是似乎没见过哪个库有这方面的实践。而IORING_SETUP_NO_SQARRAY
提出来的动机竟然也是这个理由。 - 与 SQEs 的中间层设计不同,CQEs 内嵌于 CQ 环。
- 无关八卦:通常 SQE 是 64 字节,CQE 是 16 字节,但是 SQE128/CQE32 特性可以让它们大小翻倍。这些特性只与 NVMe passthrough 需求有关(但是又给 portable 上难度了)。
- 关于 IORING_FEAT_SINGLE_MMAP 的实践:没啥好说,read the cat example!
- 关于 IORING_SETUP_NO_MMAP 的实践:该特性其实是要求缓冲具有连续的页面,但是用户态做不到这种事情,所以 liburing 对该特性的实现是只能处理共计 2MB 以内的内存需求:4KB 以内使用单个匿名常规页,否则使用单个匿名巨页。
- 关于 IORING_SETUP_NO_SQARRAY 的实践:在默认未使能对应标志的情况下,liburing 仍然会尝试使用该特性。
准备 IO
liburing 准备 IO 的做法就是先获取再填充,即先通过 io_uring_get_sqe()
库函数获取一个 SQE,再通过 io_uring_prep_*()
系列的库函数为 SQE 填充字段。
io_uring 获取 SQE 就需要由用户主动操作 SQ 环的 head 和 tail。从前面可以看出,SQ/CQ 环都是由 mmap 映射得到的,并且大概率由用户定义的结构体来接管,具体怎么做就有很大的自由度来自行管理:比如批处理,你知道特定场景不会溢出 ring mask 的话直接 tail 一把梭就行(劲省指令数!)。而 liburing 是选择隐藏全部实现细节:比如批处理分配,没有,你只能循环 get;又比如前面提到的 SQEs 中间层设计,其下标只能按固定顺序递增赋值。
至于 io_uring 的填充操作,不同的指令有不同的要求,建议直接抄 liburing 实现。总之实现上就是给 io_uring_sqe
结构体填字段。
// (不严格)模仿 liburing 的获取接口
auto get_sqe = [&] {
// 内核并不更新 SQ tail,写入方永远是用户
unsigned int head, next = sq.sqe_tail + 1;
// SQPOLL 提交线程会跟当前线程竞争 SQ head
if(p.flags & IORING_SETUP_SQPOLL) head = smp_load_acquire(sq.p_head);
else head = *sq.p_head;
if(next - head > sq.ring_entries) return (io_uring_sqe*){};
auto sqe = &sq.sqes[sq.sqe_tail & sq.ring_mask];
// 参考 liburing 实现,sqe_tail 是一个本地快照,缓存直到 submit 再释放
// 吐槽:
// 我不清楚为啥 liburing 要缓存 SQ tail 直到 submit
// 个人认为 prepare 确认后就能释放(它没这么做);
// 之前跑过基于 liburing 的 SQPOLL 测试,异步提交线程一直不干活,
// 原来是这家伙缓存起来了!
sq.sqe_tail = next;
memset(sqe, 0, sizeof(io_uring_sqe));
return sqe;
};
// 一个简单的填充接口
// 比如说我要异步写入数据到命名为 jojo 的(已打开)文件当中
auto prepare_write = [jojo_fd]<size_t N>(io_uring_sqe *sqe, const char (&buf)[N]) {
assert(sqe);
sqe->opcode = IORING_OP_WRITE;
sqe->fd = jojo_fd;
sqe->addr = std::bit_cast<decltype(sqe->addr)>(&buf);
sqe->len = N - 1;
};
// 假定了不存在 SQ 环已满的情况(queue depth >= 3)
prepare_write(get_sqe(), "hello ");
prepare_write(get_sqe(), "world ");
prepare_write(get_sqe(), "! ");
NOTES:
- 别忘了维护内存屏障。就目前的 liburing 实践而言,不再是使用 Efficient IO with io_uring 第六章提到的 read/write_barrier,而是改为 acquire/release 或者 ONCE 系列函数(这样你不用夹两遍)。
- 不熟悉这些函数的读者也可以查阅一下本人前段时间记录的 perfbook 笔记(这里还有这里)。
- 再看了眼 liburing 实现,准备阶段只有 SQPOLL 模式才需要考虑 SQ head/tail 的内存序同步,因为非 SQPOLL 只有在系统调用(submit)时才会由内核侧读取 SQ tail 和写入 SQ head。
- 这块太复杂了,图省事也可以参考 io_uring.h 注释,无脑使用 acquire/release。
- Efficient IO with io_uring 当中的示例没有检测 SQ head 越界啊 (#゚д゚)
- 有一个版本特性有意思,io_uring 原生支持 epoll 的驱动方式(IORING_OP_POLL_ADD 指令),但是早期支持并不完整,因此在使用高级特性前需要确保 IORING_FEAT_POLL_32BITS 存在。
提交 IO
// 旧版本内核的系统调用
int io_uring_enter(unsigned int fd, unsigned int to_submit,
unsigned int min_complete, unsigned int flags,
sigset_t *sig);
// 新版本内核的系统调用
int io_uring_enter2(unsigned int fd, unsigned int to_submit,
unsigned int min_complete, unsigned int flags,
sigset_t *sig, size_t sz);
io_uring 的设计是一个接口尽可能做更多的事情(以减少系统调用次数),因此 io_uring_enter()
系统调用是同时支持提交 IO 和等待完成 IO。需要注意,默认不使用任意标记的情况下,io_uring_enter 只有 submit 语义;使能 IORING_ENTER_GETEVENTS 标记后才会有 submit and wait 语义。
8.2 POLLED IO
When polling is utilized, the application can no longer check the CQ ring tail for availability of completions, as there will not be an async hardware side completion event that triggers automatically. Instead the application MUST actively find and reap these events by calling io_uring_enter(2) with IORING_ENTER_GETEVENTS set and min_complete set to the desired number of events.
NOTE: io_uring 默认模式为 IRQ-driven IO,即使是 submit 语义,IO 完成后也会使得 CQ 环的 tail 下标有进展,此时非等待状态的用户只需要主动检查 tail 下标(指针)即可。但是低时延 polled IO 模式要求必须使用 IORING_ENTER_GETEVENTS(不能非等待查询 CQ 环),详见 PDF 8.2 解释。
int *x = /* 映射得到的虚拟地址 */;
// IRQ-driven 指的是这个意思(假设编译器不会有任何优化):
assert(*x == 0);
// ===== 内核在任意指令之间都允许干涉你的变量,即使你是单线程 ======
assert(*x == 1);
注意 io_uring 是 ring buffer 设计,什么时候环上条目可以复用是需要考虑的。Linux 内核 v5.5 及以后的版本已支持 IORING_FEAT_SUBMIT_STABLE 特性,SQE 提交后即可自由复用。而旧版本的内核要求产生 CQE 后才能让用户回收使用 SQE。
By default, the CQ ring will have twice the number of entries as specified by entries for the SQ ring. This is adequate for regular file or storage workloads, but may be too small for networked workloads.
来源:man 3 io_uring_queue_init
NOTES:
- 作者在手册提到一个生产者-消费者模型的设计问题,由于 SQE 的生命周期很短,只要提交后就可能在将来产生 CQE 并且回收 SQE,因此理应让 CQE 数目多于 SQE 数目。默认设置是 2 倍,对于 SQE/CQE 生命周期差异较大的网络环境,可能需要用户进一步调整。
- 同时 IORING_FEAT_NODROP 也提到,CQE 其实是允许溢出的,内核会用内部手段帮你临时存起来直到 OOM。从 libuv 的实现来看,这可能(没细看)需要 IORING_ENTER_GETEVENTS 才能让溢出的 CQE 重新刷回到 CQEs。
- SQE 不允许溢出。
// 求出 SQE 需要刷新的数目(内部使用)
auto _flush_submit = [&] {
unsigned tail = sq.sqe_tail;
bool sqpoll = p.flags & IORING_SETUP_SQPOLL;
// 尽可能避免 SQ tail 写入操作
if(sq.sqe_head != tail) {
sq.sqe_head = tail;
if(sqpoll) {
smp_store_release(sq.p_tail, tail);
} else {
*sq.p_tail = tail;
}
}
// SQPOLL 模式需要处理原子性
unsigned head = std::invoke([&] {
if(sqpoll) return READ_ONCE(*sq.p_head);
return *sq.p_head;
});
return tail - head;
};
// 仍然是拙劣模仿 liburing 的接口
// 返回已提交的 SQE 个数
auto submit_and_wait = [&](unsigned wait_nr) -> int {
unsigned submit_nr = _flush_submit();
bool sq_need_enter = std::invoke([&] {
if(!submit_nr) return false;
if(!(p.flags & IORING_SETUP_SQPOLL)) return true;
// TODO: SQPOLL wakeup flag! Also NEED a store operation!
return false;
});
bool cq_need_enter = std::invoke([&] {
if(wait_nr) return true;
if(p.flags & IORING_SETUP_IOPOLL) return true;
auto need_flush = IORING_SQ_CQ_OVERFLOW | IORING_SQ_TASKRUN;
if(READ_ONCE(*sq.p_flags) & need_flush) return true;
return false;
});
// TODO: register ring fd.
auto enter_flags = std::invoke([&] {
unsigned int enter_flags = 0;
if(false /* TODO: use_IORING_REGISTER_RING_FDS */) {
enter_flags |= IORING_ENTER_REGISTERED_RING;
}
if(cq_need_enter) {
enter_flags |= IORING_ENTER_GETEVENTS;
}
return enter_flags;
});
if(sq_need_enter || cq_need_enter) {
return io_uring_enter(fd, submit_nr, wait_nr, enter_flags);
}
// 见 man page,返回值可以是 0(没有提交项,没有系统调用),也可以是 SQPOLL 估算值
return submit_nr;
};
auto submit = [&] { return submit_and_wait(0); };
实现层面,liburing 的 io_uring_submit()
库函数实现说简单点就是直接调用 io_uring_enter()
系统调用,但是考虑细节的话,它会尽可能避开系统调用。 比如检查 SQ 环 和 CQ 环是否真的有必要刷新,又或者是使能了 SQPOLL/IOPOLL 等模式。
NOTES:
- 提交需要刷新 SQEs,也就是用户更新 SQ tail 指针。注意 SQPOLL 模式下的 SQ tail 需要提供 release 语义:除了保证 tail 原子性以外,还使得 SQPOLL 线程对此前 SQEs 的写入顺序可见。liburing 在这里多做了一套返回 head - tail 差值的接口,而我们对 SQ head 的读取只需要 READ_ONCE() 提供原子性即可(因为 SQPOLL 可以并发更新,但是这里只用于计数用途)。
- IORING_SQ_TASKRUN 见我这篇文章。
等待 IO
一般来说,等待是特指同步等待。前面都说了就是一个 GETEVENTS 的事情,剩下的不太关心,略。
留两条笔记以后再探讨:
- SQEs 已满时也可以同步等待直到有空余条目,但这是使能 SQPOLL 后才有的特性。关键字:IORING_ENTER_SQ_WAIT。
- 据注释所言,使能 IORING_ENTER_EXT_ARG 能做到更高效 timed wait。为什么?
收割 IO
io_uring 不需要系统调用来收割 IO 完成结果,因为 CQEs 和 CQ head/tail 均已经映射到用户虚拟地址空间。
所以 liburing 的做法也是非系统调用封装。它至少提供两种接口:一是支持原地获取(遍历) CQEs 的 io_uring_for_each_cqe
宏定义;二是安全复制到用户定义 CQE 实例指针的 io_uring_peek_cqe
函数。当然,io_uring 的设计理念是鼓励批处理的,因此也有 io_uring_peek_batch_cqe
等支持批处理的变种。同样是批处理的思路,收割 IO 完成需要用户主动确认,也就是 io_uring_cq_advance
。
auto cq_ready = [&] {
// 因为可以在其他线程更新,我们需要 CPU 屏障阻止 CQEs 的乱序访问,
// 内核侧会有配对的 release 操作
return smp_load_acquire(cq.p_tail) - *cq.p_head;
};
auto peek_batch_cqe = [&](io_uring_cqe **cqe_ptrs, unsigned count) {
assert(count > 0);
auto get_ready = [&] { return std::min(cq_ready(), count); };
unsigned ready = get_ready();
if(ready == 0) {
auto need_flush = IORING_SQ_CQ_OVERFLOW | IORING_SQ_TASKRUN;
if(READ_ONCE(*sq.p_flags) & need_flush) {
io_uring_enter(fd, 0, 0, IORING_ENTER_GETEVENTS);
}
// Can be 0 again.
ready = get_ready();
}
unsigned head = *cq.p_head;
unsigned last = head + ready;
unsigned mask = cq.ring_mask;
for(auto i = 0u; head != last; i++, head++) {
cqe_ptrs[i] = &cq.cqes[head & mask];
}
return ready;
};
auto cq_advance = [&](unsigned nr) {
if(nr == 0) return;
// 防止提前执行破坏 CQEs 数据
smp_store_release(cq.p_head, *cq.p_head + nr);
};
落地到实现,最麻烦的还是内存序。我一开始觉得 liburing 是过于直接地对 CQ tail 使用 acquire/release 来保证正确性,觉得不太必要:IRQ-driven(常规模式)下的用户读取 tail 可以放松到 READ_ONCE 来保证正确性,并且以 barrier 来阻止编译器对 CQEs (提前于 tail)的乱序访问等优化;而 IOPOLL 模式肯定是由用户主动通过 enter 系统调用来更新,并不需要以上两步。后来发现这段描述不能完全覆盖 io_uring 的情况。因为 io_uring 的 CQ tail 确实存在与内核线程竞争更新的路径:比如 SQE 使能 IOSQE_ASYNC 标记后,io_uring 允许以内核线程池的方式去完成真(强制)异步 IO 的请求。所以,acquire/release 是有必要的。至于 CQ head,因为内核态允许通过 IRQ-driven 访问 CQE head 进行溢出判断,所以用户的写入操作至少需要 WRITE_ONCE 屏障;但又因为额外线程的引入,我们还需要保证 CQEs 确实由用户确认后才能看到更新后的 head 值,否则(如果有大量 IO 请求)提前执行会使得溢出判断失误导致 CQEs 结果覆盖,所以最终选择 release 操作。
THE END
基本上,我算是写了一个 mini-liburing 来说明 io_uring 的复杂性……
总之 io_uring 的低层接口使用大抵如此:你要考虑特性的组合,还有该死的内存序;要 portable 的话甚至要包多几层 linux header 兼容处理,不然你连跨内核版本编译都过不了。这些东西难以通过 read the f-文明 manpage 来了解全貌。
推荐多看点 liburing 的做法,最好还是直接用它。
References
/include/uapi/linux/io_uring.h – Bootlin Elixir Cross Referencer
man 2 io_uring_enter – man7.org
axboe/liburing – GitHub
shuveb/io_uring-by-example – GitHub
Efficient IO with io_uring – kernel.dk