I/O 多路复用机制

I/O 多路复用机制

9月 4, 2022
Linux, 操作系统, 系统设计, 数据结构

面试问的太多了,还是专门整理一下吧,理清知识结构。

总览 #

首先需要注意的是,下面讨论的几种 I/O 模型都属于同步(synchronous) I/O 操作,至于异步(asynchronous) I/O 操作,以后再说。

阻塞式 I/O(Blocking I/O) #

传统的 I/O 模型都是单个进程每次只在一个文件描述符上执行 I/O 操作,每次 I/O 系统调用都会阻塞直到完成数据传输,阻塞的好处就是挂起了线程不消耗 CPU,坏处就是处理起来是线性的,效率非常慢。

如果不希望进程在对文件描述符执行 I/O 操作时被阻塞,我们可以创建一个新的进程来执行 I/O ,此时父进程就可以去处理其他的任务了,而子进程将阻塞直到 I/O 操作完成。如果我们需要处理多个文件描述符上的 I/O,可以为每个文件描述符创建一个子进程。但是这种方式的问题在于多进程上下文切换开销昂贵且复杂,创建进程需要开销,子进程通过 IPC 机制通知父进程也需要开销。

如果是多线程的呢?还是不太行,因为本质上还是阻塞式 I/O。虽然多线程减少了多进程的切换开销,但是同样面临着资源的并发访问问题,同时线程的空间占用同样不小,仍然无法实现大规模的 I/O 请求匹配到所有线程的情况。

非阻塞式 I/O(Non-Blocking I/O) #

由于阻塞式 I/O 天花板太低,于是又产生出了非阻塞式 I/O,非阻塞式 I/O 可以让我们周期性地检查(“轮询”)某个文件描述符上是否可以执行 I/O 操作。从而避免一个子进程/线程阻塞等待的现象。

比如使用 read() 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。另外也可以依赖多线程的模型,在得到错误值之后启动新的线程去继续做轮询,主线程继续保持非阻塞效果。

但是非阻塞式 I/O 仍然存在的问题是,I/O 请求越多,轮询的事件复杂度就线性地增加,并且空轮询本身也是浪费 CPU,所以也并不适合大量的 I/O 操作。

I/O 多路复用技术 #

所以最终还是 I/O 多路复用的技术更可取,I/O 多路复用允许进程同时检查多个文件描述符以找出它们中的任何一个是否执行 I/O 操作,系统调用 select()poll() 用来执行 I/O 多路复用。

需要注意的是,I/O 多路复用机制与上面的几种方式都不一样,上面的轮询是线程和文件描述符的一对一周期性轮询,而这里看起来的轮询更像是遍历的含义,是单线程在遍历文件描述符集合。

信号驱动 I/O #

信号驱动 I/O 是指当有输入或者数据可以写到指定的文件描述符上时,内核向请求数据的进程发送一个信号,进程收到信号之前可以处理其他的任务,当 I/O 操作可执行时通过接收信号来获得通知。

当同时检查大量的文件描述符时,信号驱动 I/O 相比 select()poll() 有显著的性能提升。坏处就是编程难度非常高,尤其回调机制写过的都知道。

水平触发和边缘触发 #

另外在详细了解各种 I/O 多路复用方式之前,还需要再介绍下现有的两种文件描述符准备就绪的通知方式:水平触发通知边缘触发通知

两者的区别是,水平触发只要满足事件的条件,比如内核中有数据需要读,就一直不断地把事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才会触发,之后就不再传递同样的事件了,即只传递新产生的事件。

I/O 模式水平触发边缘触发
select(), poll()
信号驱动 I/O
epoll

I/O 多路复用 #

系统调用 select()poll() 在 UNIX 系统中已经存在了很长时间,同其他技术相比,它们主要的优势在于可移植性,主要缺点在于当同时检查大量的(数百或数千个)文件描述符时性能延展性不佳。

我们可以在普通文件、终端、伪终端、管道、FIFO、套接字以及一些其他类型的字符型设备上使用 select()poll() 来检查文件描述符。这两个系统调用都允许进程要么一直等待文件描述符成为就绪态,要么在调用中指定一个超时时间。

select() 系统调用 #

系统调用 select() 会一直阻塞,直到一个或多个文件描述符集合成为就绪态。

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数 fd_set #

参数 readfdswritefdsexceptfds 都是指向文件描述符集合的指针,所指向的数据类型是 fd_set。它们也都是保存结果值的地方,在调用 select() 之前这些结构体必须初始化。

  • readfds 是用来检测输入是否就绪的文件描述符集合。
  • writefds 是用来检测输出是否就绪的文件描述符集合。
  • exceptfds 是用来检测异常情况是否发生的文件描述符集合。

