io_uring 避坑指南
io_uring 是 Linux 内核 5.1(2019年5月发布)引入的高性能异步 I/O 框架,其主要特点如下:
✅ 真正零拷贝、无锁、高效异步 I/O;
✅ 一次系统调用提交多个 I/O 请求;
✅ 支持几乎所有 I/O 类型:文件、socket、pipe、eventfd 等;除了读写操作外,还包括打开文件(open),获取文件信息(stat)、关闭文件(close)和删除文件(unlink)等操作。
Linux 内核 5.10以后,io_uring 才基本成熟稳定,2023 年一批著名的开源软件如 redis、nginx 等适配了 io_uring。 Linux内核版本 >= 6.2 时 io_uring功能更齐全,推荐内核版本至少为 6.2。io_uring 的原理与 RDMA 的 verbs接口类似,二者都是采用请求队列和完成队列的方式。io_uring 完全可以取代传统的 epoll,在高并发场景下有明显性能优势。io_uring 的使用门槛比epoll 略高,比RDMA verbs 明显要低。 RDMA verbs 和 epoll 可以完美配合,但和 io_uring配合有些问题,并且从理论上讲并没有性能上的优势,因此我果断放弃 io_uring 对 RDMA verbs 的整合。
在我适配 io_uring 的过程中,一些不确定的地方经常询问大模型,大模型给出的回答很给力,大大提高了开发效率。在大模型的帮助下,我用了大约两周时间完成 FastDFS 和 FastCFS 的 io_uring适配。我在使用过程中发现大模型难免存在幻觉问题,在缺乏业界经验数据支持的情况下,大模型根据自己的推理机制一本正经给出错误答案,特别容易误导人。我把使用 io_uring 经验整理出来,其中若干条是大模型搞不清楚或者弄混淆了的,希望对大家有所帮助。
io_uring 异步IO框架的核心系统调用主要包括io_uring_setup、io_uring_register 和 io_uring_enter,使用门槛较高。我们可以使用 liburing 封装,这是业界主流做法。
安装 liburing 开发包:
CentOS、Rocky Linux、AlmaLinux 和 RHEL 等 Linux 发行版:
sudo yum install liburing-devel -yDebian 和 Ubuntu 等 Linux 发行版:
sudo apt install liburing-dev -y像 Rocky Linux 之类的 Linux 发行版,默认是禁用 io_uring 的,需要采用如下命令行开启(注意:服务器重启后将失效):
sysctl -w kernel.io_uring_disabled=0也可以在 /etc/sysctl.conf 或专用配置文件(如 /etc/sysctl.d/99-io_uring.conf)中加入如下配置以永久生效:
kernel.io_uring_disabled=0io_uring demo 或测试用途的server和client 根据大模型生成并做了修改,获取命令行如下:
curl -o io_uring_echo_server.c http://www.fastken.cn/test/io_uring_echo_server.ccurl -o io_uring_echo_client.c http://www.fastken.cn/test/io_uring_echo_client.c编译命令如下:
gcc -Wall -O2 -o io_uring_echo_server io_uring_echo_server.c -luringgcc -Wall -O2 -o io_uring_echo_client io_uring_echo_client.c -luringio_uring 相关封装在基础库 libfastcommon 的 ioevent.[hc]和 ioevent_loop.[hc]中,以及网络框架库libserverframe 的 sf_nio.c 中,供感兴趣的朋友参考。
io_uring 是异步 IO 机制,IO 操作提交到请求队列,IO完成后会通过完成队列得到cqe,结果存放在 cqe->res 中,比如读取或写入的字节数、建立连接后得到的 socket fd 等,负数表示错误(负的错误码)。
因为 io_uring 和 RDMA verbs 均为纯异步 IO方式,IO 操作提交给 io_uring / verbs 后尚未完成前,对应的buffer 需要保持有效(不能被释放或被复用),推荐采用引用计数的方式来保证 buffer 的有效性。
io_uring_prep_poll_multishot 通常用于监听 fd 读写事件, 只需调用一次即可多次触发事件通知,在连接关闭等情况下不再需要事件通知时,需要显式调用 io_uring_prep_cancel 取消,此时至少会收到两条通知,一条为cancel 操作的完成通知(通常cqe->res 为 1,表示成功取消了一个 IO 请求/操作),而另一条则为poll_multishot操作的完成通知(cqe->res 为 -ECANCELED,表示该请求已被取消)。
io_uring_prep_xxx 函数会将seq->flags 设置为 0,因此一定要在io_uring_prep_xxx 后设置 seq->flags,比如:
struct io_uring_sqe *sqe = io_uring_get_sqe(&ioevent->ring);
if (sqe == NULL) {return ENOSPC;
}io_uring_prep_cancel_fd(sqe, fd, 0);
/* set sqe->flags MUST after io_uring_prep_xxx */
sqe->flags = IOSQE_CQE_SKIP_SUCCESS;提升文件读写性能,可以使用 io_uring_prep_read_fixed 和 io_uring_prep_write_fixed,使用到的 buffer 需要事先注册。有两种注册方式:一次性注册和按需注册。一次性注册 API 为io_uring_register_buffers 和 io_uring_register_buffers_tags,使用其中之一即可;按需注册先使用 io_uring_register_buffers_sparse 占位,然后使用 io_uring_register_buffers_update_tag按需更新一个或多个buffer。注意:对于同一个 ring,注册 buffer 的 API 只能调用一次(除非取消注册后重新注册),而不是大模型回答的可以调用多次。io_uring_prep_read_fixed 和 io_uring_prep_write_fixed的最后一个参数 buf_index 使用的是基于0的 buffer 索引号。
友情提示:
由于内核限制,io_uring_queue_init 的 entries 上限为32K,io_uring_register_buffers 和 io_uring_register_buffers_tags注册的 buffer 数量上限为 16K;
在采用共享buffer 池(即内存池)下,一个 buffer 可以注册到多个 ring;
io_uring_register_buffers_update_tag 在修改 buffer 的情况下,将收到完成通知, 此时cqe->user_data 为原有tag 值(即旧tag的值)。一次性调用 io_uring_register_buffers_sparse 占位,然后调用io_uring_register_buffers_update_tag 首次设置其中的 buffer 不会触发通知。如果 buffer 数超过了注册上限,则回退为 io_uring_prep_read 和 io_uring_prep_write;
io_uring 的网络接收和发送数据的行为模式与传统的 recv 和 send 完全一致,比如接收较大的数据包(如 64KB),通常需要多次调用 recv 才可以完成整个数据包的接收。
尝试以零拷贝方式发送数据的函数为io_uring_prep_send_zc,目前的虚机和大多数网卡都支持内核的零拷贝发送方式。调用io_uring_prep_send_zc 后会收到两条通知:一条为发送情况通知(发送的字节数,0 字节表示发送失败);另外一条为发送完成通知,对应的cqe->flags会有 IORING_CQE_F_NOTIF标记(即 (cqe->flags & IORING_CQE_F_NOTIF) != 0),收到发送完成通知后方可释放或复用buffer。
io_uring_prep_send_zc 函数的最后一个参数传递 IORING_SEND_ZC_REPORT_USAGE,表示需要报告是否实现了零拷贝。当接收到发送完成通知时,可以通过 cqe->res & IORING_NOTIF_USAGE_ZC_COPIED 判断零拷贝情况,(cqe->res & IORING_NOTIF_USAGE_ZC_COPIED) == 0 表示实现了零拷贝。经过实测,同一台虚机内通信不会零拷贝,跨服务器通信才有可能零拷贝。
最后总结一下使用io_uring 的高性能做法:
- 多次io_uring_prep_xxx 后,调用一次 io_uring_submit 实现批量提交;
- io_uring_wait_cqe 或 io_uring_wait_cqe_timeout 成功后,调用 io_uring_for_each_cqe 遍历所有 cqe;
- 如果网卡支持内核的零拷贝机制,尽量使用io_uring_prep_send_zc发送数据,否则直接使用io_uring_prep_send。
