常见的I/O模型有
- 阻塞I/O模型
- 非阻塞I/O模型
- 信号驱动I/O模型
- 异步I/O模型
- I/O多路复用模型
阻塞I/O模型
每次对一个文件描述符执行I/O操作,每次I/O系统调用都会阻塞直到完成数据传输
。
如下图所示,当我们执行read
系统调用时,应用程序会从用户态
陷入内核态
,内核会检查文件描述符是否可读;当文件描述符中存在数据时,操作系统内核会将准备好的数据拷贝给应用程序并将控制权交回。
非阻塞I/O
如果在open
打开文件是设定了O_NONBLOCK
标志,或者使用fcntl
和参数O_NONBLOCK
,可以把一个文件描述符设置为非阻塞
,执行read
或write
等IO系统调用时会立刻
返回。
如下图所示,第一次从文件描述符中读取数据会触发系统调用并返回EAGAIN
(文件已被锁定)错误。EAGAIN
意味着该文件描述符还在等待缓冲区中的数据;随后,应用程序会不断轮询调用read
直到它的返回值大于 0,这时应用程序就可以对读取操作系统缓冲区中的数据并进行操作。进程使用非阻塞的 I/O 操作时,可以在等待过程中执行其他的任务,增加 CPU 资源的利用率。
非阻塞I/O可以让我们周期性地检查(“轮询”)某个文件描述符上是否可执行I/O操作。
但如果我们需要同时检查多个文件描述符,如果将它们都设为非阻塞,如何依次对它们轮询。但是这种轮询方法存在问题,如果轮询的频率不高
,那么应用程序响应I/O事件的延时可能会到达无法接受的程度。如果轮询的频率过高
,此时没有就绪的文件描述符,那就是在浪费CPU。
I/O多路复用
由于非阻塞的限制性,I/O多路复用模型在同时检查多个文件描述符时,更多被使用。
I/O多路复用运行同时检查多个文件描述符,以找出它们中的任何一个是否就绪,可执行I/O操作。系统调用select()
和poll()
,还有Linux专有的epoll
,用来执行I/O多路复用。
注意:这些技术都不会执行实际的 I/O 操作。它们只是告诉我们某个文件描述符已经处于就绪状态了。这时需要调用其他的系统调用来完成实际的 I/O 操作
。
如下图所示,多路复用函数
会阻塞
的监听一组文件描述符,当文件描述符的状态转变为可读或者可写时,select
会返回可读或者可写事件的个数,应用程序就可以在输入的文件描述符中查找哪些可读或者可写,然后执行相应的操作。
水平触发和边缘触发
两种文件描述符准备就绪的通知模式。
- 水平触发通知:如果文件描述符上可以非阻塞地执行I/O系统调用,此时认为它已经就绪。
- 边缘触发通知:如果文件描述符自上次状态监测以来有了新的I/O活动(比如新的输入),此时需要触发通知。
当采用水平触发通知时
我们可以在任意时刻检查文件描述符的就绪状态。这表示当我们确定了文件描述符处于就绪态时(比如存在有输入数据),就可以对其执行一些I/O操作,然后重复检查文件描述符,看看是否仍然处于就绪态(比如还有更多的输入数据),此时我们就能执行更多的I/O,以此类准。换句话说,由于水平触发模式允许我们在任意时刻重复检查I/O状态,没有必要每次当文件描述符就绪后尽可能多地执行I/O(也就是尽可能多地读取字节,亦或是根本不去执行任何I/O)
。
当采用边缘触发通知时
在一个新的I/O事件到来时,我们才会收到通知。所以,在接收到一个I/O事件通知后,程序应该在某个时刻在相应的文件描述符上尽可能多
地执行I/O(比如尽可能多地读取字节)。如果不这样做,假如我们只读取了部分字节,那么在另一个新的I/O事件到来前,文件描述符都是处于非就绪
状态,我们无法得到通知,这会导致剩余的字节一直在缓冲区里。
select()
和poll()
只支持水平触发。epoll
同时支持水平触发(默认)和边缘触发。
I/O多路复用中采用非阻塞I/O
之前一直比较疑问为I/O多路复用要搭配非阻塞I/O来使用,既然我已经得到通知文件描述符已经就绪,可读或者可写了,此时调用阻塞的I/O系统调用,应该是不会阻塞。
google了,在知乎上找到为什么 IO 多路复用要搭配非阻塞 IO?和通过书中的讲解,知道了为什么要使用非阻塞I/O。
select
返回就绪可读,和使用阻塞系统调用read
去读,是存在时间差的。也就是说文件描述符的就绪状态可能会在通知就绪和执行后续I/O调用之间发生改变
。典型场景就是,多个进程或线程使用select
或epoll
监听同一个文件描述符
,当文件描述符可读时,所有的进程或线程都会得到通知,但是最终最有一个进程或线程在文件描述符成功地执行了read
系统调用,其他进程或线程的read
会被阻塞住。- 在使用边缘触发时,我们可能会使用循环来对文件描述符执行尽可能多的I/O,如果文件描述符被设置为可阻塞的,那么最终当没有可读或者更大的空间可写时,I/O系统调用就会阻塞。
select()和poll()和epoll的比较
select()和poll()在检查大量
的文件描述符时,会存在的问题。
- 每次调用select()或poll(),程序都必须传递一个表示所有需要被检查的文件描述符的
数据结构
到内核,内核检查过描述符后,修改这个数据结构并返回给程序。从用户空间到内核空间来回拷贝这个数据结构将会占用大量的CPU时间
。 - 每次调用select()或poll(),内核都必须检查
所有
被指定的文件描述符,看它们是否存于就绪态。当检查大量文件描述符时,该检查操作耗费会占用大量的CPU时间
。 - select()或poll()调用完成后,程序还必须检查返回的
数据结构
中的每个元素,看哪个文件描述符处于就绪状态。
epoll在检查大量的文件描述符时,性能比较select()和poll()高很多,所以在Linux下,建议使用epoll。
为什么epoll在检查大量的文件描述符时,性能高?
epoll的和型数据结构称为epoll实例,它和一个打开的文件描述符相关联。这个描述符不是用来做I/O操作的,相反,它是内核数据结构的句柄,这些数据结构实现了两个目的。
- 记录了在进程中声明过的
感兴趣
的文件描述符列表 —interest list
(兴趣列表)。 - 维护了处于
I/O就绪态
的文件描述符列表—ready list
(就绪列表)。
每次调用 select()和 poll()时,内核必须检查所有在调用中指定的文件描述符。相反,当通过epoll_ctl()
指定了需要监视的文件描述符时,内核会在与打开的文件描述上下文相关联的兴趣列表
中记录该描述符。之后每当执行 I/O 操作使得文件描述符成为就绪态时,内核就在epoll描述符的就绪列表
中添加一个元素。之后的epoll_wait()
调用从就绪列表中简单地取出这些元素。
每次
调用 select()或 poll()时,我们传递一个标记了所有待监视的文件描述符的数据结构给内核,调用返回时,内核将所有标记为就绪态的文件描述符的数据结构再传回给我们(数据结构从用户态到内核态,再到用户态
)。与之相反,在epoll中我们使用epoll_ctl()
在内核空间中建立一个数据结构,该数据结构会将待监视的文件描述符都记录下来。一旦这个据结构建立完成,稍后每次调用epoll_wait()
时就不需要再传递任何与文件描述符有关的信息给内核了
,而调用返回的信息中只包含那些已经处于就绪态的描述符。
参考资料
《Linux/Unix系统编程手册》