架构

一文让你彻底明白,理解I/O多路复用

在讲解该技术之前,我们需要预习一下文件以及文件描述符。

什么是文件


程序员使用I/O最终都逃不过文件这个概念。

在Linux世界中文件是一个很简单的概念,作为程序员我们只需要将其理解为一个N byte的序列就可以了:

b1, b2, b3, b4, ....... bN

实际上所有的I/O设备都被抽象为了文件这个概念,一切皆文件,Everything is File,磁盘、网络数据、终端,甚至进程间通信工具管道pipe等都被当做文件对待。

所有的I/O操作也都可以通过文件读写来实现,这一非常优雅的抽象可以让程序员使用一套接口就能对所有外设I/O操作

常用的I/O操作接口一般有以下几类:

  • 打开文件,open
  • 改变读写位置,seek
  • 文件读写,read、write
  • 关闭文件,close

程序员通过这几个接口几乎可以实现所有I/O操作,这就是文件这个概念的强大之处。

文件描述符


要想进行I/O读操作,像磁盘数据,我们需要指定一个buff用来装入数据,一般都是这样写的:

read(buff);

但是这里我们忽略了一个关键问题,那就是虽然我们指定了往哪里写数据,但是我们该从哪里读数据呢?从上一节中我们知道,通过文件这个概念我们能实现几乎所有I/O操作,因此这里少的一个主角就是文件

那么我们一般都怎样使用文件呢?

如果周末你去比较火的餐厅吃饭应该会有体会,一般周末人气高的餐厅都会排队,然后服务员会给你一个排队序号,通过这个序号服务员就能找到你,这里的好处就是服务员无需记住你是谁、你的名字是什么、来自哪里、喜好是什么、是不是保护环境爱护小动物等等,这里的关键点就是服务员对你一无所知,但依然可以通过一个号码就能找到你

同样的,在Linux世界要想使用文件,我们也需要借助一个号码,这个号码就被称为了文件描述符,file descriptors,在Linux世界中鼎鼎大名,其道理和上面那个排队号码一样。

因此,文件描述仅仅就是一个数字而已,但是通过这个数字我们可以操作一个打开的文件,这一点要记住。

有了文件描述符,进程可以对文件一无所知,比如文件在磁盘的什么位置、加载到内存中又是怎样管理的等等,这些信息统统交由操作系统打理,进程无需关心,操作系统只需要给进程一个文件描述符就足够了。

因此我们来完善上述程序:

int fd = open(file_name); // 获取文件描述符
read(fd, buff);

怎么样,是不是非常简单。

文件描述符太多了怎么办


经过了这么多的铺垫,终于要到高性能、高并发这一主题了。

从前几节我们知道,所有I/O操作都可以通过文件样的概念来进行,这当然包括网络通信。

如果你有一个web服务器,当三次握手成功以后,我们会调用accept来获取一个链接,调用该函数我们同样会得到一个文件描述符,通过这个文件描述符就可以处理客户端发送的请求并且把处理结果发送回去。也就是说通过这个描述符我们就可以和客户端进行通信了。

// 通过accept获取客户端的文件描述符
int conn_fd = accept(...);

server的处理逻辑通常是读取客户端请求数据,然后执行某些特定逻辑:

if(read(conn_fd, request_buff) > 0) {
    do_something(request_buff);
}

是不是非常简单,然而世界终归是复杂的,当然也不是这么简单的。

接下来就是比较复杂的了。

既然我们的主题是高并发,那么server就不可能只和一个客户端通信,而是可能会同时和成千上万个客户端进行通信。这时你需要处理不再是一个描述符这么简单,而是有可能要处理成千上万个描述符

为了不让问题一上来就过于复杂,我们先简单化,假设只同时处理两个客户端的请求。

有的同学可能会说,这还不简单,这样写不就行了:

