IO模型

373 阅读8分钟

1 IO的过程

I/O(Input/Outpu) 即输入/输出 。 我们先从计算机结构的角度来解读一下 I/O。 根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。 image.png 输入设备向计算机输入数据,输出设备接收计算机输出的数据。

从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。

为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space)  和 内核空间(Kernel space ), 应用程序并不能直接操作内核空间。一次网络IO读数据的过程经过

  1. 网卡数据到达
  2. DMA拷贝网卡(输入设备)数据到内核缓存区
  3. CUP将数据从内核缓存区拷贝到用户缓存区,也就是从内核态到用户态。
  4. 应用程序读取到数据 image.png 以上过程省去了IO调用, 详细的,这个过程经过,这里例举经典的BIO的过程,见图 image.png

2 五种IO模型

UNIX 系统下, IO 模型一共有 5 种: 同步阻塞 I/O同步非阻塞 I/OI/O 多路复用信号驱动 I/O 和异步 I/O

这也是我们经常提到的 5 种 IO 模型。

2.1 BIO

同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。 image.png BIO应用程序发起IO调用后,如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。

2.2 NIO

同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。

相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。

但是,这种 IO 模型同样存在问题:应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的,这个过程有点类似AQS的自旋操作。 image.png

Java 中的NIO ,有一个非常重要的选择器 ( Selector )  的概念,也可以被称为 多路复用器,因此Java中的NIO并不等同与这里所讲的NIO模型。

2.3 IO 多路复用

称为多路IO模型或IO复用,意思是可以检查多个IO等待的状态。有三种IO复用模型:select、poll和epoll。其实它们都是一种函数,用于监控指定文件描述符的数据是否就绪。select、poll 和 epoll 都是 Linux API 提供的 IO 复用方式的实现。这三个函数仅仅只是处理了数据是否准备好以及如何通知进程的问题, 需要将这几个函数结合阻塞和非阻塞IO模式使用,但通常IO复用都会结合非阻塞IO模式。

IO多路复用解决了为每一个客户端TCP连接创建一个线程的问题,一个线程只能处理一个客户端的IO请求,这样的效率会比较低。线程虽然比较轻量,但是创建带来消耗,上下文切换也有成本。

image.png

当然可以通过一个线程不断轮询的遍历方式来实现多路复用,但是这种方式每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用。

那么真正的IO多路复用是怎么做的呢,我们先看看select的实现。

2.3.1 select()

/**
 *
 * @param maxfdp1  指定待测试的文件描述字个数,它的值是待测试的最大描述字加1。
 * @param fd_set  fd_set可以理解为一个集合,这个集合中存放的是文件描述符(file descriptor),即文件句柄。中间的三个参数指定我们要让内核测试读、写和异常条件的文件描述符集合。如果对某一个的条件不感兴趣,就可以把它设为空指针。
 * @param  timeout timeout告知内核等待所指定文件描述符集合中的任何一个就绪可花多少时间。其timeval结构用于指定这段时间的秒数和微秒数。
 * @return int 若有就绪描述符返回其数目,若超时则为0,若出错则为-1
 */
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);

select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统,让操作系统去遍历,确定哪个文件描述符可以读写,然后告诉我们去处理:

image.png

  1. select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
  4. 为了减少数据拷贝带来的性能损坏,内核对被监控的fd_set集合大小做了限制,并且这个是通过宏控制的,大小不可改变(限制为1024)

2.3.2 poll()

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
typedef struct pollfd {
        int fd;                         // 需要被检测或选择的文件描述符
        short events;                   // 对文件描述符fd上感兴趣的事件
        short revents;                  // 文件描述符fd上当前实际发生的事件
} pollfd_t;

poll()函数和select()的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。

2.3.2 epoll()

epoll 主要就是针对这select的三个细节进行了改进。

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

具体,操作系统提供了这三个函数。

第一步,创建一个 epoll 句柄
int epoll_create(int size);
第二步,向内核添加、修改或删除要监控的文件描述符。
int epoll_ctl(
  int epfd, int op, int fd, struct epoll_event *event);
第三步,类似发起了 select() 调用
int epoll_wait(
  int epfd, struct epoll_event *events, int max events, int timeout);

image.png

更多epoll相关的内容可以参考 # 图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的

2.4 信号驱动 I/O

应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从内核复制到应用进程中。

相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。

image.png

2.5 AIO 异步IO

应用进程执行 aio_read 系统调用会立即返回,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。

异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。

image.png

2.6 五种IO模型的比较

  • 同步 I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞。
  • 异步 I/O:第二阶段应用进程不会阻塞。

同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O ,它们的主要区别在第一个阶段。 非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞。 image.png

3 零拷贝

应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要 CPU 进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程)。

image.png

零拷贝技术不需要在用户态和CPU态来回切换和进行CPU拷贝,用户空间与内核空间都将数据写到一个地方,就不需要拷贝了,直接内存映射虚拟内存就是零拷贝的一种实现。

零拷贝有两种解决方式,分别是 mmap+write 方式和 sendfile 方式,其核心原理都是通过虚拟内存来解决的。 image.png

参考资料