“Better code, better life. ”
之前写了三篇博客讲Linux系统的网络IO,分别是:
从第一篇,我们基本知道了Linux系统下网络数据包是如何从网卡到达应用程序的,后面两篇讲了多路复用函数两兄弟的源码,今天我们来看一下IO多路复用的设计原理。
系统如何同时监听多个socket
我们的第一个问题是:操作系统是如何同时监听多个socket的?
回答这个问题前,我们首先要了解Linux系统对进程的管理。
进程的状态
Linux系统下,进程有五个状态:创建,就绪,运行,阻塞,结束。
运行状态是进程获得CPU使用权,正在执行代码的状态;阻塞状态是进程在等待某种资源就绪,此时不占用CPU。
我们之前介绍过的poll_wait
和epoll_wait
就是处于阻塞状态。
那么阻塞的原理是什么?这正是Linux进行进程调度的关键。
当一个进程:
- 需要做IO操作
- 等待某种资源就绪
- 当前分配的时间片执行完毕
时,会释放CPU,此时CPU会把当前进程的上下文保存成文件,放到调度器中,进程由此变成阻塞状态。当该进程等待的资源就绪时,会通过中断来通知CPU,操作系统讲其加入就绪队列,等待下次运行。知道进程结束。
这就是Linux系统下一个进程的生命周期。
多路复用其实比较类似,也是通过等待队列来实现
Socket的等待队列
当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区、接收缓冲区、等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程。
poll_wait
和epoll_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,收到数据的``sock2和
sock3被
rdlist(就绪列表)所引用。当进程被唤醒后,只要获取
rdlist`的内容,就能够知道哪些socket收到数据。
措施三:索引结构
既然``epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索,以避免重复添加。红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是O(log(N)),效率较好。
epoll`使用了红黑树作为索引结构。
PS:因为操作系统要兼顾多种功能,以及由更多需要保存的数据,``rdlist并非直接引用socket,而是通过
epitem间接引用,红黑树的节点也是
epitem`对象。同样,文件系统也并非直接引用着socket。