文件描述符集合 fd_set 是以位掩码的方式来实现的,关于 fd_set 操作都是通过四个宏来完成的:

#include <sys/select.h>

void FD_ZERO(fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_CLR(int fd, fd_set *fdset);
void FD_ISSET(int fd, fd_set *fdset);
  • FD_ZERO() 将 fdset 所指向的集合初始化为空。
  • FD_SET() 将文件描述符 fd 添加到 fdset 所指向的集合中。
  • FD_CLR() 将文件描述符 fd 从fdset 所指向的集合中移除。
  • 如果文件描述符 fd 是 fdset 所指向的集合中的成员,FD_ISSET 返回 true。

文件描述符集合有一个最大的容量限制,由常量 FD_SETSIZE 决定,在 Linux 上,该值为 1024。

参数 timeout #

参数 timeout 控制着 select() 的阻塞行为,该参数可指定为 NULL ,此时 select() 会一直阻塞,或者可以指向一个 timeval 结构体。

struct timeval {
    time_t tv_sec; /* Seconds */
    suseconds_t tv_usec; /* Microseconds */
}

如果结构体 timeval 的两个域都为 0 的话,此时 select() 不会阻塞,它只是简单地轮询指定的文件描述符集合,看看其中是否有就绪的文件描述符并立刻返回。否则,timeout 将为 select() 指定一个等待时间的上限值。

返回值 #

一般情况下,select() 会返回如下几种情况的一种:

  • 返回 -1 表示有错误发生。
  • 返回 0 表示在任何文件描述符成为就绪态之前 select() 调用已经超时。
  • 返回正整数表示就绪态的文件描述符的个数。

poll() 系统调用 #

系统调用 poll()select() 的机制很相似,两者的主要区别在于如何指定待查的文件描述符。

#include <poll.h>

int poll(struct pollfd fds[], nfds_t nfds, int timeout);

struct pollfd {
    int fd;
    short events;
    short revents;
}

参数 fds #

pollfd 结构体中的 eventsrevents 字段都是位掩码的形式,即可以指定多个事件。

  • events 需要通过初始化来为 fd 指定检查的事件(注意检查不是监听)。
  • reventspoll() 返回时被设定以此来表示该文件描述符上实际发生的事件。

参数 nfds #

参数 nfds 指定了数组 fds 中元素的个数,nfds_t 实际上为无符号整型。

参数 timeout #

timeoutselect() 中的参数 timeout 作用类似:

  • 如果 timeout 等于 -1,poll() 会一直阻塞直到 fds 数组中列出的文件描述符有一个到达就绪态。
  • 如果为 0 则不会阻塞。
  • 如果大于 0 则表示 poll() 最多阻塞的毫秒数。

返回值 #

poll() 会返回以下的几种:

  • 返回 -1 表示有错误发生。
  • 返回 0 表示该调用在任意一个文件描述符成为就绪态之前就超时了。
  • 返回正整数表示有 1 个或多个文件描述符处于就绪态了,即 fds 中拥有非零 revents 字段的 pollfd 结构体数量。

select 和 poll 的区别 #

系统调用 select() 所使用的数据类型 fd_set 对于被检查的文件描述符数量有一个上线限制 FD_SETSIZE ,在 Linux 下, 这个上限被默认设置为 1024 。而 poll() 的实现是动态数组,以链表形式组织,故对于被检查的文件描述符的数量本质上是没有限制的。

select 和 poll 存在的问题 #

系统调用 select()poll() 是用来同时检查多个文件描述符就绪状态的办法,它们是可移植的、长期存在且被广泛使用的。

但是,当检查大量的文件描述符时,这两个 API 都会遇到一些问题:

  • 每次调用 select()epoll() 时,内核都必须检查所有被指定的文件描述符,看它们是否处于就绪状态。当检查大量处于密集范围内的文件描述符时,该操作耗费的时间将大大超过接下来的操作。
  • 每次调用 select()poll 时,程序都必须传递一个标识所有需要被检查的文件描述符的数据结构到内核,内核检查过后,修改这个数据结构并返回程序。
    • 对于 select,这个结构需要在每次调用前进行初始化。
    • 对于 poll ,这个结构大小会随着待检查的 fd 数量的增加而增加。
  • select()poll() 调用完成后,程序必须检查返回的数据结构中的每个元素,以此查明哪个文件描述符处于就绪态。

当需要检查大量的文件描述符时,信号驱动 I/O 和 epoll 能提供更好的性能表现。

epoll 编程接口 #

epoll API 是 Linux 专有的特性,首次出现是在 Linux 2.6 版中。同 I/O 多路复用 API 一样,epoll API 允许进程同时检查多个文件描述符,看其中任意一个是否能执行 I/O 操作。其主要缺点是它是专属于 Linux 系统的 API 。

epoll 内部维护了两个数据结构,性能上也有了数量级的提升。

  • 兴趣列表(interest list):记录了在进程中声明过的感兴趣的文件描述符列表,使用了红黑树实现。
  • 就绪列表(ready list):维护了处于 I/O 就绪态的文件描述符列表,使用链表实现。

epoll_create() #

#include <sys/epoll.h>

int epoll_create(int size);

epoll_create() 新建了一个 epoll 实例,并返回其文件描述符 epfd,其中参数 size 指定了 epoll 实例来检查的文件描述符的个数。当不再需要这个 epoll 实例时,可以通过 close() 关闭。

epoll_ctl() #

系统调用 epoll_ctl() 能够修改由文件描述符 epfd 所代表的 epoll 实例中的兴趣列表,返回 0 代表成功,-1 代表失败。

#include <sys/epoll.h>

int epoll_ctl(int epdf, int op, int fd, struct epoll_event *ev);

struct epoll_event {
    uint32_t events;
    epoll_data_t data;
}

typedef union epoll_data {
    void *prt;
    int fd;
    uint_t u32;
    uint64_t u64;
} epoll_data_t;

参数 op 用来指定需要执行的操作,它可以是以下几种值:

  • EPOLL_CTL_ADDfd 添加到 epoll 实例 epfd 中去,对于 fd 感兴趣的事件,都指定在 ev 所指向的结构体中。
  • EPOLL_CTL_MOD 修改描述符 fd 上设定的事件。
  • EPOLL_CTL_DEL 将文件描述符 fdepfd 的兴趣列表中移除。

同时 epoll 实例监听事件 ev,当有事件发生时,会将 fd 添加到 epoll 实例的就绪列表中。

epoll_wait() #

系统调用 epoll_wait() 返回 epoll 实例中处于就绪态的文件描述符信息,单个 epoll_wait() 调用能返回多个就绪文件描述符的信息。

#include <sys/epoll.h>

int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);

