前言
上一篇《谈一谈 UNIX 中的 TCP 网络编程》我们谈到了 UNIX 中的 Socket 编程的相关内容。本篇我们谈一谈 UNIX 中的I/O 模型,在服务器网络编程中,经常会涉及到进程/线程与文件(Socket)之间的读写交互,I/O模型则描述与总结了这种读写的交互方式。
对于一次 I/O 操作,如 recv
/recvfrom
,数据会先进入内核缓冲区中,然后才会从内核缓冲区拷贝到用户缓冲区。所以说,当一个 recv
/recvfrom
操作发生时,它会经历两个阶段:
第一步,等待内核缓冲区中有数据到来; 第二步,将内核缓冲区中的数据拷贝到用户缓冲区。
正是因为这两个阶段,UNIX 产生了如下五种 I/O 模型的实现方案:
阻塞式 I/O 非阻塞式 I/O I/O 多路复用 信号驱动 I/O (SIGIO) 异步 I/O
0. 阻塞式与非阻塞式 I/O 的表现
阻塞式 I/O是最常用的 I/O 模型,而且 socket
系统调用创建的 Socket 默认就处于阻塞 I/O 模式。通过对socket
系统调用的第二个入参相与 SOCK_NONBLOCK 标志,或者对一个阻塞 Socket 执行 fcntl
系统调用并设置F_SETFL 属性,就可以将 Socket 设置为非阻塞的。不仅是 Socket,对于所有的文件描述符都可以用阻塞/非阻塞进行描述。
如果进程对阻塞的 Socket 执行一个不能立即完成的系统调用时,那么该进程将会被操作系统挂起,进入休眠,等待相应的操作完成后,才会被唤醒。 在介绍I/O模型时,我们有必要先了解一下常见系统调用在阻塞与非阻塞 I/O (Socket) 上的表现有何不同。
对于一个阻塞的 Socket,如果进程对其执行 accept
函数,并且尚未有新的连接到达,那么该进程就会被挂起,进入睡眠。
对于一个非阻塞的 Socket,如果进程对其执行 accept
函数,并且尚未有新的连接到达,accept
函数将会立即返回 EWOULDBLOCK 错误。
对于一个阻塞的 Socket,如果客户端进程对其执行 connect
函数,将会发出一个 SYN 信号,并且会一直等待,直到接收到服务端进程返回的 ACK 信号时,connect
函数才会返回。因此,每次 TCP connect
总会阻塞其执行进程,直到至少一个服务端的 RTT 时间。
对一个非阻塞的 Socket执行 connect
函数,同样会发起一个 SYN 请求,但是会立即返回 EINPROGRESS 错误。
对一个阻塞的 Socket执行 send
函数,内核会从用户缓冲区将数据拷贝到内核的发送缓冲区。如果内核的发送缓冲区没有空间,进程就会被挂起,进入睡眠,
直到有空间为止。
对一个非阻塞的 Socket 执行 send
函数,
如果内核的发送缓冲区没有空间,函数将会立即返回EWOULDBLOCK 错误。
对一个阻塞的 Socket执行 recv
/recvfrom
函数,内核会从内核的接收缓冲区将数据拷贝到用户缓冲区。如果内核的接收缓冲区没有数据可读,进程就会被挂起,进入睡眠,
直到有数据可读为止。
对一个非阻塞的 Socket 执行 recv
/recvfrom
函数,
如果内核的接收缓冲区没有数据可读,函数将会立即返回EWOULDBLOCK 错误。
以下主要以 recv
/recvfrom
读操作函数的执行过程为例,:
1. 阻塞式 I/O 模型
在阻塞式 I/O 模型中,进程在 recv
/recvfrom
函数执行后将一直阻塞,直到数据到达并且被复制到用户缓冲区中,或者发生错误,才会返回。也就是说,进程从调用recv
/recvfrom
函数开始,直到它将数据复制到用户缓冲区后函数返回的整段时间内都是阻塞的。recv
/recvfrom
函数返回后,进程才能开始处理用户缓冲区中的数据。
总而言之, revc
操作是分为两步进行的:
第一步,等待内核缓冲区中有数据到来; 第二步,将内核缓冲区中的数据拷贝到用户缓冲区。
对于阻塞 Socket,它的第一步与第二步都是阻塞的。
2. 非阻塞式 I/O 模型
进程将 Socket 设置为非阻塞,其实就是在告诉内核:当所进行的I/O操作非要将本进
程投入睡眠时,请不要将本进程投入睡眠,而是立即返回一个错误。在非阻塞I/O模型中,进程循环调用在 recv
/recvfrom
函数,由于还没有数据到达,内核总是立即返回 EWOULDBLOCK 错误。直到有数据到达后,内核将数据从内核缓冲区复制到用户缓冲区,并成功返回。
对于非阻塞 Socket,它的第一步(等待内核数据到来)是非阻塞的, 第二步(数据从内核缓冲区中拷贝到用户缓冲区)是阻塞的。
3. I/O 复用模型
在 Linux/UNIX 中,I/O 复用就是使用 select
、poll
、 epoll
等函数,实现对多个文件描述符的就绪状态(可读/可写/异常)的监听。
如上图展示了 select
I/O 复用模型的流程:
(1) 用户进程调用了 select
函数后,整个进程会被阻塞;
(2) 同时,内核将会监听 select
函数 中所指定的所有文件描述符的就绪状态;
(3) 当监听的任何一个文件描述符达到就绪状态,select
函数就会返回;
(4) 此时用户进程再调用 recv
/recvfrom
等 I/O 操作,即可将数据从内核缓冲区拷贝到用户缓冲区。
显然,I/O 复用模型的第一步(等待内核数据到来)与第二步(数据从内核缓冲区中拷贝到用户缓冲区)也都是阻塞的。
如果只监听单个Socekt的话,I/O 复用模型与上述的阻塞式 I/O 模型没有本质区别,因为都需要阻塞地等待 Socket 的内核缓冲区中有数据到达,才能拷贝数据到用户缓冲区。实际上,性能还可能更差,因为I/O 复用模型需要两次系统调用:select
和recv
/recvfrom
;而阻塞式 I/O 模型只需一次系统调用:recv
/recvfrom
。
但是 I/O 复用模型的好处在于它可以一次监听多个Socekt,但凡有一个 Socekt 就绪,就可以返回。I/O 复用模型的优势并不是对于单个 I/O 能处理得更快,而是在于能处理更多的 I/O 。
与在单线程中使用 I/O 复用模型效果相似的是,在多线程中使用阻塞式 I/O ,一个线程处理一个文件描述符。这样也可以一次性处理多个文件描述符。
4. 信号驱动式 I/O 模型
如果不想让进程阻塞在第一步(等待内核数据到来),除了非阻塞式的轮询,还可以让内核在文件描述符就绪(可读/可写/异常)的时候发送信号 SIGIO 通知进程进行 I/O 操作,该模型称为信号驱动式 I/O 模型。
对一个 Socket 使用信号驱动式 I/O 需要进行以下三步:
使用 signal
系统调用设置 SIGIO 信号的信号处理函数;设置 Socket 的属主进程的进程号,通常使用 fcntl
的 F_SETOWN 命令设置;为 Socket 设置信号驱动式 I/O 属性,通常使用 fcntl
的 F_SETFL 命令打开 O_ASYNC 标志。
当使用 signal
调用设置完毕后,该系统调用会立即返回,用户进程会继续工作,也就是说用户进程无阻塞。当内核缓冲区中有数据到来时,内核会对该进程发出一个 SIGIO 信号,随后进程就会在预先设置的信号处理函数中执行 recv
/recvfrom
操作将数据拷贝到用户缓冲区。信号驱动式 I/O 模型的优势在于等待内核缓冲区中的数据到来期间,进程不会被阻塞。
由于 UDP 没有连接,只有数据报的到达与否,因此对 UDP Socket 使用信号驱动 I/O 相对较为简单, SIGIO 信号只会在如下情况下被触发:
数据到达 Socket 的内核缓冲区; Socket 上发生异步错误。
对于 TCP Socket而言,信号驱动 I/O 模型的使用比较繁琐,甚至于几乎没有用处。这是因为 TCP 需要建立连接,因此在如下诸多的情况下都会触发 SIGIO 信号:
监听Socket 的已完成连接队列中有已连接 Socket 到来。 某个 已连接 Socket 已发起断开连接的请求; 某个 已连接 Socket 已完成断开连接的请求; 某个 已连接 Socket 的读段或写端已关闭(半关闭); 数据到达某个 已连接 Socket 的内核缓冲区; 数据已从某个 已连接 Socket 的内核缓冲区发出(内核发送缓冲区有空闲); Socket 上发生异步错误。
举例说明一下,如果一个进程既从一个 TCP Socket 中读取数据,又从中写出数据,那么当有数据到达 Socket 的内核缓冲区或者数据已从 Socket 的内核缓冲区发出,SIGIO 信号均会产生,而且在信号处理函数中无法区分这两种情况。
例如,如果是“数据已从 Socket 的内核缓冲区发出”所产生 SIGIO 信号,但是你在信号处理函数中使用的却是 recv
读取操作,那么你的进程将可能会一直阻塞在 recv
等待数据到来的过程中。因此,如果在这种情况下使用信号驱动 I/O 模型,那么 TCP Socket 最好应该设置为非阻塞的,以防被信号误触发而导致阻塞在 recv
/send
等操作上。
可以使用非阻塞的 TCP Socket,并且在信号处理函数中逐一执行 Socket 的各种调用(recv
/send
等),不合时机的调用可以立即返回,找到信号触发所要执行的正确调用。
另外,通常只对监听Socket 采用信号驱动式 I/O,因为对于监听Socket 而言,触发 SIGIO 信号的唯一条件是已完成连接队列中有已连接 Socket 到来。
5. 异步 I/O 模型
上述四种 I/O 模型是同步 I/O 模型,因为即使第一步(等待数据就绪)可以不阻塞,但是第二步(数据的拷贝)必定都是阻塞的。在第二步的阻塞期间,进程无法进行其他工作,必须要等待数据拷贝完成才行。但是如果我们要想在发起 recv
调用的整个执行期间都不阻塞(包括将数据从内核缓冲区拷贝到用户缓冲区),以便我们的进程可以同时进行其他工作,那么就要使用 POSIX 规范提供的异步 I/O 模型。在 Linux 中就实现了一套异步 I/O 的操作函数,Linux 中的异步 I/O 主要是针对磁盘文件的读写:
int aio_read(struct aiocb *aiocbp); // 提交一个异步读请求
int aio_write(struct aiocb *aiocbp); // 提交一个异步写请求
int aio_cancel(int fildes, struct aiocb *aiocbp); //取消一个异步请求
int aio_error(const struct aiocb *aiocbp); //查看一个异步请求的状态(进行中EINPROGRESS或已结束或出错)
ssize_t aio_return(struct aiocb *aiocbp); //查看一个异步请求的返回值(和同步读写定义的一样)
int aio_suspend(const struct aiocb * const list[], int nent, const struct timespec *timeout); //阻塞等待请求完成
在异步 I/O 模型中,可以同时发起(提交给内核)多个 I/O 操作。我们需要为这些 I/O 操作指定其读写的文件描述符、读写的内存起始地址、读写的内存长度以及完成 I/O 操作时触发的通知信号或者回调函数等信息。这些信息在 Linux 中是通过一个 aiocb
(Async I/O Control Block)结构体进行设置。这个结构体中包含了有关 I/O 操作的所有信息。
struct aiocb 结构体主要包含以下字段:
int aio_fildes; //要被读写的文件描述符
void *aio_buf; //读写操作对应的用户缓冲区
__off64_t aio_offset; //读写操作对应的文件偏移
size_t aio_nbytes; //需要读写的字节长度
int aio_reqprio; //请求的优先级
struct sigevent aio_sigevent; //异步事件,定义异步操作完成时的通知信号或回调函数
以 aio_read
异步读操作为例,进程调用 aio_read
函数本质上是把读操作提交到内核管理的一个异步请求队列当中 ,并告诉内核所要读取的文件描述符、用户缓冲区位置与大小、文件偏移量以及完成时的通知方式。内核将在后台自行完成用户缓冲区与内核缓冲区之间的数据移动工作,全程无需用户进程参与,也不会阻塞用户进程。
aio_read
函数在调用后会立即返回,成功提交读请求时返回0,提交失败返回为-1。
在内核进行 I/O 操作时,用户进程不会被阻塞,可以进行其他工作。直到内核将数据拷贝至用户缓冲区后,才会产生信号通知用户进程已完成。这一点是区别于信号驱动式 I/O 的。
总结
本篇中我们介绍了阻塞式 I/O、非阻塞式 I/O、I/O 复用、信号驱动式 I/O、异步 I/O 这五种 I/O 模型。其中,对于 send
/revc
等 I/O 操作,前四种 I/O 模型主要区别于第一步,因为它们的第二步都是相同的:数据在内核缓冲区与用户缓冲区之间拷贝的期间,进程阻塞。
阻塞式 I/O、非阻塞式 I/O、I/O 复用、信号驱动式 I/O 这四种 I/O 模型都符合 POSIX 规范定义的同步 I/O 模型,因为它们真正的 I/O 操作( send
/revc
/recvfrom
等)都将阻塞进程。而异步 I/O 模型不会导致进程在实际的 I/O 操作上发生阻塞。
一起探索架构技术,拥抱AI,欢迎与我交流!