大家好,这里是物联网心球。
在计算机系统中,CPU和内存属于高速设备,而磁盘、网卡等设备属于低速设备。高速设备和低速设备之间传输数据时,高速设备通常需要等待低速设备。I/O模型定义了应用程序与操作系统内核在数据交互过程中的协作方式,主要为了解决高速设备和低速设备之间传输速率不匹配的问题。
Linux系统有五种I/O模型:阻塞 I/O、非阻塞 I/O、I/O多路复用、信号驱动 I/O、异步 I/O。
对于I/O模型还有一种分类是:同步 I/O和异步 I/O。同步I/O和异步I/O的区分一直都是一个难点,有些读者甚至把阻塞和非阻塞当成同步I/O和异步I/O的区分标志。
1.同步I/O和异步I/O的区别?
在对同步I/O和异步I/O进行区分之前,我们先来了解同步I/O和异步I/O解决的是什么问题?

图1 用户缓冲区和内核缓冲区
如图1所示,在Linux系统中,用户程序并不能直接和外部设备通信,它们之间要进行通信必须要借助内核(相当于代理)。为了便于大家理解,我们把用户程序和外部设备通信的过程进行简化。用户程序发送数据过程为:数据从用户缓冲区传输至内核缓冲区,再传输至外部设备(磁盘、网卡)。用户程序接收数据的过程为:数据从外部设备传输至内核缓冲区,再传输至用户缓冲区。
同步I/O和异步I/O需要解决的问题是:用户缓冲区和内核缓冲区之间如何拷贝数据?我们先来看一下同步I/O是如何进行数据交换的,如图2所示。

图2 同步I/O数据交互
同步I/O的种类有:阻塞I/O 、非阻塞 I/O、I/O多路复用、信号驱动 I/O。这几种I/O有一个共同点:数据在用户缓冲区和内核缓冲区拷贝的过程中,进程(用户程序)需要等待。以接收数据为例,这4种I/O模型用户程序都需要调用read函数(或其他接收函数)来接收数据,用户程序需要等待read函数完成数据从内核缓冲区拷贝至用户缓冲区。虽然I/O多路复用和信号驱动两种I/O模型的实现机制比较复杂,但是它们这种复杂的机制只是为了提前知道本次I/O操作的条件是否已经具备。当条件具备时,最终还是要调用read和write来收发数据。
2.异步I/O详解
相较于同步I/O,我们对异步I/O了解就比较少了。异步I/O允许程序在发起I/O请求后立即返回,而不需要等待I/O操作完成。操作系统会在I/O操作完成后通知用户程序。异步I/O是一种非常高效的I/O模型,甚至已经超越了epoll,成为Linux性能最好的I/O模型。
学习异步I/O最好的方法是仔细研究异步I/O的实现原理,并用异步I/O代替同步I/O来实现我们的业务。
Linux支持两种异步I/O模型:Linux AIO 和io_uring 。
Linux AIO是Linux内核提供的原生异步I/O接口,主要用于文件I/O操作。Linux AIO存在很大局限性,目前已经被弃用,被io_uring取代。
io_uring是由内核开发者Jens Axboe提出的一种高性能异步I/O,io_uring是真正意义上的异步I/O,它解决传统Linux AIO的局限性。
我们io_uring为例,来讲解一下异步I/O的工作原理,如图3所示。