if(read(socket_fd1, buff) > 0) { // 处理第一个
    do_something();
}
if(read(socket_fd2, buff) > 0) { // 处理第二个
    do_something();

这是非常典型的阻塞式I/O,如果此时没有数据可读那么进程会被阻塞而暂停运行,这时我们就无法处理第二个请求了,即使第二个请求的数据已经就位,这也就意味着处理某一个客户端时由于进程被阻塞导致剩下的所有其它客户端必须等待,在同时处理几万客户端的server上,这显然是不能容忍的。

聪明的你一定会想到使用多线程,为每个客户端请求开启一个线程,这样一个客户端被阻塞就不会影响到处理其它客户端的线程了,注意,既然是高并发,那么我们要为成千上万个请求开启成千上万个线程吗,大量创建销毁线程会严重影响系统性能。

那么这个问题该怎么解决呢?

这里的关键点在于,我们事先并不知道一个文件描述对应的I/O设备是否是可读的、是否是可写的,在外设的不可读或不可写的状态下进行I/O只会导致进程阻塞被暂停运行。

因此要优雅的解决这个问题,就要从其它角度来思考这个问题了

不要打电话给我,有需要我会打给你

大家生活中肯定会接到过推销电话,而且不止一个,一天下来接上十个八个推销电话你的身体会被掏空的。

这个场景的关键点在于打电话的人并不知道你是不是要买东西,只能来一遍遍问你,因此一种更好的策略是不要让他们打电话给你,记下他们的电话,有需要的话打给他们,这样推销员就不会一遍一遍的来烦你了(虽然现实生活中这并不可能)。

在这个例子中,你,就好比内核,推销者就好比应用程序,电话号码就好比文件描述符,和你用电话沟通就好比I/O。

现在你应该明白了吧,处理多个文件描述符的更好方法其实就存在于推销电话中。

因此相比上一节中我们通过I/O接口主动问内核这些文件描述符对应的外设是不是已经就绪了,一种更好的方法是,我们把这些感兴趣的文件描述符一股脑扔给内核,并霸气的告诉内核:“我这里有1万个文件描述符,你替我监视着它们,有可以读写的文件描述符时你就告诉我,我好处理”。而不是弱弱的问内核:“第一个文件描述可以读写了吗?第二个文件描述符可以读写吗?第三个文件描述符可以读写了吗?。。。”

这样应用程序就从“繁忙”的主动变为了清闲的被动反正文件描述可读可写了内核会通知我,能偷懒我才不要那么勤奋。

这是一种更加高效的I/O处理机制,现在我们可以一次处理多路I/O了,为这种机制起一个名字吧,就叫I/O多路复用吧,这就是 I/O multiplexing。

I/O多路复用,I/O multiplexing


multiplexing一词其实多用于通信领域,为了充分利用通信线路,希望在一个信道中传输多路信号,要想在一个信道中传输多路信号就需要把这多路信号结合为一路,将多路信号组合成一个信号的设备被称为multiplexer,显然接收方接收到这一路组合后的信号后要恢复原先的多路信号,这个设备被称为demultiplexer,如图所示:

回到我们的主题。

所谓I/O多路复用指的是这样一个过程:

1. 我们拿到了一堆文件描述符(不管是网络相关的、还是磁盘文件相关等等,任何文件描述符都可以)

2. 通过调用某个函数告诉内核:“这个函数你先不要返回,你替我监视着这些描述符,当这堆文件描述符中有可以进行I/O读写操作的时候你再返回

3. 当调用的这个函数返回后我们就能知道哪些文件描述符可以进行I/O操作了。

也就是说通过I/O多路复用我们可以同时处理多路I/O。那么有哪些函数可以用来进行I/O多路复用呢?

在Linux世界中有这样三种机制可以用来进行I/O多路复用:

  • select
  • poll
  • epoll

接下来我们就来介绍一下牛掰的I/O多路复用三剑客。

I/O多路复用三剑客


本质上select、poll、epoll都是阻塞式I/O,也就是我们常说的同步I/O,原因在于调用这些I/O多路复用函数时如果任何一个需要监视的文件描述符都不可读或者可写那么进程会被阻塞暂停执行,直到有文件描述符可读或者可写才继续运行。

1 select:初出茅庐

在select这种I/O多路复用机制下,我们需要把想监控的文件描述集合通过函数参数的形式告诉select,然后select会将这些文件描述符集合拷贝到内核中,我们知道数据拷贝是有性能损耗的,因此为了减少这种数据拷贝带来的性能损耗,Linux内核对集合的大小做了限制,并规定用户监控的文件描述集合不能超过1024个,同时当select返回后我们仅仅能知道有些文件描述符可以读写了,但是我们不知道是哪一个,因此程序员必须再遍历一边找到具体是哪个文件描述符可以读写了。

因此,总结下来select有这样几个特点:

  • 我能照看的文件描述符数量有限,不能超过1024个
  • 用户给我的文件描述符需要拷贝的内核中
  • 我只能告诉你有文件描述符满足要求了,但是我不知道是哪个,你自己一个一个去找吧(遍历)

因此我们可以看到,select机制的这些特性在高并发网络服务器动辄几万几十万并发链接的场景下无疑是低效的。

2 poll:小有所成

poll和select是非常相似的,poll相对于select的优化仅仅在于解决了文件描述符不能超过1024个的限制,select和poll都会随着监控的文件描述数量增加而性能下降,因此不适合高并发场景。

3 epoll:独步天下

在select面临的三个问题中,文件描述数量限制已经在poll中解决了,剩下的两个问题呢?

针对拷贝问题,epoll使用的策略是各个击破共享内存

实际上文件描述符集合的变化频率比较低,select和poll频繁的拷贝整个集合,内核都快被烦死了,epoll通过引入epoll_ctl很体贴的做到了只操作那些有变化的文件描述符,同时epoll和内核还成为了好朋友,共享了同一块内存,这块内存中保存的就是那些已经可读或者可写的的文件描述符集合,这样就减少了内核和程序的拷贝开销。

针对需要遍历文件描述符才能知道哪个可读可写这一问题,epoll使用的策略是“当小弟”。

在select和poll机制下,进程要亲自下场去各个文件描述符上等待,任何一个文件描述可读或者可写就唤醒进程,但是进程被唤醒后也是一脸懵逼并不知道到底是哪个文件描述符可读或可写,还要再从头到尾检查一遍。

但epoll就懂事多了,主动找到进程要当小弟替大哥出头。

在这种机制下,进程不需要亲自下场了,进程只要等待在epoll上,epoll代替进程去各个文件描述符上等待,当哪个文件描述符可读或者可写的时候就告诉epoll,epoll用小本本认真记录下来然后唤醒大哥:“进程大哥,快醒醒,你要处理的文件描述符我都记下来了”,这样进程被唤醒后就无需自己从头到尾检查一遍,因为epoll小弟都已经记下来了。

因此我们可以看到,在epoll这种机制下,实际上利用的就是“不要打电话给我,有需要我会打给你”这种策略,进程不需要一遍一遍麻烦的问各个文件描述符,而是翻身做主人了,“你们这些文件描述符有哪个可读或者可写了主动报上来”,这种机制实际上就是大名鼎鼎的事件驱动,Event-driven,这也是我们下一篇的主题。

实际上在Linux平台,epoll基本上就是高并发的代名词

从一次网络调用说起


假设应用A想要请求应用B获取数据,那么他会经历以下步骤:

  1. 应用A将请求报文写入自己的TCP写缓冲区
  2. 应用A的请求报文经过网线到达应用B的TCP读缓冲区
  3. 应用B获得请求报文后,进行业务逻辑处理
  4. 应用B业务逻辑处理完成后,将响应报文写入自己的TCP写缓冲区,然后经过网线达到应用A的TCP读缓冲区

现在我们将注意力放到应用A上,应用A将请求发送出去后,就会开始调用系统调用从TCP读缓冲区读取数据,由于无法知道应用B什么时候会把响应数据返回,那么就会两种情况:

  1. 应用B响应速度很快,在应用A读取TCP缓冲区时,就已经把响应数据返回了。那么应用A就可以顺利获取到数据,皆大欢喜。
  2. 应用B响应比较慢,在应用A读取TCP缓冲区时,还没有将响应数据返回了。这个时候操作系统就面临两种选择: 选择一:把应用A的本次调用阻塞在这,等到应用B的数据达到了缓冲区再重新唤醒应用A。       选择二:告诉应用A你要的数据还没来,你等会再来问。

阻塞IO


对于选择一,就是我们常说的阻塞式IO。
学术点的说法:当用户进程发起read调用时,如果内核的数据没有准备好,那么操作系统就会把该进程挂起来,进入等待状态(不消耗CPU)。直到数据准备好了或者发生了错误,该进程才会被唤醒。
整体流程如图:

非阻塞IO


对于选择二,就是我们常说的非阻塞式IO。
学术点的说法:当用户进程发起read调用时,如果内核的数据没有准备好,那么操作系统会返回一个EAGAIN error,用户进程可以根据该error判断出是数据未准备好,可以等会再来问。如果在轮询期间,内核准备好数据了,用户进程就可以把数据拷贝到用户态空间了。
整体流程如图:

IO多路复用


阻塞IO和非阻塞IO都是早期最常见的网络编程模型,但是他们有着致命的缺点。考虑如下场景:

  1. 每个用户请求都需要使用一个单独的线程进行服务,同时还要请求应用B才能完成业务逻辑。
  2. 由于不知道应用B的响应数据何时会返回,那么只能选择阻塞IO或者非阻塞IO进行轮询。

然而阻塞IO会导致线程被挂起,非阻塞IO会导致线程一直处于轮询状态。这两种情况都会导致线程无法被释放或者复用。随着用户请求数的增多,应用A不得不创建更多的线程。 然而对于操作系统来说,可以创建的线程是有上限的,并且过多的线程会导致线程切换的时间变多,严重时可能导致系统卡死,无法对外提供服务。这也是著名的C10K问题。

为了解决这个问题,于是人们就提出了方案:由一个或者几个线程去监控多个网络请求,由他们去完成数据准备阶段的操作。当有数据准备就绪之后再分配对应的线程去读取数据,这样就可以使用少量的线程维护大量的网络请求,这就是IO多路复用。

 IO 多路复用的复用指的是复用线程,而不是IO连接;目的是让少量线程能够处理多个IO连接。

IO多路复用又主要由以下函数分别实现,分别是select、poll、epoll。

select


select是Linux最早支持IO多路复用的函数。

select原理

通过select函数可以完成多个IO事件的监听。

#define __FD_SETSIZE 1024

typedef struct {
unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;

struct timeval {
time_t      tv_sec;         /* seconds */
suseconds_t tv_usec;        /* microseconds */
};

//函数声明
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

函数参数:

  • readfds:内核检测该集合中的IO是否可读。如果想让内核帮忙检测某个IO是否可读,需要手动把文件描述符加入该集合。
  • writefds:内核检测该集合中的IO是否可写。同readfds,需要手动加入
  • exceptfds:内核检测该集合中的IO是否异常。同readfds,需要手动加入
  • nfds:以上三个集合中最大的文件描述符数值 + 1,例如集合是{0,1,4},那么 maxfd 就是 5
  • timeout:用户线程调用select的超时时长。
    • 设置成NULL,表示如果没有 I/O 事件发生,则 select 一直等待下去。
    • 设置为非0的值,这个表示等待固定的一段时间后从 select 阻塞调用中返回。
    • 设置成 0,表示根本不等待,检测完毕立即返回。

函数返回值:

  • 大于0:成功,返回集合中已就绪的IO总个数
  • 等于-1:调用失败
  • 等于0:没有就绪的IO

从上述的select函数声明可以看出,fd_set本质是一个数组,为了方便我们操作该数组,操作系统提供了以下函数:

// 将文件描述符fd从set集合中删除 
void FD_CLR(int fd, fd_set *set); 

// 判断文件描述符fd是否在set集合中 
int  FD_ISSET(int fd, fd_set *set); 

// 将文件描述符fd添加到set集合中 
void FD_SET(int fd, fd_set *set); 

// 将set集合中, 所有文件描述符对应的标志位设置为0
void FD_ZERO(fd_set *set); 

select缺点

  1. fd_set长度限制:由于fd_set本质是一个数组,同时操作系统限制了其长度,导致其只能接受文件描述符数值在1024以内的。
  2. select函数的返回值是int,导致每次返回后,用户得手动检测集合中哪些值被改为1了(被改为1的表示产生了IO就绪事件)
  3. 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,当fd很多时,开销很大。
  4. 由于fd_set本质是数组,所以每次内核都是线性扫描整个 fd_set,判断是否有IO就绪事件,导致随着监控的描述符 fd 数量增长,其性能会线性下降

poll


poll是在select之后出现的另一种I/O 多路复用技术。和 select 相比,它使用了不同的方式存储文件描述符,也解决文件描述符的个数限制。

poll原理

struct pollfd {
    int fd; /* file descriptor */
    short events; /* events to look for */
    short revents; /* events returned */
};

int poll(struct pollfd *fds, unsigned long nfds, int timeout);    

函数参数:

  • fds:struct pollfd类型的数组, 存储了待检测的文件描述符,struct pollfd有三个成员:
    • fd:委托内核检测的文件描述符
    • events:委托内核检测的fd事件(输入、输出、错误),每一个事件有多个取值
    • revents:这是一个传出参数,数据由内核写入,存储内核检测之后的结果
  • nfds:描述的是数组 fds 的大小
  • timeout: 指定poll函数的阻塞时长
    • -1:一直阻塞,直到检测的集合中有就绪的IO事件,然后解除阻塞函数返回
    • 0:不阻塞,不管检测集合中有没有已就绪的IO事件,函数马上返回
    • 大于0:表示 poll 调用方等待指定的毫秒数后返回

函数返回值:

  • -1:失败
  • 大于0:表示检测的集合中已就绪的文件描述符的总个数

在 select 里面,文件描述符的个数已经随着 fd_set 的实现而固定,没有办法对此进行配置;而在 poll 函数里,我们可以自由控制 pollfd 结构的数组大小,从而突破select中面临的文件描述符个数的限制。

poll缺点

poll 的实现和 select 非常相似,只是poll 使用 pollfd 结构,而 select 使用fd_set 结构,poll 解决了文件描述符数量限制的问题,但是同样需要从用户态拷贝所有的 fd 到内核态,也需要线性遍历所有的 fd 集合,所以它和 select 并没有本质上的区别。

epoll


epoll 是 Linux kernel 2.6 之后引入的新 I/O 事件驱动技术,它解决了select、poll在性能上的缺点,是目前IO多路复用的主流解决方案。 《The Linux Programming Interface》中用了一张图直观的展示了 select、poll、epoll在不同数量的文件描述符下的性能。

从上图可以明显地看到,随着文件描述符数量的上涨,epoll 的性能还是很优异。而select、poll则随着描述符数量的上涨性能逐渐变差。

epoll 原理

epoll 的 API 非常简洁,由3个系统函数组成:

int epoll_create(int size);  
int epoll_create1(int flags);

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

struct epoll_event {
    __uint32_t events;
    epoll_data_t data;
};

union epoll_data {
 void     *ptr;
 int       fd;
 uint32_t  u32;
 uint64_t  u64;
};
typedef union epoll_data  epoll_data_t;
  1. epoll_creare、epoll_create1:这两个函数的作用是一样的,都是创建一个epoll实例。
  2. epoll_ctl:在创建完 epoll 实例之后,可以通过调用 epoll_ctl 往 epoll 实例增加或删除需要监控的IO事件。
    • epfd:调用 epoll_create 创建的 epoll 获得的返回值,可以简单理解成是 epoll 实例的唯一标识。
    • op:表示是增加还是删除一个监控事件,它有三个选项可供选择:
      • EPOLL_CTL_ADD: 向 epoll 实例注册文件描述符对应的事件;
      • EPOLL_CTL_DEL:向 epoll 实例删除文件描述符对应的事件;
      • EPOLL_CTL_MOD: 修改文件描述符对应的事件。
    • fd:需要注册的事件的文件描述符。
    • epoll_event:表示需要注册的事件类型,并且可以在这个结构体里设置用户需要的数据。
      • events:表示需要注册的事件类型,可选值在下文的 Linux 常见网络IO事件定义中列出
      • data:可以存放用户自定义的数据。
  3. epoll_wait:调用者进程调用该函数等待I/O 事件就绪。
    • epfd: epoll 实例的唯一标识。
    • epoll_event:同2.4
    • maxevents:一个大于 0 的整数,表示 epoll_wait 可以返回的最大事件值。
    • timeout:
      • -1:一直阻塞,直到检测的集合中有就绪的IO事件,然后解除阻塞函数返回
      • 0:不阻塞,不管检测集合中有没有已就绪的IO事件,函数马上返回
      • 大于0:表示 epoll 调用方等待指定的毫秒数后返回

epoll 工作流程

epoll 主要的结构对象为eventpoll,其主要包含几个重要属性成员:

struct eventpoll {
   /* Wait queue used by sys_epoll_wait() */
 wait_queue_head_t wq;

 /* List of ready file descriptors */
 struct list_head rdllist;

    /* RB tree root used to store monitored fd structs */
 struct rb_root_cached rbr;
}
  • wq:当用户进程执行了epoll_wait导致了阻塞后,用户进程就会存储到这,等待后续数据准备完成后唤醒。
  • rdllist:当某个文件描述符就绪了后,就会从rbr移动到这。其本质是一个链表。
  • rbr:用户调用epoll_ctl增加的文件描述都存储在这,其本质是一颗红黑树。

其工作流程主要如图所示:

epoll 相比select、poll为什么高效?

  1. epoll 采用红黑树管理文件描述符 从上图可以看出,epoll使用红黑树管理文件描述符,红黑树插入和删除的都是时间复杂度 O(logN),不会随着文件描述符数量增加而改变。 select、poll采用数组或者链表的形式管理文件描述符,那么在遍历文件描述符时,时间复杂度会随着文件描述的增加而增加。
  2. epoll 将文件描述符添加和检测分离,减少了文件描述符拷贝的消耗 select&poll 调用时会将全部监听的 fd 从用户态空间拷贝至内核态空间并线性扫描一遍找出就绪的 fd 再返回到用户态。下次需要监听时,又需要把之前已经传递过的文件描述符再读传递进去,增加了拷贝文件的无效消耗,当文件描述很多时,性能瓶颈更加明显。 而epoll只需要使用epoll_ctl添加一次,后续的检查使用epoll_wait,减少了文件拷贝的消耗。

总结


I/O :网络 I/O

多路 :多个网络连接

复用:复用同一个线程。

IO多路复用其实就是一种同步IO模型,它实现了一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;而没有文件句柄就绪时,就会阻塞应用程序,交出cpu

是利用单个线程来同时监听多个Socket,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。