为什么多路复用函数可以同时监听多个Socket?

"IO多路复用的原理"

Posted by Simon on October 23, 2020

“Better code, better life. ”

之前写了三篇博客讲Linux系统的网络IO,分别是:

Linux网络数据包接受过程

poll函数源码解析

epoll函数源码解析

从第一篇,我们基本知道了Linux系统下网络数据包是如何从网卡到达应用程序的,后面两篇讲了多路复用函数两兄弟的源码,今天我们来看一下IO多路复用的设计原理。

系统如何同时监听多个socket

我们的第一个问题是:操作系统是如何同时监听多个socket的?

回答这个问题前,我们首先要了解Linux系统对进程的管理。

进程的状态

Linux系统下,进程有五个状态:创建就绪运行阻塞结束

运行状态是进程获得CPU使用权,正在执行代码的状态;阻塞状态是进程在等待某种资源就绪,此时不占用CPU。

我们之前介绍过的poll_waitepoll_wait就是处于阻塞状态。

那么阻塞的原理是什么?这正是Linux进行进程调度的关键。

当一个进程:

  1. 需要做IO操作
  2. 等待某种资源就绪
  3. 当前分配的时间片执行完毕

时,会释放CPU,此时CPU会把当前进程的上下文保存成文件,放到调度器中,进程由此变成阻塞状态。当该进程等待的资源就绪时,会通过中断来通知CPU,操作系统讲其加入就绪队列,等待下次运行。知道进程结束。

这就是Linux系统下一个进程的生命周期。

多路复用其实比较类似,也是通过等待队列来实现

Socket的等待队列

当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程。

poll_waitepoll_wait就是把当前进程加入到所有关注的Socket的等待队列中,当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。

进程唤醒

所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面。

当进程A被唤醒后,它知道至少有一个socket接收了数据。程序只需遍历一遍socket列表,就可以得到就绪的socket。

这种简单方式行之有效,在几乎所有操作系统都有对应的实现。

但是简单的方法往往有缺点,主要是:

其一,每次调用select都需要将进程加入到所有监视socket的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个fds列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定select的最大监视数量,默认只能监视1024个socket。

其二,进程被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。

那么,有没有减少遍历的方法?有没有保存就绪socket的方法?这两个问题便是epoll技术要解决的。

epoll的改进

措施1:功能分离

select低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。如下图所示,每次调用select都需要这两步操作,然而大多数应用场景中,需要监视的socket相对固定,并不需要每次都修改。epoll将这两个操作分开,先用``epoll_ctl维护等待队列,再调用epoll_wait`阻塞进程。显而易见的,效率就能得到提升。

措施二:就绪列表

select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。如下图所示,计算机共有三个socket,收到数据的``sock2sock3rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist`的内容,就能够知道哪些socket收到数据。

措施三:索引结构

既然``epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。epoll`使用了红黑树作为索引结构。

PS:因为操作系统要兼顾多种功能,以及由更多需要保存的数据,``rdlist并非直接引用socket,而是通过epitem间接引用,红黑树的节点也是epitem`对象。同样,文件系统也并非直接引用着socket。