资讯动态

io_uring在C++网络引擎中的异步Accept与零拷贝读写实践

发布时间:2026/10/23 3:28:08 来源:尧图企业网站定制
大概在两年前我第一次在一台双路服务器上跑纯 C 写的连接转发服务压到 80 万并发连接时整个进程的 CPU 已经快被epoll_wait和accept4吃光了。那时候我就意识到传统的多路复用模型在连接密度上已经到了天花板。后来切换到 io_uring同一台机器、同样的转发逻辑CPU 直接下降了接近 40%而且 read/write 路径上的内存拷贝开销明显变小。这篇就把我在 C 网络引擎里落地异步 Accept 和零拷贝 Read/Write 组合的完整过程写出来包括原理、关键代码、以及压测时踩过的坑。如果你已经写过 epoll 服务对SO_REUSEPORT、水平触发/边缘触发这些概念不陌生那这篇文章就是给你看的。如果刚接触 io_uring也不用担心我会把底层的工作方式先讲清楚保证你能听懂后面每一段代码在干什么。1. 为什么 C 网络引擎要在 epoll 之外选择 io_uring1.1 epoll 在高并发下的三个隐藏瓶颈很多人觉得 epoll 已经是 Linux 下高性能网络编程的终点了但在连接密度极高、单连接收发频繁的场景下epoll 有三个绕不开的开销。第一个是系统调用次数每有一个客户端连接建立你至少要做一次accept4每收一次数据要做一次read每发一段数据要做一次write再加上定期epoll_wait返回事件越密集陷入内核态的频次就越高。每个系统调用都有参数校验、文件描述符表查找、权限检查这些固定成本单次看着便宜量大了就是纯开销。第二个是数据路径上的内存管理成本。传统的read/write每次都要把用户态缓冲区对应的内存页临时固定住让 DMA 引擎安全地读写操作完成后再解除固定。这个过程叫get_user_pages/unpin_user_pages在高频小包场景下固定/解固定页面的开销甚至比数据拷贝本身还大。第三个问题更难发现epoll 的语义是“通知后你再发起 IO”也就是说内核负责告诉你“可读了”但真正读数据还得你再做一次系统调用。这中间多了一次上下文切换和一次调度延迟协议越复杂积少成多。1.2 io_uring 的模型差异省掉的不只是系统调用io_uring 解决这三个问题的思路很直接与其每次操作都通过系统调用进内核不如在内核和用户态之间建立两个共享内存环形队列。用户态往提交队列SQ里写入一条条操作请求每个请求叫 SQE内核执行完后把结果写入完成队列CQ每个结果叫 CQE。SQE 和 CQE 都支持批量你可以一次性提交几十个操作再一次性收割几十个结果。多批量就少系统调用这是它最直观的收益。更关键的是io_uring 的操作本身就是异步的你提交一个IORING_OP_ACCEPT内核会一直监听这个操作有新连接进来时内核直接完成 accept把新 fd 放在 CQE 里返回。这个过程中应用层不用在 accept 上阻塞也不用来回轮询回调驱动模型天然就支持。另外io_uring 提供注册机制可以预先告诉内核“哪些文件我要频繁操作”“哪些内存缓冲区我要反复使用”。注册过的文件在后续 IO 时直接用索引定位省掉了每次的 fd 查找注册过的内存则由内核提前固定好页表数据来到时 DMA 直接读写这些预注册区域这才是标题里“零拷贝 Read/Write”能成立的底层基础。1.3 什么时候该用 io_uring什么时候不该用io_uring 也不是万能药。我的判断标准很简单如果你的服务是“连接数少、单连接数据量大”的下载/上传型业务epoll 线程池 大块读写已经完全够用迁移收益不大如果你做的是短连接密集、每秒新建连接几万甚至十几万、单连接只收发几 KB 数据的网关或代理那建议尽早换。还有个前提是内核版本。io_uring 在 Linux 5.4 才稳定5.10 之后IORING_OP_ACCEPT和固定缓冲区的各种边缘情况才被修得差不多。生产环境我建议至少 5.10如果要用到真正意义的IORING_OP_SEND_ZC发送零拷贝则需要 5.19 以上。低版本内核不是不能用但你会踩到很多需要自己绕过的问题得不偿失。2. 异步 Accept 的落地IORING_OP_ACCEPT 与连接风暴处理2.1 从 accept 到 io_uring_prep_accept 的改动要点传统写法是监听 socket 之后在循环里accept4拿到新 fd然后设置非阻塞再扔给 epoll 管理。用 io_uring 之后你不再亲自调 accept而是往 SQ 里塞一条IORING_OP_ACCEPT请求由内核帮你监听。liburing 封装了对应的io_uring_prep_accept常见的用法是这样#include liburing.h struct io_uring ring; void setup_ring() { io_uring_queue_init(1024, ring, 0); } void submit_accept(int listen_fd) { io_uring_sqe* sqe io_uring_get_sqe(ring); // 在 sqe-user_data 里记录本次请求的身份标识 // 比如用一个整数标记“这是 accept 请求对应的连接序号” sqe-user_data ACCEPT_OP_MARKER; // 关键参数 // 第一个参数是监听 fd // 第二个参数是 sockaddr 输出缓冲区用来拿到客户端地址 // 第三个参数是 addrlen // 第四个参数是 accept 的标志位可以传 SOCK_NONBLOCK io_uring_prep_accept(sqe, listen_fd, nullptr, nullptr, SOCK_NONBLOCK); // 提交队列会攒请求真正要内核执行时再 submit io_uring_submit(ring); }注意到这里传了SOCK_NONBLOCK这一点很重要。io_uring 内核实现的 accept 返回的新 fd 默认继承监听 fd 的状态。如果你希望新连接走异步 IO 而不是阻塞读写最好在 accept 请求级别就设置非阻塞标志省去后续每个连接再调用一次fcntl。提交 accept 操作之后应用层不能停在那里干等需要继续跑事件循环。CQE 返回时cqe-res就是新客户端的 fd。如果cqe-res 0说明 accept 失败比如监听 fd 被关了或者文件描述符表满了。压测时我遇到过 fd 满导致的 accept 失败cqe-res返回-EMFILE这也可以作为排查资源耗尽的信号。2.2 多队列场景下 Accept 事件归属问题单线程用 epoll 实现高并发服务时很多人会 fork 多个进程并设置SO_REUSEPORT让内核把新连接分散到不同进程。io_uring 的场景里我更喜欢“多线程 每个线程独立 ring”的方案。每个线程有自己的 SQ 和 CQ线程内部完全无锁处理自己的连接跨线程的负载均衡交给内核。但这里有个新问题如果只有一个 accept 请求挂在某个线程的 ring 里而另外几个线程没有挂 accept 请求新连接来了只有那个线程在处理吞吐会受限于单线程。解决方法是每个线程的 ring 都提交多个 accept SQE并且持续补充。新连接进来后内核从该线程对应的可接受队列里取一个完成再在用户态把新 fd 加入这个线程自己的连接集合里。多 accept SQE 还有一个好处当瞬时连接风暴来临时内核可以同时完成多个 accept 请求CQ 里一批一批返回 fd。老代码在 epoll 时代经常出现 accept 单热点就是因为在极端流量下accept4调用频率成了瓶颈而 io_uring 的批量 accept 天然缓解了这个问题。我实测在 8 线程、每线程 4 个 accept SQE 的情况下单机每秒建立连接数是 epoll 模型的 2.5 倍左右。2.3 连接对象与固定文件表Accept 返回后的第一次交互在新 fd 上手之后第一个要做的往往不是立刻read而是把它注册到 io_uring 的固定文件表里。固定文件表本质是一个struct file*数组内核持有每个文件的引用指针后续 IO 操作可以不传 fd 号而是传这个数组的索引。我在引擎里维护了一个连接对象池每个连接对象有一个slot_index字段表示它在固定文件表中的槽位。accept 完成后通过IORING_REGISTER_FILES_UPDATE把新 fd 写入空闲槽位。之后所有 read/write 请求都用sqe-fd slot_index并且给sqe打上IOSQE_FIXED_FILE标志。这样做的收益有两个层面。第一省去了每次 IO 时内核根据 fd 号查找文件结构的过程在高频小包场景下这部分开销占比不低。第二也是最关键的一点固定文件表持有的是文件引用即使应用层把原来的 fd 关闭了只要拿槽位索引继续发 IO 请求内核依然可以操作那个文件这为实现连接优雅关闭留出了空间。后面讲坑的时候我还会回到这里因为它同时也是一个很容易踩雷的地方。3. 零拷贝 Read/Write 组合注册 Buffers 与注册 Files 的配合3.1 固定缓冲区减少的不是 CPU 拷贝是页面映射开销标题里的“零拷贝”需要解释清楚不然容易产生误解。传统 IO 路径上有两种能被优化的开销一种是数据复制到内核页缓存再复制到用户缓冲区的拷贝开销另一种是用户缓冲区在 DMA 过程中被固定、解除固定的页表管理开销。io_uring 的 READ_FIXED/WRITE_FIXED 并不消除前一种拷贝它消除的是后一种。什么叫固定缓冲区你在启动时先调用io_uring_register_buffers把一个struct iovec数组告诉内核内核提前尝试把这些内存区域对应的页固定住之后用IORING_OP_READ_FIXED/IORING_OP_WRITE_FIXED做 IO 时内核就不需要再针对这个内存区域做页的固定和解除固定操作。很多人不了解为什么这很重要。小包网络 IO 往往只有几十字节数据但每次固定和解固定一个页面的开销是固定的常数与数据量无关。高频小包场景下这个常数开销被无限放大。固定缓冲区相当于把这个常数开销从每次 IO 转移到了启动时的注册阶段而且是多块内存批量注册均摊成本极低。注册缓冲区的代码非常简单struct iovec iov[BUFFER_POOL_SIZE]; // 池子里的每块内存都要按页对齐 for (int i 0; i BUFFER_POOL_SIZE; i) { posix_memalign(iov[i].iov_base, 4096, BUFFER_SIZE); iov[i].iov_len BUFFER_SIZE; } int ret io_uring_register_buffers(ring, iov, BUFFER_POOL_SIZE); if (ret 0) { // 必须处理常见错误是 EINVAL原因后面详细说 fprintf(stderr, register buffers failed: %d\n, -ret); }注册完成之后就可以通过io_uring_prep_read_fixed和io_uring_prep_write_fixed提交读写请求了。这两种操作不再需要每次传 iov 结构而是传固定的缓冲区索引void submit_read_fixed(connection* conn, int buffer_slot) { io_uring_sqe* sqe io_uring_get_sqe(ring); // 内部会设置 opcode 为 IORING_OP_READ_FIXED // fd 参数传固定文件表索引 // buf 参数传固定缓冲区数组下标 io_uring_prep_read_fixed(sqe, conn-file_slot, iov[buffer_slot].iov_base, BUFFER_SIZE, 0, buffer_slot); sqe-user_data (uint64_t)conn-conn_id 32 | buffer_slot; io_uring_submit(ring); }如果你要处理的是大文件转发还有更进一步的IORING_OP_SEND_ZC可用那是从 5.19 开始提供的发送零拷贝内核会直接把用户态缓冲区页引用给网卡 DMA应用层必须等 CQE 返回后才能复用缓冲区。这是真正的“零拷贝发送”适合大块响应发送场景。我在引擎里对小包走 WRITE_FIXED对大块响应走 SEND_ZC实测转发吞吐还能再涨一截。3.2 注册文件表让 IO 路径避开 fd 查找固定文件表在上一节已经提到过它解决的问题是“IO 路径上的文件查表开销”。传统read(fd, ...)进入内核之后必须先通过当前进程的文件描述符表找到对应的struct file再取引用、做权限检查。固定文件表相当于把这部分工作提前做成了索引数组运行时不再需要查表。注册文件表和注册缓冲区经常被同时使用因为 READ_FIXED/WRITE_FIXED 两种操作都需要“固定缓冲区 固定文件”两个前置条件。我在引擎里的做法是在 accept 完成之后构造连接对象时就把 fd 注册到固定文件表的空闲槽位。固定文件表本身是定长数组所以连接对象的复用至关重要。这里需要注意一个细节IORING_REGISTER_FILES_UPDATE可以把指定槽位的文件替换成另一个文件也可以清空槽位。连接关闭后我会用-1填充原槽位让内核释放对文件的引用。这个动作看似简单但忘记做的话连接文件会被内核一直持有最终导致文件描述符泄漏这在第 5 节会展开说。3.3 一个完整的 Read/Write 状态机伪代码把 accept、固定文件表、固定缓冲区组合起来一个连接的 IO 流程可以这样组织enum conn_state { STATE_ACCEPTED, STATE_READING, STATE_WRITING, STATE_CLOSING }; struct connection { uint64_t conn_id; int file_slot; // 固定文件表槽位 int buffer_slot; // 当前使用的固定缓冲区槽位 int data_len; conn_state state; }; void on_accept_complete(const cqe* cqe) { int new_fd cqe-res; int slot alloc_file_slot(new_fd); auto* conn new connection; conn-file_slot slot; conn-state STATE_ACCEPTED; // 起始就提交第一次 read用固定缓冲区的某个槽位 submit_read_fixed(conn, buffer_slot); } void on_read_complete(const cqe* cqe) { auto* conn get_conn(cqe-user_data); int bytes_read cqe-res; if (bytes_read 0) { close_conn(conn); return; } // 业务层处理完请求组织好响应 submit_write_fixed(conn, conn-buffer_slot, response_len); conn-state STATE_WRITING; } void on_write_complete(const cqe* cqe) { auto* conn get_conn(cqe-user_data); // 写完之后赶紧把这块缓冲区释放回池子 // 再提交下一次 read release_buffer(conn-buffer_slot); submit_read_fixed(conn, alloc_buffer_slot()); }这个状态机的关键是“一个阶段只发一个 SQE”。READ 完成之前不会发下一条 READWRITE 完成之前不会发下一条 WRITE。有的框架为了追求极端性能会同时挂多个 READ SQE但从可维护性角度一个 SQE 一个状态是最容易推理的。等整个链路跑稳定了再逐步做并发 READ 的优化。3.4 零拷贝组合的取舍与实测对照缓冲区池和文件表的设计本质上是一种资源所有权转移IO 请求发出后这块缓冲区的所有权暂时归内核内核通过 CQE 宣告完成时所有权才回到应用层。这个过程避免了一部分拷贝但引入了“所有权跟踪”的复杂度。我整理过一组对照组数据同一台机器、同样的 8 线程模型压测场景是 10 万并发短连接、每连接 4KB 请求 4KB 响应模式CPU 用户态占比CPU 内核态占比平均延迟 p99备注epoll read/write32%46%1.8ms系统调用与页固定开销明显io_uring readv/writev38%33%1.2ms批量提交减少系统调用io_uring READ_FIXED/WRITE_FIXED FIXED_FILE45%22%0.9ms页固定与 fd 查找都被消除内核态占比从 46% 降到 22% 是最大的惊喜。这验证了 io_uring 的收益大头不在用户态而在内核路径上的固定开销优化。顺带说一句io_uring的readv版本依然比传统 epoll 好因为它支持批量进入内核但只有加上固定缓冲区才真正把页管理开销压到最低。4. 事件循环骨架提交与收割的节奏控制4.1 批处理提交攒够一批 SQE 再 io_uring_enterio_uring 的性能优势很大一部分来自批处理。每次调用io_uring_submit都有可能触发系统调用如果不加约束地“拿到一个 SQE 就提交一次”效果就退化成和传统模型差不多的每操作一次系统调用。我的做法是维护一个待提交队列。业务层产生 IO 请求时先统一写入这个队列事件循环每次迭代时把队列里所有请求转换成 SQE 填入 SQ然后一次io_uring_submit批量进入内核。从实际效果看每批提交 100~200 个请求时系统调用次数只有原来 epoll 模型的几十分之一延迟却没有任何劣化因为内核处理这些 SQE 本来就是异步并行的。void drain_pending_requests() { if (pending_ops.empty()) { return; } for (op* op : pending_ops) { io_uring_sqe* sqe io_uring_get_sqe(ring); fill_sqe_from_op(sqe, op); sqe-user_data make_user_data(op); } io_uring_submit(ring); pending_ops.clear(); }有一个细节容易忽略io_uring_get_sqe有可能返回空因为 SQ 队列深度是固定的。所以在循环里必须检查返回值如果返回nullptr说明 SQ 满了这时要立刻io_uring_submit让内核消费掉一批再说。我见过不少初写 io_uring 的代码没有这个判断结果是静默丢请求。4.2 CQE 收割与业务分发从完成事件回到连接上下文CQ 队列的收割分两种模式阻塞等待和轮询。阻塞模式调用io_uring_wait_cqe当前线程进入睡眠直到至少一个 CQE 到达轮询模式则是不断用io_uring_peek_cqe检查适合对延迟极敏感的场景。我默认采用阻塞等待加超时控制用io_uring_wait_cqe_timeout限定最长等待时间这样既能快速响应完成事件又能让事件循环有机会处理定时器任务。每个 CQE 里最关键的是user_data字段。这个字段由提交方自己定义它决定了内核返回完成事件后用户态如何定位到具体连接和具体操作。我设计 user_data 的编码方式是高 32 位放conn_id低 16 位放 buffer_slot再留 16 位放操作类型或序号。这比裸存指针安全因为在缓冲区池复用的场景下裸指针很容易指向一个已经被回收的内存块。处理完一个 CQE 之后不要忘记调用io_uring_cqe_seen这一步是把该 CQE 从 CQ 队列里标记为“已消费”。漏掉这个调用CQ 队列会被未消费的事件占满最终导致内核无法再提交新完成事件整个引擎会卡死。这个错误在日志里非常难定位因为表现只是“突然没有新事件了”。4.3 超时、可退出信号与优雅关闭事件循环不是无限死循环。业务上常用超时来清理不活跃连接如果某个连接 30 秒没有数据收发就应该关闭。io_uring 提供了IORING_OP_TIMEOUT操作可以提交一个定时请求内核在指定时间后返回一个 CQE。把这个超时请求和普通 IO 请求放在同一个 ring 里事件循环就统一了“等待 IO 完成”和“等待超时到期”两条路径不需要再额外维护一个定时器线程。关闭引擎时最忌讳的是直接把 ring 销毁了事。在销毁io_uring_queue_exit之前必须确保所有已提交的非完成操作都已经被取消或妥善处理。我的做法是先把所有连接标记为 CLOSING提交对应的关闭操作再调用io_uring_queue_exit让内核回收所有资源。如果直接退出未完成的固定文件表和固定缓冲区注册会被强制释放但应用层可能还持有着已经失效的连接对象指针这种状态很难排查。5. 压测中踩过的坑与排查链路5.1 连接无法关闭io_uring 持有没有释放的文件引用第一次压测短连接风暴时我发现一个诡异现象客户端全部断开后服务端的并发连接数居然不掉一直停留在几十万左右。用lsof查看进程打开的 fd发现大量已关闭的客户端连接依然被进程持有。最开始以为是 accept 返回的 fd 没关闭检查了业务代码后发现关闭逻辑没有问题——close(fd)确实调了。后来才意识到问题出在固定文件表。你把新 fd 注册进固定文件表之后内核持有的是struct file指针引用应用层close(fd)只是把文件描述符表中的条目删除文件本身并没有被释放因为内核还握着引用。也就是说固定文件表里的槽位不清理连接就不算真正关闭。排查链路是先看 fd 数量有没有回落 → 确认 close 调用正常 → 确认固定文件表中是否有残留槽位 → 用IORING_REGISTER_FILES_UPDATE把对应槽位更新为-1并重新压测。把这个补丁打上之后fd 数量曲线立刻变得健康。这个坑提醒我固定文件表的槽位管理要跟连接对象的生命周期强绑定连接关闭时第一件事就是清槽位而不是先 close fd。5.2 固定缓冲区注册经常 EINVAL对齐与长度问题io_uring_register_buffers在压测环境里偶尔返回-EINVAL而且不是稳定复现排查了很久才发现有几个条件必须同时满足第一每块缓冲区起始地址必须页对齐通常用posix_memalign分配 4KB 对齐的内存第二每块缓冲区的长度必须是页大小的整数倍我用 16KB 的单块长度就没有问题第三struct iovec数组本身可以动态分配但里面的iov_base和iov_len必须都满足对齐。还有一个容易踩的是注册数量。注册缓冲区数量没有硬性上限但如果一次注册太多内核分配页描述符时也可能失败。我经过测试单 ring 注册 1024 块 16KB 的缓冲区内存在实践中非常稳定。如果业务需要更大的容量最好按需调整不要一次性注册几万个缓冲区。当遇到-EINVAL时常规排查顺序是检查对齐 → 检查长度 → 检查内核日志。内核在注册失败时往往会在 dmesg 里留下更详细的原因这个线索经常被忽略。5.3 完成事件错乱user_data 与缓冲区生命周期有一次压测回包有明显的串包现象A 客户端发来的请求响应居然发给了 B 客户端。这个问题一度让我怀疑是业务层状态机写错了打印日志之后发现CQE 里的user_data解码出来的 conn_id 确实是错的。逐层排查后原因是缓冲池的分配策略太激进。一个连接完成 read 之后缓冲区立刻被释放回池子另一个新连接马上拿到了同一个缓冲区槽位。此时旧连接的 WRITE_FIXED 请求还没有被内核执行完但它的缓冲区内容已经被新连接用新的请求数据覆盖了。内核执行旧请求时实际上发送的是新数据所以 B 客户端收到了 A 的响应。修复方式有两种我最终采用的是“写后释放”WRITE 请求的 CQE 没有返回之前缓冲区绝对不能归还池子。也就是说缓冲区的所有权要一直跟随那个 WRITE 请求直到 CQE 明确宣告完成。我在 user_data 里存了 buffer_slot 和操作类型CQE 处理函数里严格校验这些信息确保只有 WRITE 完成才能释放缓冲区。这个调整之后串包问题立刻消失。5.4 SQPOLL 模式的一个性能误区测试IORING_SETUP_SQPOLL是 io_uring 的扩展模式内核里跑一个专门的线程不断轮询 SQ 队列应用层提交 SQE 后甚至不需要触发系统调用内核线程自己就会取走执行。听起来很美好但我在纯转发场景实测发现SQPOLL 模式下 CPU 占用反而比普通模式高出 8%~10%而且延迟的抖动更明显。原因在于内核轮询线程本身要吃 CPU而且当 SQ 队列长时间空闲时它需要进入 idle 状态然后再被唤醒这个唤醒路径在真实业务的突发流量下反而比普通io_uring_submit的系统调用更贵。只有在极高频、持续不断提交请求的场景下SQPOLL 省去系统调用的收益才能覆盖轮询线程的成本。我做了一个区分测试表格贴在下面压测模型普通模式吞吐SQPOLL 吞吐结论持续大流量转发28 万 QPS27 万 QPS几乎持平突发小包 500 QPS稳定延迟 0.8ms波动延迟 1.6msSQPOLL 反而更差这个测试让我放弃了在通用网络引擎里默认开启 SQPOLL 的想法只在专门的持续高吞吐代理里按需打开。普通 C 网络引擎用好“批处理提交 阻塞收割”的组合已经能拿到 io_uring 大部分收益还不用承担轮询线程的调度开销。按我自己的习惯新项目接入 io_uring 的顺序通常是先用 liburing 封装出一个最小的事件循环跑通 accept read write 三个基础操作然后加注册缓冲区池和固定文件表把零拷贝组合接上最后再根据压测结果决定要不要引入 SQPOLL 或者 SEND_ZC。这个路径每一步都能独立验证性能收益出问题也容易定位。中间那个串包的坑更是让我彻底理解了异步编程里“资源所有权”不是概念而是硬约束希望看到这篇文章的人别在同样的地方交学费。

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

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

免费获取报价 →
↑