Linux 中的五种 I/O 模型
Linux 中的五种 I/O 模型
tags
Linux
I/O
date
Jun 7, 2021
source
status

1. 概述

Linux/Unix 的五种 I/O 模型:
  • 阻塞 I/O
  • 非阻塞 I/O
  • I/O 多路复用
  • 异步 I/O
  • 信号驱动 I/O
TCP 发送数据流程:
TCP 发送数据流程
TCP 发送数据流程

2. 阻塞 I/O

阻塞 I/O(Blocking I/O,BIO)模型是最常见的传统 I/O 模型,当用户程序发起读取数据申请(调用 I/O 函数)时,在内核数据还没有准备好之前,该用户程序会一直处于等待数据状态,直到内核把数据准备好后,交给用户程序(从内核拷贝到用户空间)才结束。
阻塞 I/O 的工作流程
阻塞 I/O 的工作流程
工作流程:
  1. 用户进程调用 recvfrom() 函数向内核发起读取数据。
  1. 内核开始准备数据报,需要等待足够的数据到来(用户进程选择阻塞来等待内核的响应)。
  1. 内核等待到了足够的数据,会将数据从内核中拷贝到用户空间。
  1. 拷贝完成后返回成功提示,用户进程才解除阻塞状态。
阻塞 I/O 在 I/O 执行的两个阶段(2、3)都处于阻塞状态。
优点:
  • 只有一个 socket 时实现简单。
缺点:
  • 线程会进入两次阻塞状态(等待数据准备、数据拷贝)。
  • 有多个 socket 时,因为阻塞,剩下 socket 的得不到处理,导致响应性差。

3. 非阻塞 I/O

非阻塞 I/O(Non-blocking I/O,NIO)模型,当用户程序在发起读取数据申请时,如果内核数据还没有准备好,此时会即刻告知用户程序,用户程序不会进入阻塞状态,而是以轮询的方式反复调用 recvfrom() 函数获取结果,直到内核将数据报准备好。
非阻塞 I/O 的工作流程
非阻塞 I/O 的工作流程
工作流程:
  1. 用户进程调用 recvfrom() 函数向内核发起读取数据。
  1. 内核没有准备好数据报,即刻返回 EWOULDBLOCK 错误码。
  1. 用户进程重复调用 recvfrom() 函数,这个行为称为轮询(Polling)
  1. 本次调用 recvfrom() 后内核中已有数据报准备好,数据将从内核中拷贝到用户空间。
  1. 拷贝完成后返回成功提示。
经典非阻塞 I/O 模型伪代码:
for (;;) {
		data = socket.read();
		if (data == error)
				runUserThread();
		else
				processData();
				break;
}
优点:
  • 只有一次阻塞(数据拷贝)。
  • 调用 recvfrom() 函数来轮询状态是非阻塞的。
缺点:
  • 轮询会浪费 CPU 时间片,忙轮询会导致 CPU 占用率升高。
  • 有大量 socket 时,依次轮询性能也很差。

4. I/O 多路复用

以非阻塞 I/O 模型为例,在并发环境下服务器会同时收到多个请求,当前应用可能需要创建多个线程,每个线程自己又会调用 recvfrom() 函数来去 TCP 接收缓冲区中读取数据:
notion image
当服务器收到 n 个请求,应用就需要创建 n 个线程来读取数据,每个线程又在不断地调用 recvfrom() 函数来轮询,如此一来会给服务器造成巨大压力,更是一种资源上的浪费。
I/O 多路复用(I/O Multiplexing)模型的思路是:由一个线程来监控多个网络请求,这样只需要一个或少量的线程就可以完成对数据状态的轮询,当有数据报准备就绪之后,逐一分配对应的响应线程去读取数据,达到节省大量线程资源的目的。
notion image
在 Linux 把所有网络请求都以一个文件描述符(fd)来标识,并提供了三种系统函数来同时监控多个 fd:select()poll()epoll(),且 select() 函数几乎在所有的操作系统上都支持。
I/O 多路复用模型就是依靠这些函数来同时监控多个 fd,被监控的 fd 中只要有任何一个侦测到数据状态已准备就绪后,就会返回数据的可读状态,并分配响应线程去调用 recvfrom() 函数来读取数据。
I/O 多路复用的工作流程
I/O 多路复用的工作流程
函数原型:
int select(int maxfd + 1, fd_set *readset, fd_set *writeset,
					 fd_set *exceptset, const struct timeval * timeout);
