一文了解 IO 多路复用

214 阅读10分钟

写在前面

在了解 IO 多路复用的机制之前,我们有必要先知道操系统中的一些概念。

  • 用户空间/内核空间
  • 文件描述符
  • 缓存 IO

用户空间和内核空间

在操作系统中,用户空间和内核空间是两个不同的运行环境,用于执行不同的任务和提供不同的功能。

用户空间:操作系统中给应用程序提供的一块独立的内存区域。在用户空间中运行的应用程序是在操作系统的控制下执行的,但它们**不能直接访问操作系统的底层资源或硬件设备。应用程序可以使用操作系统提供的应用程序接口(API)**来请求操作系统执行特定的任务,如文件操作、网络通信等。

内核空间:操作系统的核心部分,它控制着计算机的所有硬件资源和系统功能。内核空间具有更高的权限和更广泛的访问权限,可以直接访问硬件设备,并执行特权指令。负责处理应用程序发起的系统调用请求,并根据请求的要求执行相应的操作。

用户空间和内核空间之间通过系统调用(System Call)进行通信。当应用程序需要访问内核提供的功能或资源时,它会通过系统调用请求内核执行相应的操作。

文件描述符

文件描述符(File Descriptor)是在 Unix-like 系统(各种 Unix 的派生系统以及各种与传统 Unix 类似的系统,例如Minix、Linux、QNX等)中用于表示打开文件或其他 I/O 资源的整数标识符。它是操作系统内核为了管理和操作文件而提供的一种抽象概念。

在 Unix-like 系统中,一切都是文件包括普通文件、目录、套接字、管道等等。为了对这些文件进行操作,需要使用文件描述符来引用它们。文件描述符是一个非负整数,它是操作系统为每个进程维护的一个表格(称为文件描述符表)的索引。

当应用程序打开一个文件或创建一个新文件时,操作系统会分配一个文件描述符给该文件,并将其添加到该进程的文件描述符表中。文件描述符可以看作是应用程序与打开的文件之间的连接。文件描述符是每个进程独立维护的,不同进程可以拥有相同的文件描述符值,但它们引用的是不同的文件或资源。

缓存 IO

在 Linux 的 IO 机制中,当用户进行 I/O 操作(从磁盘、网卡读取数据)时,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间

没有 IO 多路复用的世界

阻塞 IO(BIO)

在 阻塞 IO 中,用户去读取数据时,会去尝试从系统内核上加载数据,如果没有数据,那么用户就会等待,此时内核会去从硬件上读取数据,内核读取数据之后,再把数据拷贝到用户态整个过程,都是阻塞等待的。

阶段一:

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 此时用户进程也处于阻塞状态

阶段二:

  • 数据到达并拷贝到内核缓冲区,代表已就绪
  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

非阻塞 IO(NIO)

在非阻塞 IO 中,用户去读取数据时,会去尝试从系统内核上加载数据,如果没有数据,会直接返回而不是等待

阶段一:

  • 用户进程尝试读取数据(比如网卡数据)
  • 此时数据尚未到达,内核需要等待数据
  • 返回异常给用户进程
  • 用户进程拿到 error 后,再次尝试读取
  • 循环往复,直到数据就绪

阶段二:

  • 将内核数据拷贝到用户缓冲区
  • 拷贝过程中,用户进程依然阻塞等待
  • 拷贝完成,用户进程解除阻塞,处理数据

实际上,虽然采用的是非阻塞的方式,但性能并没有得到提高,用户进程会不断地尝试读取,导致CPU空转,CPU使用率暴增。

IO 多路复用

在上面的介绍的两种 IO 模型中,想要支持并发,往往需要为每个IO通道创建一个线程,但当有大量的 IO 通道时,线程的创建和切换开销会显著增加。那有没有可能只使用一个进程维护多个 IO 通道呢?答案是有的,那就是 I/O 多路复用技术

一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用。举个例子:在 socket 编程中,IO 多路复用使得服务端使用一个线程就能处理多个客户端的请求。

在 Linux 中,IO 多路复用有以下几种常见的实现方式:

  • select
  • poll
  • epoll

下面我们来介绍以下这几种实现方式。

select

select 是 Linux 最早是由的 IO 多路复用技术。