参数 evlist 所指向的结构体数组中返回的是有关就绪态文件描述符的信息,数组 evlist 的空间由调用者负责申请,所包含的元素个数在参数 maxevents 中指定。

参数 timeout 用来确定 epoll_wait() 的阻塞行为,有以下几种:

  • 如果 timeout 等于 -1,调用将一直阻塞,直到兴趣列表中的文件描述符上有事件发,或者捕获到一个信号为止。
  • 如果 timeout 等于 0,执行一次非阻塞式的检查,看兴趣列表中的文件描述符上产生了哪个事件。
  • 如果 timeout 大于 0,调用将阻塞至多 timeout 毫秒,直到文件描述符上有时间发生。

调用成功后,epoll_wait() 返回数组 evlist 中的元素个数,如果在 timeout 超时间隔内没有任何文件描述符处于就绪态的话,返回 0,出错时返回 -1 。

epoll 与 I/O 多路复用的对比 #

每次调用 select()poll() 时,内核必须遍历检查所有在调用中指定的文件描述符,内核检查完成后再将所有标记为就绪态的文件描述符的数据传给进程。

上面的这个过程涉及了两次的拷贝,而 epoll 不需要拷贝,而是通过 epoll_ctl() 在内核空间中建立一个数据结构,该数据结构会将待监视的文件描述符都记录下来,稍后每次调用 epoll_wait() 时就不需要再传递任何与文件描述符有关的信息给内核了。

epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,只将有事件发生的文件描述符集合传递给应用程序,不需要像 selectpoll 那样轮询扫描整个集合,大大提高了检测的效率。

小结 #

实际上 I/O 多路复用、信号驱动 I/O、以及 epoll 都是用来实现同一个目标的技术:同时检查多个文件描述符,看它们是否准备好了执行 I/O 操作。需要注意的是,所有这些机制并不实际执行 I/O 操作,它们在操作 I/O 时属于同步,而直接执行了 I/O 操作的才算是异步 I/O。

在一些大量 I/O 并需要快速响应的场景中,同步的 I/O 多路复用和 epoll 都是一个更好的选择,虽然异步也是一个不错的选择,但是从绝对的响应速度来讲,异步 I/O 性能还是不如同步 I/O。

参考 #

Linux/UNIX系统编程手册 第 63 章

https://mp.weixin.qq.com/s/YdIdoZ_yusVWza1PU7lWaw

https://xiaolincoding.com/os/8_network_system/selete_poll_epoll.html

本文共 4238 字,上次修改于 Oct 24, 2024,以 CC 署名-非商业性使用-禁止演绎 4.0 国际 协议进行许可。

相关文章

» 操作系统的文件系统

» Go 语言的 MPG 并发调度模型

» Redis 实现布隆过滤器

» 深入了解 Redis 的各种数据结构

» TCP/IP 协议理论基础整理