select() 函数原型
int poll(struct pollfd fd[], nfds_t nfds, int timeout);
poll() 函数原型,Linux 2.5.44 版本后,被 epoll() 取代
int epoll_create(int size);
int epoll_ctl(int efd, int op, int fd, struct epoll_event* event);
int epoll_wait(int efd, struct epoll_event* events,
							 int max_events, int timeout);
epoll() 函数原型,是 select()、poll() 的增强版本
优点:
  • 使用一个线程管理多个 socket,相较于传统 I/O 模型 + 多线程的方式,大大减少了资源占用。
  • 只有当 socket 读写事件真正发生才会占用资源来进行实际读写操作,适合连接数较多的场景。
  • 轮询 socket 状态是在内核中进行的,相较于非阻塞 I/O 在用户线程中轮询,效率要高。
缺点:
  • 如果事件响应体很大,会导致后续事件不能得到及时处理,影响新事件轮询。
  • 有两次阻塞(调用 select()、数据拷贝)。
  • 调用了两次系统函数(select()recvfrom() 函数)。

5. 信号驱动 I/O

信号驱动 I/O(Signal-Driven I/O,SIGIO)模型不需要以轮询的方式来监控数据的就绪状态,而是使用 sigaction() 函数进行系统调用,给对应的 socket 注册一个 SIGIO 的信号联系后,请求即可返回继续执行其他任务。内核会在数据就绪时生成对应用户进程的 SIGIO 信号,通过该信号回调通知应用线程来调用 recvfrom() 读取数据。
notion image
int sigaction(int signum, const struct sigaction *restrict act,
              struct sigaction *restrict oldact);
sigaction() 函数原型
在 I/O 多路复用模型中,select() 函数在不断轮询 fd 来监控数据状态,大量无效请求都是在浪费 CPU 时间片。而信号驱动 I/O 只需等待信号通知即可。
信号驱动 I/O 的工作流程
信号驱动 I/O 的工作流程
优点:
  • 避免大量无效轮询操作。
  • 等待数据报到达期间进程不被阻塞。
缺点:
  • 大量 I/O 操作时可能导致信号队列溢出而无法通知。
  • 不适合 TCP 类型的 socket,会因为结果通知判别条件过多,丧失优势。

6. 异步 I/O

同步、异步和单路、多路是两组正交的概念,在软件上只是管理模式的区别,同步与异步关注的是程序中的协作关系,而阻塞与非阻塞关注的是单个进程的执行状态,只有同步时才有阻塞和非阻塞之分。而异步就只是异步
异步是一个相对概念,在实际的应用中没有绝对的异步,不同的场合和语言环境,所表示的概念也不一样,有时候同步就代表了阻塞,异步表示非阻塞。如果细分的话,代表不同含义。
异步 I/O(Asynchronous I/O,AIO)是最理想的 I/O 模型,有必要让 CPU 参与时才会参与,既不浪费也不怠慢。异步 I/O 模型无需等待 socket 数据的准备,也无需等待数据的拷贝,将由内核来负责拷贝数据报到用户空间,拷贝完成后通知一声用户进程即可。
notion image
对于 I/O 多路复用或是信号驱动 I/O 而言,都需要分两阶段进行:第一阶段需要询问数据状态(调用 select() 函数),第二阶段需要请求数据( 调用 recvfrom() 函数)。
而异步 I/O 则是一种一劳永逸的方案,只需要用户进程将进行 read 的操作告知内核,由内核自行来完成整个操作,最后再通知用户进程。用户进程在发送完毕操作后就可以转而执行其他操作,只需要等待内核完成后的通知即可。
异步 I/O 的工作流程
异步 I/O 的工作流程
内核在拷贝数据时可以通过 mmap、DMA 等方式来达到零拷贝(不用 CPU 去参与繁重工作)的目的。
优点:
  • 一劳永逸的解决方案。
  • 能充分利用 DMA 特性,让 I/O 操作与计算重叠。
缺点:
  • 要真正实现异步 I/O,操作系统需要做大量的工作。

7. 小结

五种 I/O 模型的横向对比
五种 I/O 模型的横向对比
目前, Windows 通过 IOCP 实现了真正的异步 I/O,而 Linux 2.6 才引入了异步 I/O,目前并不完善,因此在 Linux 下实现高并发网络编程时都是以 I/O 多路复用模型为主。