写点什么

tcp/ip 协议栈——epoll 的内部实现原理

发布于: 2020 年 11 月 28 日

实现原理

  • 目录

  • 一、网卡接收数据

  • 二、数据的接收

  • 三、进程阻塞为什么不占用 cpu 资源?

  • 工作队列等待队列唤醒进程

  • 四、内核接收网络数据全过程

  • 五、同时监视多个 socket 的简单方法

  • select 的流程 select 的缺点

  • 六、epoll 的设计思路

  • 措施一:功能分离措施二: 就绪列表

  • 七、epoll 的原理和流程

  • 创建 epoll 对象维护监视列表接收数据阻塞和唤醒进程流程图如下

  • 八、epoll 的实现细节

  • 就绪列表的数据结构索引结构

  • 九、select、poll 和 epoll 的区别

  • 十、结论



前言,文章有点长建议收藏观看

epoll 作为 linux 下高性能网络服务器的必备技术至关重要,nginx、redis、skynet 和大部分游戏服务器都使用到这一多路复用技术。因为 epoll 的重要性,不少游戏公司(如某某九九)在招聘服务端同学时,可能会问及 epoll 相关的问题。比如 epoll 和 select 的区别是什么?epoll 高效率的原因是什么?如果只靠背诵,显然不能算上深刻的理解。本文会从网卡接收数据的流程讲起,串联起 CPU 中断、操作系统进程调度等知识;再一步步分析阻塞接收数据、select 到 epoll 的进化过程;最后探究 epoll 的实现细节


一、网卡接收数据

计算机由 CPU、存储器(内存)、网络接口等部件组成。了解 epoll 本质的第一步,要从硬件的角度看计算机怎样接收网络数据。

示例:pandas 是基于 NumPy 的一种工具,该工具是为了解决数据分析任务而创建的。


下图展示了网卡接收数据的过程。在①阶段,网卡收到网线传来的数据;经过②阶段的硬件电路的传输;最终将数据写入到内存中的某个地址上(③阶段)。这个过程涉及到 DMA 传输、IO 通路选择等硬件有关的知识,但我们只需知道:网卡会把接收到的数据写入内存。


通过硬件传输,网卡接收的数据存放到内存中。操作系统就可以去读取它们

二、数据的接收

了解 epoll 本质的第二步,要从 CPU 的角度来看数据接收。要理解这个问题,要先了解一个概念——中断。

计算机执行程序时,会有优先级的需求。比如,当计算机收到断电信号时(电容可以保存少许电量,供 CPU 运行很短的一小段时间),它应立即去保存数据,保存数据的程序具有较高的优先级。




现在可以回答本节提出的问题了:当网卡把数据写入到内存后,网卡向 cpu 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。


Linuxc/c++服务器开发高阶视频学习资料+群 720209036 获取


内容包括 C/C++,Linux,Nginx,ZeroMQ,MySQL,Redis,MongoDB,ZK,流媒体,P2P,K8S,Docker,TCP/IP,协程,DPDK 多个高级知识点。

关注 VX 公众号:Linux C 后台服务器开发



三、进程阻塞为什么不占用 cpu 资源?

了解 epoll 本质的第三步,要从操作系统进程调度的角度来看数据接收。阻塞是进程调度的关键一环,指的是进程在等待某事件(如接收到网络数据)发生之前的等待状态,recv、select 和 epoll 都是阻塞方法。了解“进程阻塞为什么不占用 cpu 资源?”,也就能够了解这一步。为简单起见,我们从普通的 recv 接收开始分析,先看看下面代码:

//创建socketint s = socket(AF_INET, SOCK_STREAM, 0);   //绑定bind(s, ...)//监听listen(s, ...)//接受客户端连接int c = accept(s, ...)//接收客户端数据recv(c, ...);//将数据打印出来printf(...)
复制代码

这是一段最基础的网络编程代码,先新建 socket 对象,依次调用 bind、listen、accept,最后调用 recv 接收数据。recv 是个阻塞方法,当程序运行到 recv 时,它会一直等待,直到接收到数据才往下执行。

那么阻塞的原理是什么?

工作队列

操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得 cpu 使用权,正在执行代码的状态;等待状态是阻塞状态,比如上述程序运行到 recv 时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。

下图中的计算机中运行着 A、B、C 三个进程,其中进程 A 执行着上述基础网络程序,一开始,这 3 个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行。


工作队列中有 A、B 和 C 三个进程

