搞定技术面试

Unix 五种 IO 模型

Posted by Mcoder on January 8, 2020 文章阅读量

Unix 五种 IO 模型

  • Unix 的 IO 模型有哪几种?分别是咋样的?
  • 各种IO模型有什么特点?
  • 阻塞和非阻塞的区别?
  • 同步和异步的区别?
  • epoll 为什么比较高效?

IO 模型

Unix 提供了五种 IO 模型,分别是阻塞式IO、非阻塞式IO、IO复用、信号驱动式IO、异步IO

一个IO操作通常包括两个阶段:

  1. 等待数据准备好;
  2. 从内核向进程复制数据;

对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。

实际应用程序在系统调用完成上面的 2 步操作时,调用方式的阻塞、非阻塞,操作系统在处理应用程序请求时,处理方式的同步、异步处理的不同,可以分为 5 种 I/O 模型。

*注:recvfrom 函数(经 Socket 接收数据),这里把它视为系统调用

阻塞式 IO (Blocking IO)

  • 使用系统调用,并一直阻塞直到内核将数据准备好,之后再由内核缓冲区复制到用户态,在等待内核准备的这段时间什么也干不了
  • 下图函数调用期间,一直被阻塞,直到数据准备好且从内核复制到用户程序才返回,这种IO模型为阻塞式IO。

优点:程序简单,在阻塞等待数据期间进程/线程挂起,基本不会占用 CPU 资源。

缺点:每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。

非阻塞式 IO (Non-blocking IO)

  • 内核在没有准备好数据的时候会返回错误码,而调用程序不会休眠,而是不断轮询询问内核数据是否准备好
  • 下图函数调用时,如果数据没有准备好,不像阻塞式IO那样一直被阻塞,而是返回一个错误码。数据准备好时,函数成功返回。
  • 应用程序对这样一个非阻塞描述符循环调用成为轮询。
  • 非阻塞式IO的轮询会耗费大量cpu,通常在专门提供某一功能的系统中才会使用。通过为套接字的描述符属性设置非阻塞式,可使用该功能

优点:不会阻塞在内核的等待数据过程,每次发起的 I/O 请求可以立即返回,不用阻塞等待,实时性较好。

缺点:轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不使用这种 I/O 模型。

IO 复用 (I/O multiplexing)

  • 类似与非阻塞,只不过轮询不是由用户线程去执行,而是由内核去轮询,内核监听程序监听到数据准备好后,调用内核函数复制数据到用户态
  • 下图中select这个系统调用,充当代理类的角色,不断轮询注册到它这里的所有需要IO的文件描述符,有结果时,把结果告诉被代理的recvfrom函数,它本尊再亲自出马去拿数据
  • IO多路复用至少有两次系统调用,如果只有一个代理对象,性能上是不如前面的IO模型的,但是由于它可以同时监听很多套接字,所以性能比前两者高

主要是select和epoll。一个线程可以对多个IO端口进行监听,当socket有读写事件时分发到具体的线程进行处理。模型如下所示:

优点:可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程),这样可以大大节省系统资源。

缺点:当连接数较少时效率相比多线程+阻塞 I/O 模型效率较低,可能延迟更大,因为单个连接处理需要 2 次系统调用,占用时间会有增加。

信号驱动式 IO (signal driven I/O (SIGIO))

信号驱动式I/O:首先我们允许Socket进行信号驱动IO,并安装一个信号处理函数,进程继续运行并不阻塞。

  • 使用信号,内核在数据准备就绪时通过信号来进行通知
  • 首先开启信号驱动io套接字,并使用sigaction系统调用来安装信号处理程序,内核直接返回,不会阻塞用户态
  • 数据准备好时,内核会发送SIGIO信号,收到信号后开始进行io操作

优点:线程并没有在等待数据时被阻塞,可以提高资源的利用率。

缺点:信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。

信号驱动 I/O 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达一个数据报,或者返回一个异步错误。

但是,对于 TCP 而言,信号驱动的 I/O 方式近乎无用,因为导致这种通知的条件为数众多,每一个来进行判别会消耗很大资源,与前几种方式相比优势尽失。

异步 IO (asynchronous I/O)

  • 异步IO依赖信号处理程序来进行通知
  • 不过异步IO与前面IO模型不同的是:前面的都是数据准备阶段的阻塞与非阻塞,异步IO模型通知的是IO操作已经完成,而不是数据准备完成
  • 异步IO才是真正的非阻塞,主进程只负责做自己的事情,等IO操作完成(数据成功从内核缓存区复制到应用程序缓冲区)时通过回调函数对数据进行处理

相对于同步IO,异步IO不是顺序执行。用户进程进行aio_read系统调用之后,无论内核数据是否准备好,都会直接返回给用户进程,然后用户态进程可以去做别的事情。等到socket数据准备好了,内核直接复制数据给进程,然后从内核向进程发送通知。IO两个阶段,进程都是非阻塞的。异步过程如下图所示:

优点:异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠。

缺点:要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O。

五种 IO 模型比较

阻塞式与非阻塞式的比较:调用阻塞IO后进程会一直等待对应的进程完成,而非阻塞IO不会等待对应的进程完成,在kernel还在准备数据的情况下直接返回。两者的最大区别在于被调用方在收到请求到返回结果之前的这段时间内,调用方是否一直在等待。

阻塞是指调用方一直在等待而且别的事情什么都不做;非阻塞是指调用方先去忙别的事情。

阻塞、非阻塞讨论的是调用 IO 的调用者;