在 select 模式中,

  • 我们把需要处理的数据(在网络模型中体现为已经建立连接的 socket)封装成FD
  • 然后在用户态时创建一个 FD 的集合(这个集合的大小是要监听的那个FD的最大值+1,但是大小整体是有限制的 ),这个集合的长度大小是有限制的,同时在这个集合中,标明出来我们要控制哪些数据(要监听哪些 socket)
  • 执行 select 函数,然后将整个 FD 集合发给内核态
  • 内核通过遍历 FD 集合的方式来检查是否准备好的数据(对于网络IO来说是检查是否有网络事件产生),当检查到有事件产生后,将此 Socket 标记为可读或可写。
  • 将这个FD集合写回到用户态中
  • 但是对于用户态而言,并不知道谁处理好了,所以用户态也需要去进行遍历,然后找到对应准备好数据的节点,再去发起读请求。

对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 BitsMap,表示 FD 集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll

poll模式对select模式做了简单改进,但性能提升不明显。

IO流程:

  • 创建 pollfd 数组(相当于 select 中的 FD 集合),向其中添加关注的 fd 信息,数组大小自定义。
  • 调用poll函数,将 pollfd 数组 拷贝到内核空间,转链表存储,无上限
  • 内核遍历 FD,判断是否就绪
  • 数据就绪或超时后,拷贝 pollfd 数组到用户空间,返回就绪 FD 数量 n
  • 用户进程判断 n 是否大于 0,大于 0 则遍历 pollfd 数组,找到就绪的 fd

与select对比,poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,但是 poll 和 select 并没有太大的本质区别,在寻找可被处理的数据时,都只能使用遍历的方式。而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll

epoll 模式是对 select 和 poll 的改进,它提供了三个函数:

  1. epoll_create():在内核空间创建一个 epoll 对象 epfd,epfd 内部包括了两部分
    1. 红黑树:记录要监听的 FD
    2. 链表:记录就绪的可以被处理的 FD
  2. epoll_ctl():将要监听的数据添加到红黑树上去,并且给每个 FD 设置一个监听函数,这个函数会在 FD 数据就绪时触发,将 FD 添加到链表中。
  3. epoll_wait() :返回有事件发生的文件描述符的个数

总的来说,

  • epoll模式下,用户首先调用 epoll_create() 在内核空间创建一个 epoll 对象 epfd,该对象维护了一棵红黑树,负责记录要监听的 FD;一个链表,记录就绪的 FD
  • 然后用户调用epoll_ctl() ,将要监听的数据添加到红黑树上去。并注册监听函数,当某个 FD 有事件发生时,通过监听函数内核会将其加入到这个就绪事件列表(也即 epfd 的链表中)中。
  • 当用户调用 epoll_wait() 函数时,在用户态创建一个空的 events 数组,然后去检查 epfd 中的链表,当然这个过程需要参考配置的等待时间,可以等一定时间,也可以一直等, 如果在此过程中,检查到了链表中有数据,将数据放入到 events 数组中,并且返回对应的文件描述符的个数。
  • 这样一来,epollo 不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

💡 当 FD 有数据可读时,我们调用 epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:ET 和 LT

ET 和 LT

  • LevelTriggered:简称LT,也叫做水平触发。只要某个 FD 中有数据可读,每次调用 epoll_wait 都会得到通知。
  • EdgeTriggered:简称ET,也叫做边沿触发。只有在某个 FD 有状态变化时,调用 epoll_wait 才会被通知。

例如:

  1. 假设一个客户端 socket 对应的 FD 已经注册到了 epoll 实例中
  2. 客户端 socket 发送了 2kb 的数据
  3. 服务端调用 epoll_wait,得到通知说 FD 就绪
  4. 服务端从 FD 读取了 1kb 数据。回到步骤3(再次调用 epoll_wait,形成循环)

如果我们采用 LT 模式,因为FD中仍有1kb数据,则再次调用 epoll_wait 依然会返回结果,并且得到通知。

如果我们采用 ET 模式,因为第 3 步已经消费了 FD 可读事件,再次调用 epoll_wait FD 状态没有变化,因此 epoll_wait 不会返回,数据无法读取,客户端响应超时。

一般来说,ET 的效率比 LT 的效率要高,因为边缘触发可以减少 epoll_wait的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。