蛮荆

I/O 多路复用模型

2022-01-23

I/O 多路复用是什么?

I/O 多路复用于处理同一个事件循环中的多个 I/O 事件,这里的「多路」指多个 IO 事件,「复用」指处理事件的程序 (线程) 是同一个。

I/O 多路复用(I/O Multiplexing)是一种高效处理多个 I/O 事件的技术,允许单个进程/线程同时监听多个文件描述符(如套接字、文件、管道等),并在这些文件描述符中的任何一个变得可读或可写时进行相应的处理 (内核通知上层应用程序,然后由应用程序进行对应的操作)。

和传统的多进程/多线程支持并发 IO 相比,I/O 多路复用最大的优势就是系统开销小,不必创建大量的进程/线程 (自然也没有维护这些资源带来的额外开销),此外,I/O 多路复用是非阻塞的,可以尽可能地提高服务器的吞吐能力,所以广泛应用于现代网络编程中,

同步 I/O 和异步 I/O

虽然 I/O 多路复用可以同时监听多个文件描述符,但是其本质上还是同步 I/O 模型,因为在读写事件 (非阻塞) 就绪后,应用程序需要负责进行事件对应的操作,而这个操作过程是阻塞的。

真正的异步 I/O 不需要应用程序进行读写,异步 I/O 的 (内核) 实现会负责将数据直接从内核空间拷贝到用户空间,应用程序只需要设置好对应的事件回调即可。

阻塞与非阻塞 I/O

水平触发和边缘触发

水平触发:当文件描述符对应的事件就绪时,进行系统调用,内核会返回该事件,应用程序如果没有处理,那么之后系统调用时,内核依然会返回该事件,直到应用程序处理完成为止,也就时说,内核会通知多次。应用程序编程简单,适用于需要处理多个事件,并且不要求一次性处理所有数据的场景,当然,不足之处就是如果处理不及时,可能会导致事件被反复通知,增加系统调用的开销。

边缘触发:当文件描述符对应的事件就绪时,内核只会通知一次,应用程序如果没有处理,之后不会再次通知,所以就要求应用程序在接收到事件通知后,尽可能多地读取或写入数据,直到系统调用 (例如 read/write) 返回 EAGAIN 错误。系统调用次数减少可以提升性能,当然,不足之处就是提升了应用程序编程复杂度,需要确保在单次通知中处理完所有数据,否则就会发生数据丢失。


进程/线程模型

I/O 多路复用机制落实到具体的代码实现,主流方式还是多进程/多线程编程模式的各类组合,下面列举几种常见的实现模型。

1. 主进程 (master) + 多个子进程 (worker)

  1. 主进程执行 bind() + listen(),创建多个子进程
  2. 每个子进程中,都通过 accept() 或 epoll_wait() 处理连接

这种模型的好处在于主进程和子进程的职责分明,主进程负责事件分发,子进程负责事件处理,不足之处也很明显,需要额外的进程通信开销、主进程可能成为瓶颈,此外还有 惊群问题

惊群问题:当网络 I/O 事件发生时,多个进程/线程被同时唤醒,但实际上只有一个进程/线程来响应这个事件,其他被唤醒的进程/线程就会重新休眠。

为了避免惊群问题, Nginx 增加一个了全局锁(accept_mutex), worker 进程首先需要竞争到锁,只有竞争到锁的进程,才会加入到 epoll 中,这样就确保只有一个 worker 子进程被唤醒。

2. 多进程模型

所有的进程都监听相同的端口,并且开启 SO_REUSEPORT 选项,内核负责对请求进行负载均衡,分配到具体的监听进程,内核确保只有一个进程被唤醒,所以不会出现惊群问题。

多个进程之间通常通过共享监听套接字来处理客户端请求,具体的负载均衡工作交给操作系统,新连接到达后,操作系统会自动分配给一个空闲的工作进程。