同步IO和异步IO的区别:同步IO在做IO操作时,会将进程阻塞掉,阻塞IO、非阻塞IO、IO复用、信号驱动IO都属于同步IO。因为这几种模型,虽然非阻塞IO在轮询是否准备好时是没有阻塞的,但在读取数据阶段,三个模型都发生阻塞。 异步IO是进程发出IO操作请求后,便返回不在理财,直到内核通知进程IO完成,IO再继续执行之前的操作。

同步、异步讨论的是被调用者;

从上图中我们可以看出,越往后,阻塞越少,理论上效率也是最优。

这五种 I/O 模型中,前四种属于同步 I/O,因为其中真正的 I/O 操作(recvfrom)将阻塞进程/线程,只有异步 I/O 模型才与 POSIX 定义的异步 I/O 相匹配。

IO 复用更多介绍

主要有几个函数epoll, select, poll

  1. 当客户处理多个描述符时(一般是交互式输入和网络套接口),必须使用I/O复用。
  2. 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
  3. 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
  4. 如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
  5. 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。

select

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。

等待多个文件描述符, 当任意一个可用时, select 就返回, 否则一直阻塞. 函数返回时, 如果不是因为超时, 可以通过遍历文件描述符来找到就绪的描述符(用 FD_ISSET 检测文件描述符).

select 对等待的文件描述符数量有限制.

函数参数为: 三个集合大小中的最大值加一, 等待读的文件描述符集合, 等待写的文件描述符集合, 等待异常的文件描述符集合, 最长等待时间. 返回值是就绪的文件描述符数量.

poll

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。

同样是等待多个文件描述符, 当一个就绪或超时时就返回, 需要遍历 fd 来获得就绪的 fd.

poll没有等待的数量的限制, 但是过多时性能会下降.

通过pollfd集合来指定文件描述符.

epoll

epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

首先建立一个epoll句柄, 内核此时会建立一个数据结构来维护要等待的文件描述符及其事件.

然后通过epoll_ctl增删改等待的文件描述符及其事件.

最后通过epoll_wait来等待文件描述符就绪.

epoll有两种工作模式: 水平触发(level trigger)和边沿触发(edge trigger). 水平触发是默认的工作模式, 指当IO就绪时, epoll_wait 检测到事件后, 如果不处理, 事件不会立刻销毁, 下次调用 epoll_wait 时仍然可以检测到此事件. 边沿触发则不同, 当 epoll_wait 检测到事件后, 就会将事件销毁, 如果不处理, 下次 epoll_wait 就不能检测到此事件.

因此当采用边沿触发时, 如果读到的数据与请求的大小相同, 很可能还有数据没有读完. 因此此时要采用非阻塞读, 直到返回的数据少于请求的数据或返回EAGAIN错误.

epoll_wait 可以直接得到就绪的文件描述符以及对应的事件, 因此不需要扫描所有文件描述符.

epoll 相比 poll 和 select 的另一个优势是不需要在每次调用时都将文件描述符集合复制到内核空间.

epoll 的最大 fd 数等于最大的可打开文件数.

IO 复用的实现原理

IO设备的驱动程序中有一个等待队列, 可以通过驱动程序提供的 poll 接口来获取IO设备是否就绪, 也可以将进程加入到等待对应的等待队列中. 当IO设备就绪时, 就可以通知等待队列中的进程, 将其从睡眠中唤醒. select, poll 的实现是:

  1. 扫描所有 fd, 利用 poll 接口检测对应的IO是否就绪, 并将进程加入到等待队列.
  2. 如果存在就绪的 fd, 就在遍历后返回就绪 fd 的数量.
  3. 如果没有就绪的 fd, 就睡眠一段时间.
  4. 如果休眠结束或被IO驱动程序唤醒, 继续循环上面的过程. 如果是后者, 就会检测到就绪的 fd, 从而可以在第二步返回.

epoll 流程也类似上面, 只有下面几点不同:

  1. epoll 在创建句柄时将 fd 集合拷贝到内核, epoll_wait 时不需要再拷贝 fd 集合, 这样对同一集合多次调用 epoll_wait 时就只需要拷贝一次.
  2. epoll 的句柄通过一个红黑树来维护 fd 集合, 每次加入时会先在红黑树中查找是否已经保存了该 fd,
  3. 时间复杂度是 log(N)log(N).
  4. epoll 第一次也要遍历 fd, 并将进程加入到等待队列, 但是同时为每个 fd 指定了回调函数, 当 fd 就绪时,设备驱动程序会唤醒进程, 并调用此回调函数. 这个回调函数的作用是将这个就绪的 fd 加入到就绪链表.因此, 当进程从等待中被唤醒时, 就可以直接通过检查这个就绪链表是否为空来判断是否有 fd 就绪,而不需要像 select/poll 一样再次遍历 fd 集合.
  5. 调用 epoll_wait 时, 会把就绪的 fd 拷贝到用户态内存, 然后清空就绪链表, 最后再检查这些 fd.在水平触发模式下, 如果检查到这些 fd 上还有未处理的事件, 会将这些 fd 放回就绪链表中, 保证事件得到正确处理.
  6. 对于 fd 集合大小的限制, epoll 是进程可以打开的最大文件数目, 这个值保存在 /proc/sys/fs/file-max.

Reference

  1. unix 网络编程 第一卷
  2. 五种IO模型介绍和对比
  3. 一文读懂高性能网络编程中的I/O模型
  4. 聊聊IO多路复用之select、poll、epoll详解