Linux IO 模型
在了解 IO 多路复用之前,首先回顾一下常见的 IO 模型。总览如下:
1 阻塞 IO (Blocking IO)
在 Linux 中,所有的 IO 默认都是阻塞的。一个典型的 IO 读操作流程如下:
当应用程序调用了 read() 系统调用,kernel 首先进入等待数据阶段(对于网络 IO,可读数据还没到达或者包不完整),等待足够的数据,此时用户进程会被阻塞。当有可读数据到达后,kernel 将数据拷贝到用户内存空间,read() 系统调用返回,此时用户进程解除阻塞状态。
2 非阻塞 IO(Non-blocking IO)
可通过 O_NONBLOCK 选项配置文件或 socket 为非阻塞模式。非阻塞 IO 的请求示例:
当应用程序调用 read() 系统调用时没有数据可读,用户进程不会阻塞,而是立刻返回 error,应用程序可以通过返回值和 errno 决定下一步的操作。
应用程序尝尝考虑的情况有:
- 返回值大于 0,表示有数据可读且读操作已经完成,返回值为读到的字节数。
- 返回值等于 0,表示连接已经断开(socket)。
- 返回值为 -1,且 errno 为 EAGAIN 或 EWOULDBLOCK(两者等价),数据暂时没有准备好,用户可以决定稍后重试。
- 返回值为 -1,且 errno 不为 EAGAIN 和 EWOULDBLOCK,表示读操作遇到严重错误,重读也不会成功。
这里的非阻塞指的是在等待数据阶段不会阻塞,但内核空间拷贝数据的时候仍会阻塞。
3 异步 IO(Asynchronous IO)
以上两种 IO 均属于同步 IO 模型,即使非阻塞 IO 和接下来要讨论的 IO 多路复用也都不是真正的异步 IO。
异步 IO 与非阻塞 IO 不同之处在于其在内核空间也不会发生阻塞,是真正意义上的非阻塞。当可读数据被拷贝到用户空间后,内核会给用户进程发送一个信号(signal),通知系统调用完成。
Linux 上的异步 IO 用在磁盘 IO 读写操作,不用在网络。Windows 上的完成端口(IOCP, I/O Completion Ports)是完整的异步 IO。
既然非阻塞 IO 和异步 IO 对用户来说都是非阻塞操作,那么异步 IO 的意义在哪里呢?
- 首先异步 IO 在编程方式上往往是信号驱动,有的提供回调接口,用户程序可以自定义读写回调函数,而且不用操心内核什么准备好数据。
- 系统调用在内核态也不阻塞,系统 CPU 利用率更高。
4 IO 多路复用 (IO Multiplexing)
对于阻塞 IO,如果使用单线程,进程就无法在多个文件描述符上阻塞,为一个文件描述符提供服务的同时,就无法为其他描述符提供服务。但是文件描述符往往是关联的,如管道的两端、高并发服务中的 sockets 等。如果对其中一个文件描述符的操作一直没有返回,进程将一直阻塞。
非阻塞 IO 是上述问题的一个解决方案,应用发送的 IO 请求不阻塞,而是返回特定的错误信息。但是改方案仍然面临效率不高的问题,主要原因有:
- 应用程序需要连续随机地发送 IO 请求用来判断当前描述符是否可以操作,开发人员和维护者会可能为此恼火,毕竟谁都想从琐事中解放双手去做更有意义的事;
- 相比睡眠,不断的重试 IO 请求,更加浪费 CPU 资源。
IO 多路复用可以解决上述问题,它支持应用同时在多个文件描述符上阻塞。当没有文件描述符 IO 可以操作时,应用程序处于睡眠状态,其中一个或多个 IO 数据就绪后,应用程序被唤醒并且知道哪些文件描述符可以操作。
Linux 提供了三种 IO 多路复用方案:select、poll、epoll。
4.1 select()
4.1.1 接口
select 是一种同步 IO 多路复用。函数签名和相关宏定义如下:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
4.1.2 参数
在没有 IO 就绪时,调用 select()
将阻塞进程。select()
监听一个文件描述符集合的三种 IO 事件:
readfds
:监视是否有数据可读。writefds
:监视是否可以进行无阻塞写。exceptfds
:监视是否有异常发生,或者有带外数据(out-of-band)到达。
注意:指定的监听集合可以为NULL
,此时,select()
不监听任何事件。
select()
的第一个参数是集合中文件描述符的最大值加1,这样 select()
就知道集合中文件描述符的范围,避免处理事件时的不必要的循环。
参数 timeout
是指向 timeval
结构体的指针,精度为微秒,其定义如下:
#include <sys/time.h>
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
// 0: 立即返回
// -1: 永远阻塞
// >0: 超时返回
注意! select() 调用返回后,timout 参数会被修改,所以每次调用 select() 前必须重新初始化。
4.1.3 返回值
select()
成功返回后,返回值为 IO 就绪的文件描述符的个数,出错时返回 -1,此时 errono
值可能为:
EBADF
:集合中存在非法文件描述符。EINTR
:等待时捕获了一个信号,被迫中断,可以重新发起调用。EINVAL
:无效的 timeout 参数。ENOMEM
:没有足够的内存完成调用。
4.1.4 文件描述符操作宏
Linux 定义了三个符合 POSIX 接口规范的宏用来操作文件描述符。
FD_ZERO(&fd_set)
:从指定的集合中删除所有的文件描述符。FD_SET(fd, &fd_set)
:向指定的集合中添加一个文件描述符。FD_ISSET(fd, &fd_set)
:检查一个文件描述符是否在给定的集合中。FD_CLR(fd, &fd_set)
:从指定的集合中删除一个文件描