等待队列

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


当程序执行到 recv 时,操作系统会将进程 A 从工作队列移动到该 socket 的等待队列中(如下图)。由于工作队列只剩下了进程 B 和 C,依据进程调度,cpu 会轮流执行这两个进程的程序,不会执行进程 A 的程序。所以进程 A 被阻塞,不会往下执行代码,也不会占用 cpu 资源。



操作系统添加等待队列只是添加了对这个“等待中”进程的引用,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下。

唤醒进程

当 socket 接收到数据后,操作系统将该 socket 等待队列上的进程重新放回到工作队列,该进程变成运行状态(当 socket 事件触发是,也就是有数据到来,会取下一个进程结构调用其回调,将其挂到工作队列中),继续执行代码。同时也由于 socket 的接收缓冲区已经有了数据,recv 可以返回接收到的数据。


四、内核接收网络数据全过程

这一步,贯穿网卡、中断、进程调度的知识,叙述阻塞 recv 下,内核接收数据全过程。进程在 recv 阻塞期间,计算机收到了对端传送的数据(步骤①)。数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知 cpu 有数据到达,cpu 执行中断程序(步骤③)。此处的中断程序主要有两项功能,先将网络数据写入到对应 socket 的接收缓冲区里面(步骤④),再唤醒进程 A(步骤⑤),重新将进程 A 放入工作队列中。


唤醒进程的过程如下图所示。


以上是内核接收数据全过程这里留有两个思考题,大家先想一想。其一,操作系统如何知道网络数据对应于哪个 socket?其二,如何同时监视多个 socket 的数据?第一个问题:因为一个 socket 对应着一个端口号,而网络数据包中包含了 ip 和端口的信息,内核可以通过端口号找到对应的 socket。当然,为了提高处理速度,操作系统会维护端口号到 socket 的索引结构,以快速读取。第二个问题是多路复用的重中之重


五、同时监视多个 socket 的简单方法

服务端需要管理多个客户端连接,而 recv 只能监视单个 socket,这种矛盾下,人们开始寻找监视多个 socket 的方法。epoll 的要义是高效的监视多个 socket。从历史发展角度看,必然先出现一种不太高效的方法,人们再加以改进。只有先理解了不太高效的方法,才能够理解 epoll 的本质。

假如能够预先传入一个 socket 列表,如果列表中的 socket 都没有数据,挂起进程,直到有一个 socket 收到数据,唤醒进程。这种方法很直接,也是 select 的设计思想。

为方便理解,我们先复习 select 的用法。在如下的代码中,先准备一个数组(下面代码中的 fds),让 fds 存放着所有需要监视的 socket。然后调用 select,如果 fds 中的所有 socket 都没有数据,select 会阻塞,直到有一个(也可以是多个)socket 接收到数据,select 返回,唤醒进程。用户可以遍历 fds,通过 FD_ISSET 判断具体哪个 socket 收到数据,然后做出处理。

