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

Linux io_uring 怎样用异步超时限制慢请求

来源:17golang原创

时间:2026-11-08 04:03:34 356浏览 收藏

Linux io_uring 要给一个可能变慢的 I/O 请求设置上限,通常把请求 SQE 标记为 IOSQE_IO_LINK,再紧跟一个 IORING_OP_LINK_TIMEOUT。这样超时针对的是前一个链接请求,而不是把整个完成队列当成一个计时器。请求先完成时,超时 CQE 通常是 -ECANCELED;超时先到时,超时 CQE 是 -ETIME,原请求则会收到取消相关结果。

官方地址:https://docs.kernel.org/

要点速览
  • IORING_OP_LINK_TIMEOUT 必须通过 IOSQE_IO_LINK 绑定到前一个 SQE,不能单独当作普通完成队列定时器。
  • 两个 CQE 要靠各自的 user_data 区分,res 直接返回负的 errno,不要只看全局 errno。
  • 超时只解决等待上限,不保证底层磁盘 I/O 已经停止;缓冲区和超时结构体的生命周期仍由应用负责。

触发场景:为什么给单个请求加链接超时

在事件驱动程序里,慢请求最麻烦的地方不是“计时器不会用”,而是计时器到底作用于谁。普通的 IORING_OP_TIMEOUT 是面向完成队列等待者的超时;IORING_OP_LINK_TIMEOUT 则和一个前置 SQE 组成链接,专门限制这一个请求。

因此,适合的场景是“读一个可能卡住的文件描述符”“等待一个可能迟到的 socket 操作”或“给一段链路中的单个操作设置上限”。它不等于强制终止内核已经开始执行的所有工作:对于不可中断的磁盘操作,应用仍要按 CQE 结果决定是否丢弃结果、重试或记录超时。

io_uring 链接超时结构图,展示请求 SQE、IOSQE_IO_LINK、超时 SQE 与提交关系
图1:链接超时的静态结构图,展示请求、超时配置和一次提交之间的关系;这是说明图,不是运行截图或执行证据。

权限配置:准备 I/O SQE 和超时结构

liburing 把底层 SQE 的字段封装成准备函数。关键约束有三个:先准备实际 I/O 请求,再给它设置 IOSQE_IO_LINK;超时 SQE 要紧跟在它后面;两者必须在同一次 io_uring_submit 中送入内核。

#include 
#include 

// 为一个读请求准备链接超时;timeout 由调用方持有,不能在未完成前失效。
static int prepare_read_with_timeout(struct io_uring *ring,
                                     int fd,
                                     void *buffer,
                                     unsigned length,
                                     struct __kernel_timespec *timeout) {
    struct io_uring_sqe *read_sqe = io_uring_get_sqe(ring);
    if (read_sqe == NULL) {
        return -ENOSPC; // SQ 环已没有可用的提交项。
    }

    // 第一个 SQE 是真正的读请求,user_data 用来识别它的 CQE。
    io_uring_prep_read(read_sqe, fd, buffer, length, 0);
    read_sqe->user_data = 1001;
    read_sqe->flags |= IOSQE_IO_LINK;

    struct io_uring_sqe *timeout_sqe = io_uring_get_sqe(ring);
    if (timeout_sqe == NULL) {
        return -ENOSPC; // 超时项拿不到时,不能提交不完整的链路。
    }

    // 第二个 SQE 只约束前一个请求,不统计整个 CQ 的完成数量。
    io_uring_prep_link_timeout(timeout_sqe, timeout, 0);
    timeout_sqe->user_data = 1002;
    return 0;
}