由于每个工作进程都是独立的,不会共享内存,避免了进程间通信带来的开销。当然,为了高性能,这种模型依然要依赖底层提供的 IO 多路复用机制、并且以事件驱动的方式来处理具体操作,所以本质上和下文中的 Reactor 模型是一样的 (唯一的区别在于多进程还是多线程)。

下面是 Nginx 的多进程监听相同端口示例图。

3. 多线程模型

每个连接由一个单独的线程处理,虽然线程的开销远远小于进程,但是需要处理线程之间的数据同步和共享问题,以及更麻烦的数据竞争和锁 (线程安全性)。

其次,线程开销少只是相对进程来说,当面对海量连接时,one thread per connection 的模式注定无法处理。

4. Reactor 模型

通常由一个主线程监听 I/O 事件,并在事件到达后,分发给相应的事件处理 (回调函数)。

Reactor 模型是事件驱动的 I/O 多路复用模型的典型代表,通常由以下几个部分组成:

  • 事件多路复用器:如 select、poll、epoll 等,用于监听 I/O 事件变化 (内核实现)
  • 事件分发器:负责将 I/O 事件分发给相应的事件处理器 (框架开发者来实现)
  • 事件处理器:处理具体的 I/O 事件 (应用开发者来实现)

这种模型实现相对复杂,尤其是线程安全性方面,但是该模型可以支持高并发处理 (主要通过复用来最小化创建、销毁 进程/线程带来的开销),并且架构核心的多路复用和事件分发/处理是解耦的,所以扩展性和可维护性很好,这也是大多数主流网络编程框架,使用该模型作为实现的主要原因。

Reactor 模型又可以细分为:

  • 单 Reactor 单线程模型 (早期的 Redis 中的 I/O 多路复用实现)
  • 单 Reactor 多线程模型
  • 主从 Reactor 多线程模型 (多 Reactor 多线程模型)

限于篇幅,本文不展开讲解这些细分模型。


操作系统提供的 API

前文中提到,事件多路复用器 是由内核实现的,开发框架模块封装了操作系统内核实现的 I/O 多路复用模块,为应用层提供了同一的接口,这样应用程序就可以通过一个方法调用获取到多个发生变化的事件。

例如在 Nginx 中, I/O 多路复用模块针对不同的平台,封装了不同的 API。

select

select 是最早的一种 I/O 多路复用机制,允许应用程序监视多个文件描述符,等待它们变为可读、可写或有错误发生。使用 select 时,应用程序需要提供一个文件描述符集合,并在每次调用时重新初始化该集合。

特点

  • 可移植性强,几乎在所有操作系统上都能使用 (包括 Windows)
  • 有文件描述符数量的限制(在 32 位系统中,默认限制是 1024)
  • 性能取决于文件描述符数量,因为每次调用都需要遍历整个文件描述符集合 (时间复杂度 O(N)),所以文件描述符数量较多时性能较差

select 底层维护一个类似 Set 的数据结构,用于存放 (套接字对应的) 文件描述符,当套接字状态为就绪时,select 将对应的文件描述符复制到用户空间,并返回就绪的文件描述符数量,这会导致用户空间和内核空间来回复制该结构时带来额外的开销。

而且 select 内部检查套接字状态时,采用的是轮询遍历,时间复杂度为 O(N), 此外应用程序无法得知哪些文件描述符是就绪的,所以需要遍历全部的文件描述符,总的时间复杂度为 O(N)

下面将 select 工作流程翻译为简单的伪代码。

# 初始化文件描述符集
read_fds = set()
write_fds = set()
error_fds = set()

# 添加文件描述符到集合中
read_fds.add(socket1)
read_fds.add(socket2)

...