图3 异步I/O之io_uring
一个完整的I/O操作大致可以分为四个环节:准备I/O请求、提交I/O请求、内核执行I/O操作、等待I/O操作完成。同步I/O模型这四个环节必须按照顺序完成,而异步I/O模型将这些环节进行了解耦,每个环节都可以单独运行。这样,异步I/O模型的灵活性大大提高了。
io_uring为了将这四个环节解耦,将I/O操作都封装成一个个独立的执行单元,其内核定义如下:
/* IO操作内核定义*/
struct io_issue_def {
......
/* I/O操作函数 */
int (*issue)(struct io_kiocb *, unsigned int);
/* I/O操作预处理函数 */
int (*prep)(struct io_kiocb *, const struct io_uring_sqe *);
};
内核通过一张I/O操作表来维护所有的I/O操作。相关内核源码如下:
/* I/O操作码 */
enum io_uring_op {
......
IORING_OP_SENDMSG,
IORING_OP_RECVMSG,
IORING_OP_TIMEOUT,
IORING_OP_TIMEOUT_REMOVE,
IORING_OP_ACCEPT,
IORING_OP_ASYNC_CANCEL,
IORING_OP_LINK_TIMEOUT,
IORING_OP_CONNECT,
IORING_OP_FALLOCATE,
IORING_OP_OPENAT,
IORING_OP_CLOSE,
IORING_OP_FILES_UPDATE,
IORING_OP_STATX,
IORING_OP_READ,
IORING_OP_WRITE,
......
IORING_OP_LAST,
};
/* 内核io_uring I/O操作表 */
const struct io_issue_def io_issue_defs[] = {
......
[IORING_OP_SENDMSG] = {
.needs_file = 1,
.unbound_nonreg_file = 1,
.pollout = 1,
.ioprio = 1,
.async_size = sizeof(struct io_async_msghdr),
.prep = io_sendmsg_prep,
.issue = io_sendmsg,
},
[IORING_OP_RECVMSG] = {
.needs_file = 1,
.unbound_nonreg_file = 1,
.pollin = 1,
.buffer_select = 1,
.ioprio = 1,
.async_size = sizeof(struct io_async_msghdr),
.prep = io_recvmsg_prep,
.issue = io_recvmsg,
},
......
}
内核io_uring定义了50多种I/O操作,通过I/O操作码查询I/O操作表就能找到指定的I/O操作函数,执行I/O操作函数就能完成对应的I/O操作。
io_uring想要执行某个I/O操作,需要先准备一个I/O请求(SQE),SQE定义如下:
struct io_uring_sqe {
__u8 opcode; /* 操作码 */
__u8 flags; /* 控制标志 */
__s32 fd; /* 文件描述符 */
__u64 addr; /* 用户数据缓冲区地址(用户态指针)*/
__u32 len; /* 数据缓冲区长度(字节数)*/
__u64 user_data; /* 用户自定义数据 */
......
};
准备I/O请求时,需要正确设置操作码、文件描述、数据缓冲区地址和用户自定义数据等信息。I/O请求准备好后,就可以提交给内核,由内核来完成整个I/O操作。内核根据I/O请求中的操作码查询I/O操作表获取并执行I/O操作。内核完成I/O操作后,会将I/O操作结果存储在完成队列项(CQE)中,CQE定义如下:
struct io_uring_cqe {
__u64 user_data; /* 用户自定义数据 */
__s32 res; /* I/O操作的结果 */
__u32 flags; /* CQE标志位 */
};
以读套接字数据为例,用户程序首先准备一个读操作的I/O请求(IORING_OP_RECVMSG),读操作需要指定一块用户数据缓冲区,用于存储从套接字读取的数据。所以I/O请求需要指向用户缓冲区地址并设置缓冲区大小。I/O请求一定要设置user_data,user_data可以设置为指针(指向用户缓冲区地址)或整型数(索引值)。读操作完成后,需要依据user_data提供的信息对数据进行处理。
用户程序提交读操作I/O请求(SQE)至内核,内核会完成读操作,将套接字接收缓冲区数据拷贝至SQE中addr指向的用户缓冲区。读操作完成后,内核会将读操作返回结果存储在CQE中,CQE中的user_data指向用户缓冲区,res为返回结果(成功:res为读取的数据长度;失败:res为错误码)。
内核通过SQ(请求队列)和CQ(完成队列)来批量化管理I/O请求和I/O操作结果。SQ和CQ都是一个无锁化的环形队列,并且通过mmap函数实现用户程序和内核共享SQ和CQ。
有了SQ和CQ后,一次完整的I/O操作流程变为:
首先,用户程序从SQ中获取一个闲置的SQE,并准备I/O请求;接着,用户程序调用io_uring_submit函数提交I/O请求(通知内核执行I/O操作),内核轮询SQ获取就绪SQE并执行I/O操作;I/O操作完成后,从CQ中获取一个闲置的CQE,将I/O操作结果存储在CQE中;最后,用户程序轮询CQ获取就绪的CQE就能够得到I/O操作结果。
以上就是io_uring的工作原理,我们回到异步I/O这个主题,来探讨几个比较重要的问题?
问题1:io_uring为什么是异步I/O?
用户程序提交完成I/O请求,数据从内核空间拷贝到用户空间,用户程序是不需要参与的,所以io_uring是异步I/O。
问题2:io_uring为什么高效?
io_uring采用无锁环形队列,降低了锁开销。通过mmap机制,实现用户程序和内核共享SQ和CQ,减少了数据拷贝和上下文切换。Io_uring可以批量提交I/O请求和批量处理I/O操作结果。
问题3:用户程序是否需要阻塞等待I/O操作结果?
I/O操作结果存储在CQ队列中,用户程序只需要查询CQ队列就能够获取I/O操作结果。查询的过程可以采用阻塞和非阻塞方式。
需要注意的是,高性能I/O模型执行I/O操作时都是采用非阻塞模式。异步I/O也不例外,采用io_uring执行I/O操作,内核会强制采用非阻塞模式。
