I/O多路复用

深入了解Socket编程

时隔 5 个月,我又新写了一篇文章更详细的介绍 I/O 多路复用技术,欢迎移步 I/O 多路复用: 从入门到放弃(一)

Table of Contents

在《UNIX网络编程》中,将全部的I/O模型抽象成5类:

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

其中前四类称为同步I/O,最后一个是异步I/O。在这篇文章中主要介绍的是I/O 多路复用相关的知识,它是利用非阻塞I/O实现的同步I/O。

背景

Socket与fd

socket几乎完全依赖文件描述符(fd),相关的fd主要分两类listenfd和connfd。listenfd称为监听套接字(listening socket)描述符,它的生命周期与服务器的生命周期一致且一般一个服务器保有一个listenfd。connfd是已连接套接字(connected socket)描述符,一个连接对应一个connfd,当连接关闭时connfd也会被关闭。值得一提的是connfd是在完成TCP三次握手后被创建。

实现并发服务器主要包括并发建立连接和并发处理连接。那么一个服务器只有一个listenfd,socket是如何实现并发建立连接的呢?这个其实是内核为一个套接字维护了两个队列:未完成连接队列和已完成连接队列,在调用listen()方法时可以设置backlog参数,该参数指定了两个队列的容量总和,即最大连接数(DDoS攻击就利用了这个机制),当然并发建立连接的过程对于开发者来说是透明的,整个过程由内核维护。开发者需要关系的实际上是并发处理连接,上面介绍过在连接建立后会创建connfd,这里开发者需要把connfd分发给子进程或者其他线程。

阻塞?非阻塞?同步?异步?

整个网络I/O过程可以分为两步:等待数据到达和数据拷贝。前者很好理解,因为网速慢需要等待数据到达。后者则是机制问题,网络到达数据首先存储至内核空间,等待全部数据完成后将会从内核空间拷贝到用户空间12

阻塞/非阻塞3则是针对查询数据到达状态的。如果是阻塞的则用户进程必须等待直到数据到达;如果是非阻塞的,其形式主要包括轮询、事件驱动等方式,在数据没有到达时就立即返回错误(如EAGAIN),允许用户进程在这期间去做别的事情。

同步和异步主要区分点在于从内核空间拷贝到用户空间是否需要用户进程等待,如果是则说明是同步过程,如果否则说明是异步过程。

实现

在Linux中,主要的实现方案是select, poll和epoll,其中select和poll相比于epoll的性能低下,所以epoll用的更广泛。I/O多路复用是基于事件驱动4方式实现的,使用一个线程监听多个fd,这样比每个线程不停监听自己的fd的效率要高不少(非阻塞I/O模式),这里复用指的是复用线程。

select和poll方法

select和poll方法实现的基本原理差不多,所以这里以select方法为例。

select方法的核心是fd_set数据结构。本质上fd_set是一个bitset5,fd_set的大小将直接决定select方法最大的监听量。

select接收的参数包括5个:

  • nfds:指定最大的fd序号,select是通过遍历的方式检查fd的就绪状态,fd_set支持的监听量可能包括上千个,如果不指定则需要完全遍历全部的fd,如果指定则读到最大的fd停止。
  • readfds、writefds和expectfds:指定让内核测试读、写和异常条件的描述符。
  • timeout: 定时器。

select方法在执行的时候会被阻塞,其他的用户进程则可以继续做自己的事情。根据timeout传入值的不同,select的返回时机包含3种情况:

  • timeout传入为空,select将永远等待,直到有一个描述符准备好后就返回了。
  • timeout不为空且不为0,在不超过timeout时间内有一个描述符准备。
  • timeout为0,则此时变为轮询模式。

与fd_set相关的四个宏的作用:

  • FD_ZERO:初始化fd_set。
  • FD_CLR:将fd从fd_set中清除。
  • FD_ISSET:检查fd是否存在于fd_set中。
  • FD_SET:将fd添加到fd_set中。

select方法完整的使用流程67如下:

select/poll方法的不足:

  • 监控fd数量受限于fd_set的大小。
  • 每次调用select方法都需要将fds从用户空间拷贝到内核空间,在fd很多时有明显的拷贝性能消耗。
  • 内核监控时需要完整地扫描fd_set,所以时间复杂度为O(n)(遍历逻辑51行-82行)。

epoll方法

epoll方法提供的系统调用主要包括3个:

epoll_create创建epoll实例并返回epoll的文件描述符(epfd),接收的参数size表示监控fd的数量,但是在Linux 2.6.8以后epoll实现了监控数量的自适应,系统会忽略size的值。epfd指向的是内核中的epoll的数据结构,后续添加、删除以及修改需要监控的fd需要依赖该epfd。

epoll_ctl允许向epoll实例中添加/修改/删除需要监控的fd,在epoll实例中维护了一个名为epoll set(AKA interest set),用于保存已注册的fd,值得一提的是epoll使用红黑树来管理fd,让查找等操作更加高效。当epoll set中的任一fd就绪后,会被添加到ready set中。

epoll_ctl的其中一个参数event,其类型是一个epoll_event的结构体,主要包含两个字段event和data。event表示需要监控fd的事件,数据字段是一个union字段,携带这个事件的上下文信息。常用的事件8包括:

  • EPOLLIN(监控读事件)
  • EPOLLOUT(监控写事件)
  • EPOLLRDHUP(监控读关闭事件)9

epoll_wait则是用于阻塞等待,直到有任一fd准备就绪。

epoll方法通过epoll_ctl方法注册fd,此时fd从用户空间复制到内核空间,在整个生命周期中fd只会被复制一次,其次在fd就绪后通过回调的方式将已经就绪的fd添加到就绪队列,不需要遍历fd集合,因此整个时间复杂度降至O(1),最后epoll_wait方法实际上就是不停地检查就绪链表是否有就绪的fd,如果用则返回。

  1. https://strikefreedom.top/go-netpoll-io-multiplexing-reactor

  2. 请参阅《UNIX网络编程》“I/O复用:select和poll函数章节”

  3. https://www.zhihu.com/question/19732473

  4. https://www.redhat.com/zh/topics/integration/what-is-event-driven-architecture

  5. IntSet的Golang实现:参见《The Go Programming Language》“Section6.5 Example: Bit Vector Type”

  6. https://blog.csdn.net/lihao21/article/details/66097377

  7. 正常情况下应在另一个线程中调用select方法,但是为了方便选择阻塞主进程。

  8. https://man7.org/linux/man-pages/man2/epoll_ctl.2.html

  9. https://www.linuxidc.com/Linux/2016-04/129819.htm