# 超时时间为 5 秒
timeout = 5 
# 调用 select 系统调用
# 每次调用时需要重新初始化文件描述符集合
# 否则就无法区分状态是之前的,还是重新获取后的
# 内核会将就绪的 文件描述符 设置为 OK, 然后返回给用户空间
# 总共经过 2 次上下文切换和数据拷贝
#   1. 将文件描述符集合从用户空间拷贝到内核空间
#   2. 将文件描述符集合从内核空间拷贝到用户空间
ready_read, ready_write, ready_error = select(read_fds, write_fds, error_fds, timeout)

# 处理就绪的文件描述符
for fd in ready_read:
    if fd == socket1:
        handle_socket1_read()
    elif fd == socket2:
        handle_socket2_read()

for fd in ready_write:
    if fd == socket1:
        handle_socket1_write()
    elif fd == socket2:
        handle_socket2_write()

for fd in ready_error:
    handle_error(fd)

poll

pollselect 机制进行了改进,取消了文件描述符数量的限制(当然还会受到系统文件描述符限制),底层数据结构改为链表,同样允许应用程序监听多个文件描述符。

poll 和 select 类似,因为用户空间和内核数据的数据结构复制开销,以及对文件描述符的轮询遍历操作,所以 poll 在文件描述符数量较多时性能较差,总的时间复杂度同样为 O(N)

下面将 poll 工作流程翻译为简单的伪代码。

# 初始化 pollfd 结构体
# 这里使用数组模拟 poll 的链表
poll_fds = [
    {'fd': socket1, 'events': POLLIN},
    {'fd': socket2, 'events': POLLIN}
]

# 调用 poll 系统调用
timeout = 5000  # 超时时间为 5秒
ready_fds = poll(poll_fds, timeout)

# 处理就绪的文件描述符
# 和 select 一样,依然会经过 2 次上下文切换和数据拷贝
for pfd in ready_fds:
    if pfd['revents'] & POLLIN:
        if pfd['fd'] == socket1:
            handle_socket1_read()
        elif pfd['fd'] == socket2:
            handle_socket2_read()
    if pfd['revents'] & POLLOUT:
        if pfd['fd'] == socket1:
            handle_socket1_write()
        elif pfd['fd'] == socket2:
            handle_socket2_write()
    if pfd['revents'] & POLLERR:
        handle_error(pfd['fd'])

epoll

前文中提到的 selectpoll 存在的性能问题,主要原因可以总结为两点:

  1. 底层数据结构,select 和 poll 采用的都是时间复杂度为 O(N) 的数据结构,所以遍历时只能采用轮询方式,这从根本上决定了性能会随着文件描述符数量变多而降低
  2. 复制方式,select 和 poll 在 (套接字对应的) 文件描述符状态发生变化后,没有对应的事件回调机制,需要调用方主动 (轮询) 将文件描述符集合从用户空间复制到内核空间,内核修改完成后,再从内核空间复制到用户空间,两次的上下文切换会带来不小的开销

如果想要优化 selectpoll 的性能,最有效的方式就是从这两方面入手,所以 epoll 应运而生。

epoll 是 Linux 特有的 I/O 多路复用机制,目标是高效处理大量并发连接场景,同时支持边缘触发和水平触发两种模式,使用事件通知机制,避免了轮询遍历文件描述符集合。

  1. 首先,epoll 添加/删除操作文件描述符都是增量方式,而不是像 select, poll 每次都是全量方式 (初始化 + 复制传递)
  2. 其次,epoll 只需要检测 就绪事件链表 中有无数据即可,如果链表中有数据,直接复制到用户态,并返回就绪的文件描述符数量。由于通知到应用程序的已经是就绪的文件描述符,因此应用程序无需再次遍历,直接进行处理即可,时间复杂度 O(1)。当然,将 就绪事件链表 从内核空间复制到用户空间的开销是无法避免的

epoll 的实现主要使用到了两种数据结构:

  1. 红黑树
  2. 双向链表
  • 采用红黑树数据结构来管理内核中的 所有文件描述符,因为红黑树插入、更新、删除操作时间复杂度都是 O(log N), 避免了轮询遍历时的 O(N) 复杂度
  • 采用双向链表存储 就绪事件

