登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  linux

io_uring 注册缓冲区高并发读写怎样减少重复注册开销为什么结果为空或不符合预期

来源:17golang原创

时间:2026-11-09 11:02:52 208浏览 收藏

我在看高并发读写的 I/O 路径时,最容易误判的一件事是:把“注册缓冲区”也放进每个请求的热路径。io_uring 的 registered buffer 更适合在 ring 初始化阶段完成一次映射和固定,后续请求通过固定缓冲区索引复用它。这样减少的是每次 I/O 的映射、页引用处理和注册管理开销,并不等于所有读写都会自动变快。

官方地址:https://docs.kernel.org/next/filesystems/fuse/fuse-io-uring.html

先注册一组稳定的匿名内存,再用 read_fixed 或 write_fixed 指向正确的 buf_index;如果结果为空或不符合预期,第一时间看 CQE 的 res,再查索引、范围和请求完成前的缓冲区生命周期。
要点速览
  • 注册动作通常属于 ring/worker 初始化,不属于每个请求的循环体。
  • 固定 I/O 仍要满足地址和长度落在对应注册缓冲区内,且使用正确的索引。
  • CQE 的负值是直接返回的负 errno,不应只把它当成“读到 0 字节”。

先把一次性注册从并发热路径移走

普通读写每次都要把用户缓冲区交给内核处理;注册缓冲区则让内核为一组用户内存建立长期映射,后续固定读写直接引用这张表。Linux 内核文档在 FUSE-over-io-uring 的 buffer pool 说明中也采用了“池先注册、请求重复使用”的思路:同一批页可以继续用于后续的固定 I/O。

因此,错误的结构通常是“收到请求 → register buffers → 提交一次 → unregister buffers”。高并发时这会反复触发资源管理,而且注册与注销还必须避开仍在使用这些内存的在途请求。更稳妥的边界是“创建 ring → 分配缓冲区池 → 注册一次 → 多次提交 → ring 退出时统一清理”。

io_uring ring、注册缓冲区表、worker 与固定读写请求的静态关系说明图
图1:结构说明图展示 ring、注册缓冲区表、worker 与 fixed I/O 请求的职责边界,不是运行截图。
对象主要职责常见误区
io_uring ring保存提交队列、完成队列和资源表关联以为注册表跨所有 ring 自动共享
iovec描述注册时的基址和长度提交时使用超出范围的地址或长度
buf_index选择注册表中的固定缓冲区索引与请求实际使用的 buffer 不一致
CQE res表示完成字节数或负 errno只看指针是否为空,不看返回值

固定读写必须同时满足索引和范围

注册成功并不会让普通的 io_uring_prep_read 自动变成固定读。需要使用 io_uring_prep_read_fixed、io_uring_prep_write_fixed 或对应的 fixed 变体,并把 buf_index 传给 SQE。提交的 buf 和 nbytes 必须落在该索引对应的注册区间中;可以只使用注册缓冲区的一部分,但不能越界。

#include 
#include 
#include 
#include 

// fd 假设已经打开;示例只展示一次注册和一次固定读的边界。
int submit_fixed_read(int fd) {
    struct io_uring ring;
    char *buf = NULL;
    struct iovec iov;
    struct io_uring_sqe *sqe;
    struct io_uring_cqe *cqe = NULL;
    int ret = 0;

    // 4096 同时满足常见页大小和 aligned_alloc 的尺寸要求。
    buf = aligned_alloc(4096, 4096);
    if (buf == NULL) return -1;
    memset(buf, 0, 4096);

    ret = io_uring_queue_init(8, &ring, 0);
    if (ret user_data = 1;
    ret = io_uring_submit(&ring);
    if (ret res res;
        else ret = cqe->res;
        io_uring_cqe_seen(&ring, cqe);
    }

cleanup_buffers:
    // 只有确认在途请求已完成后,才能注销并回收这块内存。
    io_uring_unregister_buffers(&ring);
cleanup_ring:
    io_uring_queue_exit(&ring);
cleanup_buf:
    free(buf);
    return ret;
}

这段代码是文章中的最小结构示意,没有在本机执行。实际并发服务通常把固定缓冲区做成池,用空闲槽位分配给请求;槽位必须一直保持占用到对应 CQE 被消费。多个 ring 不应默认共享一张注册表,需要按 ring 管理,或使用内核/库提供的显式克隆能力。

结果为空时先看 CQE,而不是先改并发度

“结果为空”至少有三种不同含义。cqe->res == 0 可能是文件当前位置已经到 EOF;cqe->res 则是负 errno,例如范围、权限、文件状态或资源限制问题;而 CQE 尚未按预期出现,则可能是提交失败、没有取得 SQE,或者请求被错误地归类到另一个 user_data。

固定缓冲区特有的排查顺序可以压缩成四项:

  1. 确认注册成功,并记录注册数组中每个 buffer 的顺序。
  2. 确认 fixed 操作的 buf_index 与实际 buffer 对应,地址和长度没有越界。
  3. 确认提交返回值和 CQE 的 res,负值按负 errno 解释,不用 errno 覆盖它。
  4. 确认缓冲区在 CQE 到来前没有被另一个请求覆盖、复用或释放。
固定缓冲区索引、地址范围、完成队列 CQE 与结果解释的静态关系说明图
图2:关系说明图展示固定缓冲区索引、地址范围、CQE 和结果解释之间的边界,不是运行结果截图。
# 只查看当前进程允许锁定的内存额度,不会替代程序内的 CQE 检查。
ulimit -l

# 按应用日志记录三类信息:提交返回值、user_data、cqe.res。
# cqe.res 小于 0 时按负 errno 处理,不能只打印“空结果”。
printf 'submit=%s user_data=%s cqe_res=%s\n' "$submit_ret" "$user_data" "$cqe_res"

注册缓冲区需要长期持有用户内存;在较老内核上还可能受 RLIMIT_MEMLOCK 约束,注册失败时要把资源限制和匿名内存类型一起检查。文件映射、地址偏移和对齐也必须符合当前 I/O 操作的要求,不能因为注册成功就忽略底层文件语义。

什么时候不该强行使用 registered buffer

registered buffer 最适合小块、频繁、反复使用同一组内存的 I/O。如果请求大小很大,映射管理开销相对整个 I/O 时间可能已经很小;如果每个请求的内存都不同,维护一张大注册表还会增加锁定内存和槽位管理成本。此时先用普通读写建立基线,再决定是否把稳定的热点缓冲区迁移为 fixed。

还要把生命周期写成明确的所有权规则:谁拿到槽位,谁负责在 CQE 完成后归还;注销只放在 ring 停止并且没有在途请求的位置。这样才能把“减少重复注册开销”变成可控的资源策略,而不是把内存固定、索引错配和结果解释混在一起。

相关问题

注册成功后为什么普通 read 还是没有收益?

注册表只为 fixed 变体提供已登记的缓冲区。若仍然调用普通 read,先确认是否真的使用了 read_fixed 或相应 fixed 操作,再比较相同负载下的成本。

为什么 CQE 返回负数却没有 errno?

io_uring 把错误直接放进 CQE 的 res,形式是负 errno。应先保存这个值,再按需要转换为可读错误文本,不要只检查线程的全局 errno。

能否每次请求结束就注销 buffer?

只有在确认所有使用它的请求都完成后才可以。逐请求注销会重新引入注册管理开销,也容易和并发请求的生命周期冲突。

资料参考:https://man7.org/linux/man-pages/man7/io_uring_registered_buffers.7.html、https://man7.org/linux/man-pages/man3/io_uring_prep_read_fixed.3.html。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>