io_uring

Linux下io_uring的原理、结构与使用

前言

        我们先总结Linux一些常用的IO:

  •         BIO(阻塞同步IO):read()、write()、accept()。这种阻塞式IO随着设备的更迭、程序的复杂,可能会不适用。
  •         NIO(非阻塞同步IO):epoll()、poll()、select()。应用程序调用这些函数时不会阻塞,而是会立即返回一个已经ready的文件描述符列表,但是这种方式只支持网络套接字和管道。
  •         AIO(非阻塞异步IO):io_setup()、io_submit()、io_getevents()。但是Linux AIO有些许问题:只支持O_DIRECT文件,也就是数据库应用。并且虽说是非阻塞,但是又可能有很多原因导致它阻塞:如果执行 I/O 需要元数据,提交将阻塞等待该元数据。对于存储设备,有固定数量的请求槽可用。如果这些槽当前都在使用中,提交将阻塞等待其中一个变得可用。这些不确定性意味着依赖于提交总是异步的应用程序仍然被迫卸载那部分工作。

     所以为了应对业务需求,弥补AIO的不足,io_uring就出世了。首先它本身在系统中调用上下文就只用往队列里面放请求,仅此而已,因此就不会阻塞;其次它支持任何类型的IO,不仅局限于O_DIRECT文件;此外它的灵活性和可扩展性也较高。

原理

        话不多说,咱们直接看原理。上图:

        一个io_uring就是一对在共享内存中的环形队列。看这张图咱们就已经能清楚地了解io_uring的两个特点:共享内存、环形队列。初始AIO的设计,效率和可扩展性都明显受到了 AIO 必须做的多次拷贝的伤害,所以拷贝不可取,利用共享内存零拷贝;环形队列也是一种高效的数据结构,io_uring利用两个环形队列+共享内存这种灵活的机制来实现内核态与用户态的通信,减少变态,也就能大大提高性能。

数据结构

        接下来我们详细讨论一下io_uring的数据结构:提交队列、完成队列、请求队列

        提交队列(SQ):它不仅需要描述比完成事件更多的信息,而且设计目标是让 io_uring 能够扩展以适应未来的请求类型。

struct io_uring_sqe {
    __u8 opcode;//描述这个特定请求的操作码
    __u8 flags;//包含跨命令类型的通用修饰标志
    __u16 ioprio;//此请求的优先级。对于正常的读写,它遵循 ioprio_set(2) 系统调用中概述的定义
    __s32 fd;
    __u64 off;//包含操作应该发生的位置的偏移量
    __u64 addr;//应该执行 I/O 的地址
    __u32 len;
    union {//特定于 op-code 的标志的联合
        __kernel_rwf_t rw_flags;
        __u32 fsync_flags;
        __u16 poll_events;
        __u32 sync_range_flags;
        __u32 msg_flags;
    };
    __u64 user_data;
    union {
        __u16 buf_index;
        __u64 __pad2[3];
    };
};

        完成队列(CQ):完成队列很简单。它需要携带有关操作结果的信息,以及一些将该完成事件链接回它起源请求的方式。

struct io_uring_cqe {
    __u64 user_data;//从最初的请求提交中传递过来,可以包含应用程序需要识别所述请求的任何信息。
//一个常见的用例是让它成为原始请求的指针。比如在SQE中传递的指针。
    __s32 res;
    __u32 flags;
};

        请求队列。请求项的存储并不在提交队列中,也不再完成队列中,事实是真正的IO请求保存在一个基于数组结构的环形队列中,也就是说,提交队列与完成队列真正掌握的其实是指针或者编号,这样一来,任务提交,完成后我们又避免了拷贝,而是直接将提交队列中已经完成的IO的实际地址赋值给完成队列,效率又大大提高。

        一旦 SQE 被内核消费,应用程序就可以自由地重用那个 SQE 条目。即使内核还没有完全完成给定的 SQE 也是如此。如果内核在条目被消费后需要访问它,它会制作一个稳定的副本。为什么会发生这种情况并不重要,但它对应用程序有一个重要的副作用。通常,应用程序会请求一个给定大小的环,并且假设这个大小直接对应于应用程序可以在内核中挂起的请求数量。然而,由于 SQE 的生命周期只是实际提交它的那段时间,因此应用程序可以推动比 SQ 环大小指示的挂起请求计数更高。应用程序必须注意不要这样做,否则可能会冒着溢出 CQ 环的风险。默认情况下,CQ 环的大小是 SQ 环的两倍。这为应用程序在管理这方面提供了一定的灵活性,但它并没有完全消除这样做的需要。如果应用程序违反了这个限制,它将作为 CQ 环中的溢出条件被跟踪。

