资讯动态

从epoll到io_uring:Linux异步IO框架底层设计与实战指南

发布时间:2026/9/26 13:51:56 来源:尧图企业网站定制
1. 为什么我会从epoll转向io_uring一次压测逼出来的选择如果你常年写Linux服务端程序多半和我一样对epoll早就形成了肌肉记忆。事件循环、红黑树、回调、边缘触发……这套模型本身没错但在某个时间点你会遇到一个很尴尬的问题连接数和吞吐量上去了CPU占用也上去了但IOPS和延迟并没有按预期变好。我大概是在做一版存储引擎原型的时候被这个问题堵死的当时用fio和自研客户端分别压了epoll驱动下的随机读写发现性能曲线无论如何也压不满NVMe盘的潜力。任务切换、系统调用、数据拷贝、fd查询这些开销在低频场景里完全不是事一旦进入高并发高吞吐区间全部变成了拦路虎。也就是从那时候开始我认真去研究Linux内核里的io_uring机制。它不是一个用户态库的小技巧而是从内核IO路径彻底翻新的异步IO框架。简单说io_uring允许你先把一批IO请求放到一个内核和用户共享的环形队列里内核批量消费、批量完成你再从另一个环形队列里批量收割结果。整个过程里系统调用次数被压缩到极低甚至在特定模式下可以完全绕开系统调用。对做存储中间件、数据库内核、消息队列、代理网关这类需要压榨单机IO能力的开发者来说这基本是必看的内容。这篇文章不是内核源码逐行注释也不是把io_uring文档翻译一遍。我会按自己的理解把底层设计、liburing最常用的API走读、多种进阶模式怎么选、以及我踩过的几个真实坑完整写下来。看完你至少能判断自己的场景适不适合切io_uring以及第一版原型该怎么搭。2. io_uring底层设计把开销藏在了哪里环形队列与三套机制拆解2.1 传统IO路径到底贵在哪在展开io_uring之前先明确一下我们要消灭的是什么。一次传统的read()CPU要经历的工序大概是从用户态切到内核态模式切换、根据fd查找文件描述符对应的file对象、做文件锁/偏移量检查、把用户缓冲区对应的物理页pin住get_user_pages、执行实际的块设备或网络协议栈IO、把数据从内核缓冲区拷贝到用户缓冲区read这一类通常需要拷贝、最后切回用户态。这一套下来即便没有真正落盘纯机械开销也是几百纳秒到微秒级别。如果逻辑IO很小系统调用本身的开销甚至能占到整个时延的大头。更麻烦的是epoll只是解决了“怎么知道IO就绪”的问题。它告诉你某个fd可读可写了但真正的读写动作仍然要你逐个系统调用去执行。也就是说在高频小IO场景下每次IO至少还是要有一次等待事件一次实际读写的系统调用路径。io_uring的野心是把“准备请求、提交请求、收割结果”三段全部批量化和异步化让内核的IO处理像一个运转中的流水线一样不再频繁被打断。2.2 共享内存环形队列SQ与CQ如何配合io_uring的核心是两块内核与用户共享内存的环形队列SQsubmission queue和CQcompletion queue。SQ保存的是你提交的请求每个请求是一个SQEsubmission queue entry里面描述了这次IO的类型、fd、缓冲区地址、长度、偏移量等信息CQ保存的是内核处理完的完成事件每个事件是一个CQE里面记录了这个请求对应的user_data和返回值。两个队列都是典型的无锁单生产者单消费者模型靠head和tail指针推进。你可以把它理解成一个回转寿司店SQ是传送带入口你把一碟一碟的寿司SQE放上去内核师傅在传送带另一端批量取走做完之后把成品CQE放到另一条出口传送带你坐在出口那拿就行了。放菜和取菜互不阻塞师傅也不会因为某碟寿司没做好就堵住整条传送带。具体到指针协作上用户把SQE写入SQ队列的尾部位置提交后更新SQ tail内核消费一个就推进SQ head。完成之后内核写CQE并更新CQ tail用户消费CQ时推进CQ head。两个方向的指针通过适当的屏障保证顺序可见性没有锁竞争。这套设计是它性能好的根本原因之一一次io_uring_enter调用可以同时提交一整批SQE再顺手取回一批CQE上下文切换次数被压缩到几乎和批次数相等。2.3 注册机制把高频操作的“查找成本”提前预付io_uring还提供了一套注册register接口。最常见的是文件注册和缓冲区注册。你可以提前把自己频繁使用的文件描述符批量注册到内核里之后提交请求时直接用一个下标索引指向fd表内核就不需要每次去全局fd表里做查找同理提前注册好一组缓冲区后内核可以预先完成用户地址空间的映射和页面的pin操作后续每次IO都复用这份映射避免反复做页表遍历和get_user_pages。这个设计的本质是把“每次都要付的钱”变成“一次打包预付”。对随机小IO非常划算因为单次请求的固定开销被大幅摊薄。后面我会单独讲fixed file和fixed buffer的具体用法这里先建立概念。2.4 三种内核处理模式默认、SQPOLL与IOPOLLio_uring支持的关键内核模式有三个很多人在刚接触时容易混淆默认模式没有任何额外flag。你提交SQE之后通常需要调用io_uring_enter让内核处理新提交的请求然后等待CQE。虽然没有锁竞争但系统调用还是存在只不过频次大幅降低。SQPOLL模式IORING_SETUP_SQPOLL内核为这个ring单独创建一个内核线程负责不停轮询SQ尾部是否有新请求。如果一切正常你的提交动作是纯用户态操作——把SQE丢进共享队列更新tail剩下的交给内核线程完全不需要进入内核。代价是机器上多了一个常驻内核线程CPU开销会上去。IOPOLL模式IORING_SETUP_IOPOLL针对支持轮询的块设备典型如NVMe内核不再依赖硬件中断通知IO完成而是主动去轮询设备完成队列。配合应用侧的自己控制提交与收割节奏可以把完成延迟压得非常低代价同样是CPU被占用。这三个模式不是互斥的唯一选项实际项目里可以组合。比如你可以在启用SQPOLL的同时对某些设备使用IOPOLL但工程上很少有人同时开多数场景还是按需选一个。后面我会给一个对比表。3. 从零写一个可运行的io_uring文件读写liburing常用API走读3.1 为什么我建议直接用liburingio_uring的内核接口很简洁但直接用系统调用裸写不排除是学习底层的好办法实际开发时效率偏低。liburing是内核社区维护的用户态封装库把sqe的准备、提交、收割封装成了很顺手的API并且处理了不少内核版本差异。我见过的生产项目里几乎没有不用liburing裸写io_uring的所以这篇文章的代码示例全部基于liburing。安装很简单绝大多数发行版都有liburing包也可以通过源码编译。如果从源码编译记得用与当前内核版本接近的分支否则某些新feature可能在旧内核上跑不通。编译链接时加-luring头文件用liburing.h。3.2 初始化与第一次read我们先写一个最简单的例子异步读取某个文件的前4096字节并打印。重点看整个请求的生命周期。#include stdio.h #include stdlib.h #include fcntl.h #include unistd.h #include liburing.h #define QUEUE_DEPTH 8 int main(int argc, char **argv) { if (argc 2) { fprintf(stderr, usage: %s file\n, argv[0]); return 1; } struct io_uring ring; int ret io_uring_queue_init(QUEUE_DEPTH, ring, 0); if (ret 0) { fprintf(stderr, io_uring_queue_init failed: %s\n, strerror(-ret)); return 1; } int fd open(argv[1], O_RDONLY); if (fd 0) { perror(open); io_uring_queue_exit(ring); return 1; } char buf[4096]; struct io_uring_sqe *sqe io_uring_get_sqe(ring); if (!sqe) { fprintf(stderr, io_uring_get_sqe failed\n); close(fd); io_uring_queue_exit(ring); return 1; } io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0); io_uring_sqe_set_data(sqe, NULL); io_uring_submit(ring); struct io_uring_cqe *cqe; ret io_uring_wait_cqe(ring, cqe); if (ret 0) { fprintf(stderr, io_uring_wait_cqe failed: %s\n, strerror(-ret)); close(fd); io_uring_queue_exit(ring); return 1; } if (cqe-res 0) { fprintf(stderr, read failed: %s\n, strerror(-cqe-res)); } else { printf(read %d bytes: %.*s\n, cqe-res, cqe-res, buf); } io_uring_cqe_seen(ring, cqe); close(fd); io_uring_queue_exit(ring); return 0; }每个接口我简单捋一下io_uring_queue_init(entries, ring, flags)创建ring。第一个参数是队列深度liburing会处理成合理的环形队列规格第三个参数是内核flag比如IORING_SETUP_SQPOLL。返回值是负数错误码时用strerror(-ret)打印错误信息。io_uring_get_sqe从SQ里拿一个空闲SQE。如果队列满了会返回NULL这时候要么先submit腾位置要么把队列深度调大。io_uring_prep_read把sqe初始化成一次pread操作。注意到这里用户态完全不碰read()系统调用只是往内核共享内存里写一个“愿望单”。io_uring_sqe_set_data把user_data字段绑定到sqe通常是用户态上下文比如请求的序号或结构体指针。收割CQE时通过它知道这个完成事件属于哪个请求。io_uring_submit通知内核消费SQ里的新SQE。实际上多数场景就是一个io_uring_enter系统调用。io_uring_wait_cqe阻塞等待至少一个CQE出现。返回后可以处理一批完成事件。它能同时扮演“提交剩余请求并等待”的角色所以提交等待可以合并成一次系统调用。io_uring_cqe_seen告诉内核这个CQE你已经消费完了CQ中的槽位可以被回收。很多人刚开始容易漏掉这一步导致CQ队列被占满、后续请求迟迟没有完成槽位可用。3.3 多请求批量提交与收割真正的异步姿态单请求例子只能算热身。io_uring的价值在于批量异步你可以一次准备几十个读写请求全部提交然后慢慢收割。下面是一个常见的批量模式for (int i 0; i n; i) { struct io_uring_sqe *sqe io_uring_get_sqe(ring); io_uring_prep_read(sqe, fd, bufs[i], len, offset[i]); io_uring_sqe_set_data(sqe, (void *)(long)i); } io_uring_submit(ring); unsigned int complete 0; while (complete n) { struct io_uring_cqe *cqe; int ret io_uring_wait_cqe(ring, cqe); if (ret 0) { perror(io_uring_wait_cqe); break; } complete; // 根据cqe-user_data定位这是第几个请求 long idx (long)cqe-user_data; if (cqe-res 0) { fprintf(stderr, req %ld failed: %s\n, idx, strerror(-cqe-res)); } io_uring_cqe_seen(ring, cqe); }这里有个实际心得wait_cqe一次只要返回就应该把当前CQ队列里所有可消费的CQE都消费完然后再回到wait。如果一次只消费一个、每次循环都重新wait完成吞吐会比较差。liburing其实还提供了一批批量收割接口比如io_uring_peek_cqe、io_uring_for_each_cqe就是为这个准备的。3.4 数据在内存里如何流转async与缓冲区的生命周期一个很容易踩的坑是缓冲区生命周期。io_uring是异步的submit返回后请求可能还在内核里执行尤其是真发起了磁盘IO或网络IO。应用绝对不能在submit之后立刻复用或释放提交时指向的缓冲区。我见过不止一次因为栈上缓冲区被复用导致读出来全是脏数据的bug。标准做法是要么每个请求绑定独立缓冲区要么等CQE返回确认res之后再复用这块内存。liburing的io_uring_prep_read默认是普通内存。如果你要追求极致的page pin开销削减就得配合固定缓冲区这在后面进阶部分讲。4. 进阶玩法怎么选SQPOLL、IOPOLL、Fixed File、缓冲区注册对比4.1 一张表看懂四种手段的取舍io_uring的进阶优化很容易让人眼花缭乱。我自己整理过一版对比写在这里帮你快速建立判断框架优化手段主要省掉的开销代价/限制适用场景默认模式批量提交系统调用次数、上下文切换仍需进内核固定开销仍在几乎所有异步IO场景Fixed File注册每次请求的fd查找与引用计数注册表有限制fd句柄生命周期要管理高频重复操作同一批文件/连接Fixed Buffer注册用户缓冲区的页表遍历与pin/unpin内存被锁定注册/更新有额外成本大量同类型小IO、固定缓冲区池SQPOLL提交路径的系统调用内核线程常驻CPU消耗上升极高频小IO、目标机器CPU有富余IOPOLL中断处理和完成通知延迟设备需支持轮询CPU占用高NVMe等低延迟块设备场景这张表不是让你全部上而是帮你看清楚每个手段的边际收益和边际成本。我见过很多项目一开始就把所有flag全开结果到处撞限制最后定位问题反而更困难。合理的思路是先用默认模式跑出baseline看性能报告再决定加哪个。4.2 固定文件与固定缓冲区的代码姿势固定文件注册的代码很直白。首先把fd注册进ringint fds[] { fd1, fd2, fd3 }; int ret io_uring_register_files(ring, fds, 3); if (ret 0) { fprintf(stderr, register_files failed: %s\n, strerror(-ret)); }注册之后后续请求的fd参数就不再填真实fd而是填它在注册表中的下标struct io_uring_sqe *sqe io_uring_get_sqe(ring); io_uring_prep_read(sqe, 0, buf, sizeof(buf), 0); // 0是注册表中的下标 sqe-flags | IOSQE_FIXED_FILE;固定缓冲区的注册方式类似struct iovec iov { .iov_base buf, .iov_len sizeof(buf), }; int ret io_uring_register_buffers(ring, iov, 1); if (ret 0) { fprintf(stderr, register_buffers failed: %s\n, strerror(-ret)); }后续请求改用io_uring_prep_read_fixed内核直接通过注册好的iovec完成映射struct io_uring_sqe *sqe io_uring_get_sqe(ring); io_uring_prep_read_fixed(sqe, fd, buf, sizeof(buf), 0, 0);这里要注意注册缓冲区有一个隐含前提buf所在内存页必须能被锁定也就是进程的RLIMIT_MEMLOCK要放得足够大。默认的memlock限制往往很小注册几十MB缓冲区都可能失败。排查时看日志里有没有Cannot allocate memory大概率就是memlock被卡住了用ulimit -l或者调整/etc/security/limits.conf解决。4.3 SQPOLL模式的唤醒细节容易被忽视的NEED_WAKEUP启用SQPOLL时常规提交路径确实不需要系统调用。但内核线程不会一直满负荷轮询它在空闲一段时间后会休眠。休眠之后如果应用继续往SQ里写请求内核线程可能感知不到。内核通过SQ ring里的IORING_SQ_NEED_WAKEUP标志来通知应用现在你去调用一次io_uring_enter把它唤醒。liburing没有把这个细节完全隐藏因为是否检查标志会影响性能。推荐的提交模板是if (*ring.sq.kflags IORING_SQ_NEED_WAKEUP) { io_uring_enter(ring, 1, 0, IORING_ENTER_GETEVENTS, NULL); } else { io_uring_submit(ring); }我第一次跑SQPOLL模式时没有处理这个标志结果机器空转时请求偶尔会凭空卡几百微秒就是因为内核线程休眠后没被及时唤醒。这个唤醒路径同时也是SQPOLL模式延迟抖动的来源之一如果你对延迟非常敏感可以配合把ring绑定到空闲CPU上或者接受它带来的少量延迟。更激进的人会调整内核线程的调度策略但配置起来比较讲究。另外提一句和热词里“cachyos默认内核调度器”相关的事不同发行版对不同内核调度器改动的默认配置会影响SQPOLL内核线程和业务线程之间的调度抢占行为。在桌面发行版上压测SQPOLL时如果发现延迟忽高忽低先看一眼CPU亲和性和调度策略别一股脑全怪在io_uring头上。4.4 IOPOLL的实际效果取决于硬件IOPOLL不是万能的它要求底层设备支持轮询式完成典型的是NVMe设备。当你启用IORING_SETUP_IOPOLL后内核不会走传统中断回调而是主动查询设备完成队列。对于非NVMe或模拟存储这个flag可能带来不了任何收益甚至因为CPU空转导致性能下降。我做过的实际对比中IOPOLL对随机小读的延迟确实有改善但吞吐提升幅度和盘的队列深度密切相关。如果盘已经很忙、中断本来就不密集IOPOLL的收益就变得很有限。建议你在固定硬件上用fio实测一版再决定是否长期开启不要想当然。4.5 用fio快速验证io_uring的收益如果你想先观测io_uring和自己当前方案的差距fio是最快的工具。fio原生支持io_uring引擎fio --nameuring-test \ --ioengineio_uring \ --rwrandread \ --bs4k \ --size1G \ --iodepth32 \ --direct1 \ --numjobs4把ioengine换成sync或libaio跑一遍同样的参数对比一下IOPS和p99延迟基本就能知道自己该不该迁移。注意--direct1要先加否则页缓存会掩盖真实IO性能。如果机器比较老还需要确认内核版本在5.1以上最好用5.10以上的长期支持版本io_uring的稳定性才更有保障。5. 半年实战踩坑清单版本兼容、容器限制与压测陷阱5.1 内核版本与liburing版本错位io_uring是一个演进非常快的子系统新版本内核不断加入新flag和新featureliburing也在同步跟进。最典型的错误是代码里用了某个新加入的ring flag或sqe flag编译链接都没问题但运行时内核根本不认识直接返回EINVAL。排查这类问题先确认内核版本再确认liburing版本两者尽量保持配套。有人把内核从5.10升级到6.x之后原本的liburing旧代码突然在某个新flag上报错这就是版本错位。个人建议在锁定的内核版本上用一个较新的liburing然后避免使用文档里标注为“recently added”的API除非你真的确认内核支持。5.2 io_uring被内核配置或安全模块关掉不是所有内核都默认开启io_uring。嵌入式平台、云厂商自定义内核、以及某些经过安全加固的内核可能根本没打开CONFIG_IO_URING或者用seccomp把这些系统调用拦截掉了。检查方法很简单grep IO_URING /boot/config-$(uname -r)如果看到CONFIG_IO_URINGy就放心了。跑代码之前也可以先用strace看一眼系统调用是否被拦截返回EPERM或EACCES基本就是被安全策略限制了。我确实遇到过容器环境里io_uring_setup直接报错的情况最后定位是容器运行时老版本的seccomp默认策略拦了新的系统调用。升级运行时或者修改容器安全配置后问题消失。如果你是在KVM、云主机这类虚拟化环境里压测还要额外看看宿主机内核和客户机内核的绑定关系部分半虚拟化设备对io_uring的适配程度不如物理机那么理想。这也是热词里“linux内核虚拟化”“虚拟机安装linux”相关开发者容易踩的暗坑虚拟机里的盘大概率是virtio-blk或网络存储IOPOLL在这些虚拟设备上几乎没有用处sqpoll的延迟表现也可能比物理机差。5.3 CQ队列被占满后的连锁反应很多初学者的第一反应是把QUEUE_DEPTH设得很大觉得这样一定能扛更多请求。实际上CQ队列深度有限制如果提交了大量请求但迟迟不消费CQECQ满之后内核无法写入新的完成事件后续请求只能阻塞。更隐蔽的是有些版本中CQ的大小会受IORING_CQ_ENTRY_SIZE之类的影响如果你用了扩展CQE功能队列容量的计算方式会变。解决方法是维持一个稳定的收割循环永远不要让CQ积压到满。实用做法是每次wait_cqe之后用io_uring_for_each_cqe把所有可消费的CQE全部消费完再回到等待点。这个习惯比盲目增大队列深度有用得多。5.4 请求语义的变化read不一定返回你要的长度传统read()是“读到多少算多少”遇到文件尾或短读很自然。io_uring里的read也一样cqe-res表示实际读取的字节数不保证等于你请求的长度。如果你写的是类似“必须读满4096字节才算成功”的逻辑需要自己处理短读必要时在CQE返回后根据user_data重新发起一个补偿读请求。这个细节在做文件解析、网络流处理时特别重要。5.5 网络场景不要照搬存储优化io_uring在网络方向确实能用accept、connect、send、recv都有对应的prep函数。但我实际用下来网络优化收益和存储方向不太一样。存储IO的瓶颈通常在内核IO路径的机械开销io_uring优化得很彻底而网络IO的瓶颈更多落在协议栈处理、中断处理、内存拷贝上io_uring能消掉的系统调用开销占比没有存储场景那么夸张。另外把socket注册成fixed file虽然可以减少fd查找开销但对socket这种生命周期频繁变化的资源注册表的维护成本反而可能抵消收益。我的经验是高并发短连接场景先别上fixed file先靠批量提交和等待合并拿收益长连接海量收发场景再考虑注册sockfd。切勿把保存存储那套优化无脑搬到网络路径。5.6 用perf验证瓶颈而不是猜io_uring不是银弹。如果你的业务其实卡在锁竞争、内存分配或者用户态业务逻辑上换io_uring毫无帮助。我每次做完一版优化都会用perf top和perf stat看一下CPU到底花在哪确认系统调用开销确实被压下去了再去调下一步。很多时候你会发现真正的热点已经转移到了内存分配或同步原语上此时再改io_uring参数意义不大。最后分享一个个人经验不要一上来就追求把所有高级模式全部打开。先用最简单的方式把ring建起来、把read/write跑通、用fio拿到数据确认缺口在哪里再逐步叠加fixed file、fixed buffer、SQPOLL等优化。io_uring本身就是一个可以渐进式加深投入的框架它的设计也支持你从浅到深逐步逼近极限。我见过不少项目在默认模式下就已经拿到了大部分收益真正需要把SQPOLL和IOPOLL同时拉满才能解决的场景其实并没有想象中那么多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