引言
epoll 是 Linux 系统下高性能网络服务的必备技术,很多面试中高频出现的 Nginx、Redis 都使用了这一技术,本文总结 linux 多路复用模型的演变过程,看一看epoll 是如何实现高性能的。
一、相关基础知识
1.1 文件描述符
文件描述符:file descriptor,是Linux 内核为了高效管理已被打开的文件所创建的索引,形式上是一个非负整数,用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符。
最常见的文件描述符是 0 (标准输入)、1 (标准输出)、2(标准错误),它们被维护在进程的 fd 目录下:
1.2 多路复用
在数据通信系统或计算机网络系统中,为了有效的利用通信线路,希望一个信道同时传输多路信号,这就是多路复用技术。
采用多路复用技术能把多个信号组合起来在一条物理信道上进行传输,
二、多路复用模型演化过程
早期的 Socket 通信是 BIO,即阻塞 IO。
应用进程通过系统内核的 read()系统调用来访问 Socket fd,由于是阻塞的,应用程序的线程是阻塞的,必须收到 fd 返回的数据响应后,才能够继续向下执行。那么,对于一个网络程序,多个TCP请求过来时,应用进程必须分配对等数量的线程才能够完成处理,因此,这会造成极大的资源浪费,例如CPU的效率,线程变多,忙于线程调度,而且线程的内存占用也会增多。
为了解决一系列资源浪费和效率问题,内核改进了IO方式,变为了NIO。
NIO是非阻塞式IO通信,即便没有数据到达,系统调用也会立刻返回,告知应用进程没有数据。此时,应用程序就可以使用一个线程处理多个 socket fd,但这需要应用程序轮询访问 fd 集合,每次都需要调用 read 并反复切换用户态、内核态。这个时期的内核通过 NIO 的方式,用户程序使用一个线程来操作,是同步处理的,因此可以称为“同步非阻塞模型”。
随着内核的发展,1983年加入了新的系统调用——select,它也是“多路复用”技术实现的第一个版本。使用 man 2 select 命令查看相关信息:
当应用程序需要监视多个 fd 时,会把一个 fd 集合传给 select 接口(最多1024个fd),这需要将fd集合整个由用户态拷贝到内核态,这个开销在 fd 很多时会很大。
select 实际上是将用户轮询的操作移入了内核空间,内核依然需要循环每个 fd 进行判断是否有数据到达。而 poll 的原理和 select 一样,不过它支持的fd 数量要大于 1024。
于是,在2002年,epoll诞生,它是基于 select、poll 之上进行改进的线程安全的多路复用技术。
epoll内部使用了 mmap 共享了用户和内核的部分空间,避免数据的来回拷贝。
基于事件驱动,epoll_ctl 注册事件和callback回调函数,epoll_wait 只返回发生的事件(一般是读或写事件),避免像 select 和 poll 对事件的整体轮询。
三、epoll 原理的深入理解
在一个典型的计算机结构图中,网卡收到网线传来的网络数据,经过硬件电路的传输,最终将数据写入到内存中的某个地址上。
通过硬件传输,网卡接收的数据存放到内存中,操作系统就可以去读取它们。但是,忙碌的CPU并不会一直盯着网卡,这需要网卡在接收到数据之后,向CPU发送一个中断信号(80中断),告诉CPU有网络数据到达。
计算机执行程序时,会有优先级的需求,比如,当计算机收到断电信号时(电容可以保存少许电量,供cpu运行很短的一小段时间)它应立即去保存数据,保存数据的程序具有较高优先级。
一般而言,由硬件产生的信号需要CPU立马作出回应(不然数据可能会丢失),所以它的优先级很高。网卡向CPU发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。
理解 epoll还需要理解另一个非常重要的概念——进程阻塞。它的含义很简单,就是进程停止执行,等待某个唤醒信号使其恢复运行状态。但是阻塞的进程并不会占用CPU资源,这是为什么?
这就需要理解阻塞的本质——进程调度。
操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。
下图中计算机运行A、B、C三个进程,其中进程A执行着上述基础网络程序,一开始,这三个进程被操作系统的工作队列所引用,处于运行状态,会分时执行。
当进程A执行到创建socket语句时,操作系统会创建一个由文件系统管理的 socket 对象。这个socket 对象包含了发送缓冲区、接收缓冲区、等待队列等成员。而等待队列指向所有需要等待该socket事件的进程。
当程序执行到recv时,操作系统会将进程A从工作队列移入该 socket 的等待队列中,CPU就只会执行另外两个B、C进程,而不会执行A进程。
所以,进程A被阻塞,不会往下执行代码,也不会占用CPU资源。
当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回工作队列,该进程就会再次变成运行状态,继续执行代码。也由于socket的接收缓冲区已经有数据,recv可以返回接收到的数据。
那么内核接收网络数据的全过程如下图(其中,中断程序主要两项功能:先将网络数据写入对应socket的接收缓冲区,再唤醒进程A,即重新将A放入工作队列):
服务端需要管理多个客户端连接,而 recv 只能监视单个socket,这种矛盾下,人们开始寻找监视多个socket 的方法。epoll 的要义是高效的监视多个 socket 。
假如能够预先传入一个 socket 列表,如果列表中的 socket 都没有数据,挂起进程(阻塞),直到有一个socket 收到数据,唤醒进程。这种方法很直接,也是 select 的设计思想。操作系统把进程A分别加入多个socket的等待队列中,如下图:
epoll 相对于 select ,主要有以下几点改进措施:
措施一:功能分离
select 低效的原因之一是将“维护等待队列” 和 “阻塞进程” 两个步骤合二为一。每次调用 select 都需要这两步操作,然而,大多数场景中,需要监听的socket相对固定,并不需要每次都修改。epoll 将这两个操作分开,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程:
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中while(1){int n = epoll_wait(...)for(接收到数据的socket){//处理}
}
措施二:就绪队列
select 低效的另一个原因是程序不知道哪些 socket 收到数据,只能一个一个遍历。加入内核维护一个“就绪列表” ,引用收到数据的 socket,就能避免遍历:
epoll 系列总共有三个方法,贯穿数据接收和进程调度的始终:
1、epoll_create 会创建一个 eventpoll 对象,它也是文件系统中的一员,和socket 一样,它也会有等待队列。创建一个代表该 epoll 的 eventpoll 对象是必须的,因为内核需要维护“就绪列表” 等数据,可以作为 eventpoll 的成员。
2、epoll_ctl ,创建 epoll 对象之后,可以用 epoll_ctl 添加和删除所要监听的 socket。当socket 收到数据后,中断程序会操作 eventpoll 对象,而不是直接操作进程。
当socket 收到数据后,中断程序会给 eventpoll 的“就绪队列” 添加 socket 引用:
eventpoll对象相当于是 socket 和进程之间的中介,socket 接收数据并不直接影响进程,而是通过改变 eventpoll 的就绪队列来改变进程状态。
3、epoll_wait,当程序执行到 epoll_wait 时,如果 rdlist 已经引用了socket,那么 epoll_wait 直接返回,如果 rdlist 为空,内核会将进程A放入 eventpoll 的等待队列,阻塞进程。
当socket接收到数据,中断程序一方面修改 rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程A再次进入运行状态。也因为 rdlist 的存在,进程A可以知道哪些 socket 发生了变化。
四、epoll的实现思考
了解了 epoll的工作原理,我们再来思考一下 epoll的内部存储结构。
前面已经提到就绪列表,它引用着就绪的 socket ,所以应该具备快速插入的特性,因为程序可能随时调用 epoll_ctl 添加监听 socket,也可能随时删除。
所以,就绪列表应该是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll 使用它来实现就绪队列。
而维护监视的 socket ,采用的是红黑树,因为它的搜索、插入、删除时间复杂度都是O(logN),效率较好。
总结
参考
《如果这篇文章说不清epoll的本质,那就过来掐死我吧!》
《阿里面试题 | Nginx 所使用的 epoll 模型是什么?》