// 调用方需要在合适的时机提交,并检查 submit 返回值。
static int submit_read_with_timeout(struct io_uring *ring,
                                    int fd,
                                    void *buffer,
                                    unsigned length,
                                    struct __kernel_timespec *timeout) {
    int prepared = prepare_read_with_timeout(ring, fd, buffer, length, timeout);
    if (prepared 

这个片段故意把 timeout 作为调用方参数,而不是在函数内部创建栈变量。普通提交模式下,超时结构至少要保持到提交完成;如果启用了 SQPOLL,还要按当前 liburing/内核文档的要求延长它的有效期。实际读写缓冲区也不能在原请求的 CQE 到来前释放。

流水线阶段:把请求和超时放进同一条链路

链路的静态关系可以概括为“前一个请求 + 一个链接超时”。io_uring_prep_link_timeout 的超时参数可以是相对时间;不指定其他时钟标志时,相关文档使用单调时钟。不要把它写成单独提交的普通 timeout,否则它就不再明确绑定刚才那个 I/O 请求。

#include 

// 这个示例只展示调用关系;fd、buffer 和 ring 的创建由上层负责。
int submit_bounded_read(struct io_uring *ring, int fd, char *buffer, unsigned size) {
    struct __kernel_timespec timeout = {
        .tv_sec = 2,  // 最多等待两秒,示例使用相对时间。
        .tv_nsec = 0  // 纳秒部分保持为合法范围内的值。
    };

    // timeout 在请求完成前仍由当前作用域持有,实际异步程序应把它放在更长生命周期对象中。
    return submit_read_with_timeout(ring, fd, buffer, size, &timeout);
}

上面的简化函数只用于解释组合关系:如果 ring 使用 SQPOLL,函数返回后栈上的 timeout 可能过早失效,生产代码应把它放到请求对象、堆内存或其他可证明存活的存储里。这个生命周期问题和“是否调用了 submit”是两件不同的事。

门禁规则:按两个 CQE 解释结果

提交后不要只等待一个结果就把请求对象释放。链路通常会产生两个可识别的 CQE:一个来自原始 I/O,一个来自链接超时。用 user_data 区分来源,再根据 cqe->res 判断是正常完成、超时还是取消。

#include 
#include 

// 读取一条 CQE,并把业务请求和超时请求分开解释。
static int consume_one_cqe(struct io_uring *ring) {
    struct io_uring_cqe *cqe = NULL;
    int wait_result = io_uring_wait_cqe(ring, &cqe);
    if (wait_result user_data == 1001) {
        if (cqe->res >= 0) {
            printf("read completed: %d bytes\n", cqe->res);
        } else if (cqe->res == -ECANCELED || cqe->res == -EINTR) {
            printf("read was canceled or interrupted: %d\n", cqe->res);
        } else {
            printf("read failed: %d\n", cqe->res); // res 已经是负 errno。
        }
    } else if (cqe->user_data == 1002) {
        if (cqe->res == -ETIME) {
            printf("link timeout expired\n"); // 超时先到,链路请求被尝试取消。
        } else if (cqe->res == -ECANCELED) {
            printf("link timeout canceled by request completion\n");
        } else {
            printf("timeout result: %d\n", cqe->res);
        }
    }

    io_uring_cqe_seen(ring, cqe); // 每个取出的 CQE 都要归还给 CQ 环。
    return 0;
}

这里的“门禁”不是再发一个探测请求,而是把状态解释完整:原请求成功时,业务可以消费数据;原请求被取消时,业务不能把缓冲区里的旧内容当成新结果;超时 CQE 为 -ECANCELED 时,说明计时器因前置请求完成而被取消,并不表示业务请求失败。

io_uring CQE 结果边界图,展示原请求与链接超时的完成结果、-ECANCELED 和 -ETIME
图2:CQE 结果边界说明图,展示正常完成、链接超时和取消结果的静态对应关系;这是结果示意图,不是运行截图。

失败处理:区分超时、取消和底层 I/O 失败

最常见的误判是看到 -ETIME 就认为“原请求已经安全停止”。链接超时会尝试取消前置请求,但不同 I/O 类型的可取消性不同,已经在内核中开始的磁盘操作不一定能瞬间消失。程序应把“本次请求对调用方超时”与“底层工作已经完全终止”分开记录。

观察位置典型结果处理建议
原请求 CQE非负值按实际字节数或操作结果消费数据
原请求 CQE-ECANCELED 或 -EINTR丢弃未确认数据,释放请求资源
超时 CQE-ETIME记录超时并决定重试、降级或返回上层
超时 CQE-ECANCELED前置请求已完成或链路被取消,不能当成超时

如果上层需要重试,必须先确认旧请求的所有 CQE 和缓冲区生命周期,避免同一块内存被旧请求继续写入时又交给新请求。对于不可安全重试的写操作,更应该把超时当成“结果未知”的状态,交给幂等键、日志或业务补偿机制处理,而不是机械地再次提交。

通知复盘:给超时链路留下可追踪记录

线上排查时建议同时记录请求的 user_data、提交时间、超时预算、原请求 CQE 结果和超时 CQE 结果。这样能回答三个不同问题:请求有没有提交、是否先收到超时、内核最终给原请求返回了什么。

request_id=1001 timeout_id=1002 timeout_ms=2000 read_res=-125 timeout_res=-62 state=timed_out
request_id=1001 timeout_id=1002 read_res=4096 timeout_res=-125 state=completed

复盘时不要只统计超时数量,还要区分“计时器正常取消”和“计时器真正到期”。前者通常说明请求已经在预算内完成;后者才说明调用方的等待预算被消耗。若超时持续升高,再结合文件系统、网络和队列深度指标定位原因。

相关问题与边界

IORING_OP_TIMEOUT 和 IORING_OP_LINK_TIMEOUT 有什么区别?

前者是面向完成队列等待的普通超时,可以按时间或完成事件数量触发;后者必须链接到前一个请求,作用对象是那条请求链里的操作。

链接超时一定会让磁盘 I/O 立即停止吗?

不一定。它会对链接请求发起取消处理,但底层操作能否及时中止取决于请求类型和当时是否已经开始执行。应用应等待原请求 CQE,再释放缓冲区。

为什么 timeout CQE 是 -ECANCELED?

这通常表示原请求已经完成,计时器因不再需要而被取消;真正到期的 timeout CQE 通常是 -ETIME。

能不能把 timeout 结构体定义在提交函数的栈上?

只有在能证明内核读取它之前栈帧仍有效时才安全。使用 SQPOLL 或其他异步提交方式时,应让结构体的生命周期覆盖实际需要的阶段。

总结一下:先把请求 SQE 标记为 IOSQE_IO_LINK,再紧跟 IORING_OP_LINK_TIMEOUT,两者一起提交;随后按两个 user_data 分别解释 CQE,并把超时预算、取消结果和底层 I/O 是否真正停止分开处理。

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