API

        io_uring的系统调用有三个:io_uring_setup()、io_uring_register()、io_uring_enter()

        第一个io_uring_setup()是一个用于设置 io_uring 实例的系统调用:设置上下文,这个系统调用创建一个SQ和CQ,SQ 和 CQ 在应用和内核之间共享,避免了在初始化和完成 I/O 时(initiating and completing I/O)拷贝数据

int io_uring_setup(unsigned entries, struct io_uring_params *params);

        应用程序必须为这个 io_uring 实例提供一个期望的条目数量,以及与之相关的一组参数。entries 表示将与这个 io_uring 实例关联的SQES 的数量它必须是 2 的幂,在 1..4096(包括两者)的范围内。params 结构由内核读取和写入,它被定义为:

struct io_uring_params {
    __u32 sq_entries;
    __u32 cq_entries;
    __u32 flags;
    __u32 sq_thread_cpu;
    __u32 sq_thread_idle;
    __u32 resv[5];
    struct io_sqring_offsets sq_off;
    struct io_cqring_offsets cq_off;
};

        sq_entries 将由内核填写,让应用程序知道这个环支持多少 sqe 条目。同样对于 cqe 条目,cq_entries 成员告诉应用程序 CQ 环有多大。在成功调用 io_uring_setup(2) 后,内核将返回一个文件描述符,用于引用这个 io_uring 实例。应用随后可以将这个 fd 传给 mmap(2) 系统调用,来 map the submission and completion queues 或者传给 io_uring_register() io_uring_enter()

        第二个io_uring_register()注册用于异步IO的文件或用户缓冲区:使内核能长时间持有对该文件在内核内部的数据结构引用创建应用内存的长期映射, 这个操作只会在注册时执行一次,而不是每个 I/O 请求都会处理。

int io_uring_register(unsigned int fd, unsigned int opcode,
 void *arg, unsigned int nr_args);

        第三个io_uring_enter()用于初始化和完成(initiate and complete)I/O,使用共享的 SQ 和 CQ。 单次调用同时执行:提交新的 I/O 请求&&等待 I/O 完成

int io_uring_enter(unsigned int fd, unsigned int to_submit,
unsigned int min_complete, unsigned int flags,sigset_t sig);

        fd 是环文件描述符,如 io_uring_setup(2) 返回的。to_submit 告诉内核有多达那个数量的 sqes 准备被消费和提交,而 min_complete 请求内核等待完成那个数量的请求。拥有一个单一的调用可用于提交和等待请求完成意味着应用程序可以单次系统调用来提交和等待请求完成。flags 包含修改调用行为的标志,也就是设置工作模式。

工作模式

  • 中断驱动模式(默认模式):可通过 io_uring_enter() 提交 I/O 请求,然后直接检查 CQ 状态判断是否完成。
  • 轮询模式:繁忙等待IO完成,而不是通过异步 IRQ(Interrupt Request)接收通知。这种模式需要文件系统和块设备(block device)支持轮询功能。 相比中断驱动方式,这种方式延迟更低, 但可能会消耗更多 CPU 资源。目前,只有指定了 O_DIRECT flag 打开的文件描述符,才能使用这种模式。当一个读 或写请求提交给轮询上下文之后,应用必须调用 io_uring_enter() 来轮询 CQ 队列,判断请求是否已经完成。对一个 io_uring 实例来说,不支持混合使用轮询和非轮询模式
  • 内核轮询模式:这种模式中,会 创建一个内核线程(kernel thread)来执行 SQ 的轮询工作。使用这种模式的 io_uring 实例, 应用无需切到到内核态 就能触发(issue)I/O 操作。通过 SQ 来提交 SQE,以及监控 CQ 的完成状态,应用无需任何系统调用,就能提交和收割 I/O。如果内核线程的空闲时间超过了用户的配置值,它会通知应用,然后进入 idle 状态。这种情况下,应用必须调用 io_uring_enter() 来唤醒内核线程。如果 I/O 一直很繁忙,内核线程是不会 sleep 的。
