常见的I/O模型有

  • 阻塞I/O模型
  • 非阻塞I/O模型
  • 信号驱动I/O模型
  • 异步I/O模型
  • I/O多路复用模型

阻塞I/O模型

每次对一个文件描述符执行I/O操作,每次I/O系统调用都会阻塞直到完成数据传输

如下图所示,当我们执行read系统调用时,应用程序会从用户态陷入内核态,内核会检查文件描述符是否可读;当文件描述符中存在数据时,操作系统内核会将准备好的数据拷贝给应用程序并将控制权交回。
YLrUYj.png

非阻塞I/O

如果在open打开文件是设定了O_NONBLOCK标志,或者使用fcntl和参数O_NONBLOCK,可以把一个文件描述符设置为非阻塞,执行readwrite等IO系统调用时会立刻返回。

如下图所示,第一次从文件描述符中读取数据会触发系统调用并返回EAGAIN(文件已被锁定)错误。EAGAIN意味着该文件描述符还在等待缓冲区中的数据;随后,应用程序会不断轮询调用read直到它的返回值大于 0,这时应用程序就可以对读取操作系统缓冲区中的数据并进行操作。进程使用非阻塞的 I/O 操作时,可以在等待过程中执行其他的任务,增加 CPU 资源的利用率。

YLrj1I.png

非阻塞I/O可以让我们周期性地检查(“轮询”)某个文件描述符上是否可执行I/O操作。

但如果我们需要同时检查多个文件描述符,如果将它们都设为非阻塞,如何依次对它们轮询。但是这种轮询方法存在问题,如果轮询的频率不高,那么应用程序响应I/O事件的延时可能会到达无法接受的程度。如果轮询的频率过高,此时没有就绪的文件描述符,那就是在浪费CPU。

I/O多路复用

由于非阻塞的限制性,I/O多路复用模型在同时检查多个文件描述符时,更多被使用。

I/O多路复用运行同时检查多个文件描述符,以找出它们中的任何一个是否就绪,可执行I/O操作。系统调用select()poll(),还有Linux专有的epoll,用来执行I/O多路复用。

YL4Zi6.png

注意:这些技术都不会执行实际的 I/O 操作。它们只是告诉我们某个文件描述符已经处于就绪状态了。这时需要调用其他的系统调用来完成实际的 I/O 操作

如下图所示,多路复用函数阻塞的监听一组文件描述符,当文件描述符的状态转变为可读或者可写时,select会返回可读或者可写事件的个数,应用程序就可以在输入的文件描述符中查找哪些可读或者可写,然后执行相应的操作。

YLhDPK.png

水平触发和边缘触发

两种文件描述符准备就绪的通知模式。

  • 水平触发通知:如果文件描述符上可以非阻塞地执行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。

  1. select返回就绪可读,和使用阻塞系统调用read去读,是存在时间差的。也就是说文件描述符的就绪状态可能会在通知就绪和执行后续I/O调用之间发生改变。典型场景就是,多个进程或线程使用selectepoll监听同一个文件描述符,当文件描述符可读时,所有的进程或线程都会得到通知,但是最终最有一个进程或线程在文件描述符成功地执行了read系统调用,其他进程或线程的read会被阻塞住。
  2. 在使用边缘触发时,我们可能会使用循环来对文件描述符执行尽可能多的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系统编程手册》

网络轮询器

Last modification:May 22nd, 2020 at 11:50 pm
如果觉得我的文章对你有用,请尽情赞赏 🐶