epoll 主要的几个函数:

  • epoll_create(): 创建一个 epoll 文件描述符,这是一个内核对象,用于管理所有被监听的文件描述符 (套接字)
  • epoll_ctl(): 添加、删除、修改某个具体的文件描述符 (操作的是 红黑树数据结构)
  • epoll_wait(): 等待已经就绪事件的文件描述符 (操作的是 双向链表数据结构),如果没有文件描述符就绪,调用线程会进入挂起等待,直到有文件描述符变为 就绪或超时

应用程序系统调用 epoll_ctl 时采用事件通知回调方式,例如调用 add 操作时,不仅将文件描述符加入到内核的红黑树中 (树中节点以文件描述符 fd 对象进行对比,所以重复添加也没什么影响),同时事件还向内核注册了对应的回调函数,这样在某个事件就绪时,主动调用回调函数,然后回调函数会把该事件加入到 就绪事件链表 中。

下面将 epoll 工作流程翻译为简单的伪代码。

# 初始化,创建一个 epoll 文件描述符实例
epoll_fd = epoll_create() 

# 添加要监听的文件描述符到 epoll 实例
epoll_ctl(epoll_fd, ADD, listen_sock, READ_EVENT)

# 主循环
while True:
  # 等待事件发生
  events = epoll_wait(epoll_fd)

  # 遍历返回的就绪事件链表
  for event in events:
      if event.fd == listen_sock:
          # 新的客户端连接
          conn_sock = accept(listen_sock)
          # 将连接对应的文件描述符添加到红黑树
          epoll_ctl(epoll_fd, ADD, conn_sock, READ_EVENT)
      else:
          # 处理现有连接的事件
          if event.type == READ_EVENT:
              data = read(event.fd)
              if data:
                  # 处理读取到的数据
                  process(data)
              else:
                  # 将连接对应的文件描述符从红黑树中删除
                  epoll_ctl(epoll_fd, REMOVE, event.fd)
                  # 连接关闭
                  close(event.fd)

三者的性能差异

select、poll 和 epoll 的模式区别

在文件描述符复制方面,相比于 select, poll 每次系统调用时复制全部的文件描述符的开销,epoll 虽然同样会发生系统调用,但是复制的是 已经就绪 的文件描述符,其量级要远远小于全部的。

在事件 (文件描述符) 管理方面,相比于 select, poll 不断轮询所有文件描述符的方式 (时间复杂度 O(N)),epoll 只需要调用 epoll_wait 检测和遍历 就绪事件链表 即可 (时间复杂度 O(1))。

可以想象一下,假如文件描述符有 10000 个,平均每次就绪事件为 10 个:

  • select、poll 每次主动遍历所有文件描述符,一共 10000 次操作
  • epoll 只需要判断 就绪事件链表 是否为空 (1 次操作),然后遍历链表 (10 次操作),一共 11 次操作

select、poll 和 epoll 的性能差异

从图中可以看到,如果文件描述符数量在 1000 个以内,三者之间几乎没有性能差异。


必须要使用 epoll 吗 ?

在大量并发连接的场景中 (例如 Web Server),毫无疑问的首选方案是 epoll, 那么 selectpoll 是不是就一无是处呢?

当然不是,简单来说:

select, poll 适合连接数量很少,但是每个连接都很活跃的场景

epoll 适合连接数量很多,但是活跃连接很少的场景,毕竟 epoll 的事件通知、回调机制也会带来系统开销

此外,select 和 poll 还有下面的一些微小的差异:

  • select 的可移植性最好,而且 timeout 超时参数为微妙,而 poll 为毫秒,因此实时性可以更高一些
  • poll 没有最大描述符数量的限制,如果系统对实时性没有严格要求,应该使用 poll 替代 select

扩展阅读

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。