int s = socket(AF_INET, SOCK_STREAM, 0);  bind(s, ...)listen(s, ...) int fds[] =  存放需要监听的socket while(1){    int n = select(..., fds, ...)    for(int i=0; i < fds.count; i++){        if(FD_ISSET(fds[i], ...)){            //fds[i]的数据处理        }    }}
复制代码

select 的流程

select 的实现思路很直接。假如程序同时监视如下图的 sock1、sock2 和 sock3 三个 socket,那么在调用 select 之后,操作系统把进程 A(包括了 select 逻辑)分别加入这三个 socket 的等待队列中(前文说过,放的其实是进程 A 的引用)。


当任何一个 socket 收到数据后,中断程序将唤起进程。下图展示了 sock2 接收到了数据的处理流程。




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

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

工作流程如下图:


select 的缺点

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

其一,每次调用 select 都需要将进程加入到所有监视 socket 的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历(遍历进程 A 关心的所有 socket,需要注意的是添加从等待队列头部添加,删除通过回调直接实现,所以每个 socket 的等待队列不用遍历),而且每次都要将整个 fds 列表传递给内核,有一定的开销。正是因为遍历操作开销大,出于效率的考量,才会规定 select 的最大监视数量,默认只能监视 1024 个 socket。

其二,进程被唤醒后,程序并不知道哪些 socket 收到数据,还需要遍历一次(这一次遍历是在应用层)。

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

当程序调用 select 时,内核会先遍历一遍 socket,如果有一个以上的 socket 接收缓冲区有数据,那么 select 直接返回,不会阻塞。这也是为什么 select 的返回值有可能大于 1 的原因之一。如果没有 socket 有数据,进程才会阻塞


六、epoll 的设计思路

epoll 是在 select 出现 N 多年后才被发明的,是 select 和 poll 的增强版本。epoll 通过以下一些措施来改进效率。

措施一:功能分离

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


相比 select,epoll 拆分了功能

为方便理解后续的内容,我们先复习下 epoll 的用法。如下的代码中,先用 epoll_create 创建一个 epoll 对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到 epfd 中,最后调用 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){        //处理    }}
复制代码

功能分离,使得 epoll 有了优化的可能。

措施二: 就绪列表



什么时候 select 优于 epoll?一般认为如果在并发量低,socket 都比较活跃的情况下,select 效率更高,也就是说活跃 socket 数目与监控的总的 socket 数目之比越大,select 效率越高,因为 select 反正都会遍历所有的 socket,如果比例大,就没有白白遍历。加之于 select 本身实现比较简单,导致总体现象比 epoll 好)


七、epoll 的原理和流程

创建 epoll 对象



内核创建 eventpoll 对象

创建一个代表该 epoll 的 eventpoll 对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为 eventpoll 的成员。

维护监视列表

创建 epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 socket。以添加 socket 为例,如下图,如果通过 epoll_ctl 添加 sock1、sock2 和 sock3 的监视,内核会将 eventpoll 添加到这三个 socket 的等待队列中


当 socket 收到数据后,中断程序会操作 eventpoll 对象,而不是直接操作进程(也就是调用 epoll 的进程)。

接收数据

当 socket 收到数据后,中断程序会给 eventpoll 的“就绪列表”添加 socket 引用。如下图展示的是 sock2 和 sock3 收到数据后,中断程序让 rdlist 引用这两个 socket。


eventpoll 对象相当于是 socket 和进程之间的中介,socket 的数据接收并不直接影响进程,而是通过改变 eventpoll 的就绪列表来改变进程状态。

当程序执行到 epoll_wait 时,如果 rdlist 已经引用了 socket,那么 epoll_wait 直接返回,如果 rdlist 为空,阻塞进程。

阻塞和唤醒进程

假设计算机中正在运行进程 A 和进程 B,在某时刻进程 A 运行到了 epoll_wait 语句。如下图所示,内核会将进程 A 放入 eventpoll 的等待队列中,阻塞进程。


当 socket 接收到数据,中断程序一方面修改 rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态(如下图)。也因为 rdlist 的存在,进程 A 可以知道哪些 socket 发生了变化。


epoll 唤醒进程

流程图如下


八、epoll 的实现细节

至此,相信读者对 epoll 的本质已经有一定的了解。但我们还留有一个问题,eventpoll 的数据结构是什么样子?

再留两个问题,就绪队列应该应使用什么数据结构?eventpoll 应使用什么数据结构来管理通过 epoll_ctl 添加或删除的 socket?如下图所示,eventpoll 包含了 lock、mtx、wq(等待队列)、rdlist 等成员。rdlist 和 rbr 是我们所关心的


就绪列表的数据结构

就绪列表引用着就绪的 socket,所以它应能够快速的插入数据。

程序可能随时调用 epoll_ctl 添加监视 socket,也可能随时删除。当删除时,若该 socket 已经存放在就绪列表中,它也应该被移除。(事实上,每个 epoll_item 既是红黑树节点,也是链表节点,删除红黑树节点,自然删除了链表节点)

所以就绪列表应是一种能够快速插入和删除的数据结构。双向链表就是这样一种数据结构,epoll 使用双向链表来实现就绪队列(对应上图的 rdllist)。

索引结构

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

ps:因为操作系统要兼顾多种功能,以及由更多需要保存的数据,rdlist 并非直接引用 socket,而是通过 epitem 间接引用,红黑树的节点也是 epitem 对象。同样,文件系统也并非直接引用着 socket。为方便理解,本文中省略了一些间接结构。

九、select、poll 和 epoll 的区别

I/O 多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。select,poll,epoll 都是 IO 多路复用的机制。但 select,poll,epoll 本质上都是同步 I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步 I/O 则无需自己负责进行读写,异步 I/O 的实现会负责把数据从内核拷贝到用户空间。下来,分别谈谈。

select——>原理概述:select 的核心功能是调用 tcp 文件系统的 poll 函数,不停的查询,如果没有想要的数据,主动执行一次调度(防止一直占用 cpu),直到有一个连接有想要的消息为止。从这里可以看出 select 的执行方式基本就是不同的调用 poll,直到有需要的消息为止。缺点:

1、每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;

2、同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大;

3、select 支持的文件描述符数量太小了,默认是 1024。

优点:

1、select 的可移植性更好,在某些 Unix 系统上不支持 poll()。

2、select 对于超时值提供了更好的精度:微秒,而 poll 是毫秒。

poll——>原理概述:

poll 本质上和 select 没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个 fd 对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有 fd 后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历 fd。这个过程经历了多次无谓的遍历。poll 还有一个特点是“水平触发”,如果报告了 fd 后,没有被处理,那么下次 poll 时会再次报告该 fd。

缺点:

1、大量的 fd 的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义;

2、与 select 一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符。

优点:

1、poll() 不要求开发者计算最大文件描述符加一的大小。

2、poll() 在应付大数目的文件描述符的时候速度更快,相比于 select。

3、它没有最大连接数的限制,原因是它是基于链表来存储的。

epoll——>原理概述:

epoll 同样只告知那些就绪的文件描述符,而且当我们调用 epoll_wait()获得就绪文件描述符时, 返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去 epoll 指定的一 个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射技术,这 样便彻底省掉了这些文件描述符在系统调用时复制的开销。

epoll 的优点就是改进了前面所说缺点:

1、支持一个进程打开大数目的 socket 描述符:相比 select,epoll 则没有对 FD 的限制,它所支持的 FD 上限是最大可以打开文件的数目,这个数字一般远大于 2048,举个例子,在 1GB 内存的机器上大约是 10 万左右,具体数目可以 cat /proc/sys/fs/file-max 察看,一般来说这个数目和系统内存关系很大。

2、IO 效率不随 FD 数目增加而线性下降:epoll 不存在这个问题,它只会对"活跃"的 socket 进行操作— 这是因为在内核实现中 epoll 是根据每个 fd 上面的 callback 函数实现的。那么,只有"活跃"的 socket 才会主动的去调用 callback 函数,其他 idle 状态 socket 则不会,在这点上,epoll 实现了一个"伪"AIO,因为这时候推动力在 os 内核。在一些 benchmark 中,如果所有的 socket 基本上都是活跃的—比如一个高速 LAN 环境,epoll 并不比 select/poll 有什么效率,相 反,如果过多使用 epoll_ctl,效率相比还有稍微的下降。但是一旦使用 idle connections 模拟 WAN 环境,epoll 的效率就远在 select/poll 之上了。

3、使用 mmap 加速内核与用户空间的消息传递:这点实际上涉及到 epoll 的具体实现了。无论是 select,poll 还是 epoll 都需要内核把 FD 消息通知给用户空间,如何避免不必要的内存拷贝就 很重要,在这点上,epoll 是通过内核于用户空间 mmap 同一块内存实现的。

三者对比与区别:1、select,poll 实现需要自己不断轮询所有 fd 集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而 epoll 其实也需要调用 epoll_wait 不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪 fd 放入就绪链表中,并唤醒在 epoll_wait 中进入睡眠的进程。虽然都要睡眠和交替,但是 select 和 poll 在“醒着”的时候要遍历整个 fd 集合,而 epoll 在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的 CPU 时间。这就是回调机制带来的性能提升。

2、select,poll 每次调用都要把 fd 集合从用户态往内核态拷贝一次,并且要把 current 往设备等待队列中挂一次,而 epoll 只要一次拷贝,而且把 current 往等待队列上挂也只挂一次(在 epoll_wait 的开始,注意这里的等待队列并不是设备等待队列,只是一个 epoll 内部定义的等待队列)。这也能节省不少的开销。


十、结论

epoll 在 select 和 poll(poll 和 select 基本一样,有少量改进)的基础引入了 eventpoll 作为中间层,使用了先进的数据结构,是一种高效的多路复用技术。


用户头像

Linux服务器开发qun720209036,欢迎来交流 2020.11.26 加入

专注C/C++ Linux后台服务器开发。

评论 (2 条评论)

发布
用户头像
很不错。
2020 年 12 月 23 日 21:00
回复
用户头像
666
2020 年 11 月 28 日 16:31
回复
没有更多了
tcp/ip协议栈——epoll的内部实现原理