在使用 `io_uring` 进行异步 I/O 操作时,日志记录和日志处理是调试和性能分析的重要手段。`io_uring` 提供了高效的异步 I/O 接口,但其复杂性也使得日志记录成为必要的调试工具。以下是几种常见的日志记录和处理方法。 ### 日志记录方法 #### 1. 使用 `strace` 跟踪系统调用 `strace` 是一个强大的工具,可以用来跟踪进程的系统调用和信号。对于 `io_uring` 应用程序,`strace` 可以显示所有与 `io_uring` 相关的系统调用,如 `io_uring_setup`、`io_uring_enter` 和 `io_uring_register`。这有助于理解程序的行为并进行调试。 ```bash strace -f ./your_program ``` 此命令会显示程序执行过程中所有的系统调用及其参数和返回值,从而帮助开发者识别潜在的问题所在[^1]。 #### 2. 使用 `liburing` 提供的调试功能 `liburing` 是 `io_uring` 的用户空间库,它提供了一些调试功能来帮助开发者更好地理解和调试 `io_uring` 应用程序。例如,可以通过设置环境变量 `LIBURING_DEBUG=1` 来启用详细的调试输出。 ```bash LIBURING_DEBUG=1 ./your_program ``` 启用调试模式后,`liburing` 会在运行时输出更多的调试信息,包括提交队列(SQ)、完成队列(CQ)的状态变化等。这些信息对于理解 `io_uring` 的内部工作流程非常有用[^1]。 #### 3. 自定义日志记录 除了使用现有的工具外,开发者还可以在代码中添加自定义的日志记录逻辑。例如,在提交 I/O 请求或处理完成事件时,可以记录相关的上下文信息、请求状态等。这样可以在应用程序级别捕获详细的运行时信息。 ```c #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <fcntl.h> #include <sys/stat.h> #include <liburing.h> void log_sqe(struct io_uring_sqe *sqe) { printf("Submitting SQE: opcode=%d, fd=%d, off=%lu, len=%u\n", sqe->opcode, sqe->fd, (unsigned long)sqe->off, sqe->len); } int main() { struct io_uring ring; struct io_uring_sqe *sqe; struct io_uring_cqe *cqe; int ret; io_uring_queue_init(8, &ring, 0); sqe = io_uring_get_sqe(&ring); io_uring_prep_nop(sqe); sqe->user_data = 1; log_sqe(sqe); // 自定义日志记录 io_uring_submit(&ring); io_uring_wait_cqe(&ring, &cqe); printf("CQE received: res=%d, user_data=%llu\n", cqe->res, (unsigned long long)cqe->user_data); io_uring_cqe_seen(&ring, cqe); io_uring_queue_exit(&ring); return 0; } ``` 上述代码片段展示了如何在提交 I/O 请求时记录 SQE 的相关信息。通过这种方式,可以更直观地了解每个请求的具体内容和状态。 ### 日志处理方法 #### 1. 使用日志分析工具 日志文件通常包含大量的信息,手动分析这些信息可能会非常耗时。因此,可以使用日志分析工具(如 `logrotate`、`rsyslog` 或 `ELK Stack`)来自动处理和分析日志数据。这些工具可以帮助开发者快速定位问题,并提供可视化的日志分析结果。 #### 2. 实时监控与报警 为了及时发现和响应潜在的问题,可以在应用程序中集成实时监控和报警机制。例如,可以使用 Prometheus 和 Grafana 来监控 `io_uring` 应用程序的性能指标,并在出现异常时发送警报。这样可以确保问题能够在第一时间被发现和解决。 #### 3. 日志级别控制 为了减少日志文件的大小和提高性能,可以实现日志级别的控制。通过设置不同的日志级别(如 DEBUG、INFO、WARNING、ERROR),开发者可以选择性地记录不同严重程度的信息。这不仅可以减少日志文件的大小,还可以提高应用程序的性能。 ```c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdarg.h> typedef enum { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_WARN, LOG_LEVEL_ERROR } LogLevel; LogLevel current_log_level = LOG_LEVEL_INFO; void log_message(LogLevel level, const char *format, ...) { if (level < current_log_level) return; const char *level_str; switch (level) { case LOG_LEVEL_DEBUG: level_str = "DEBUG"; break; case LOG_LEVEL_INFO: level_str = "INFO"; break; case LOG_LEVEL_WARN: level_str = "WARN"; break; case LOG_LEVEL_ERROR: level_str = "ERROR"; break; } va_list args; va_start(args, format); fprintf(stderr, "[%s] ", level_str); vfprintf(stderr, format, args); fprintf(stderr, "\n"); va_end(args); } int main() { log_message(LOG_LEVEL_DEBUG, "This is a debug message"); log_message(LOG_LEVEL_INFO, "This is an info message"); log_message(LOG_LEVEL_WARN, "This is a warning message"); log_message(LOG_LEVEL_ERROR, "This is an error message"); return 0; } ``` 上述代码片段展示了一个简单的日志级别控制系统。通过设置 `current_log_level`,可以控制哪些级别的日志信息会被记录。这对于调试和生产环境中的日志管理非常有用。 ###
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值