从事服务器端开发需要接触网络编程。 epoll是Linux下高性能网络服务器的必备技术。 Nginx、Redis 和大多数游戏服务器都使用这种复用技术。
◆() 和 poll() IO 复用模型
缺点:
单个进程可以监控的文件描述符数量有最大限制,通常为1024。当然这个数量是可以改变的,但是由于扫描文件描述符的轮询方式,文件描述符数量越大,性能越差; (在Linux内核头文件中,有这样的定义:#1024)
内核/用户空间内存复制问题需要复制大量的句柄数据结构,造成巨大的开销;
返回的是一个包含整个句柄的数组。 应用程序需要遍历整个数组来发现哪些句柄经历了事件;
触发方式为水平触发。 如果应用程序没有完成对一个就绪文件描述符的IO操作,那么后续的每次调用仍然会通知进程这些文件描述符。
与模型相比,poll 使用链表保存文件描述符,因此监控的文件数量没有限制,但其他三个缺点仍然存在。
以模型为例,假设我们的服务器需要支持100万并发连接,如果是1024的话,我们至少需要开启1k个进程才能实现100万并发连接。 除了进程间上下文切换的时间消耗之外,来自内核/用户空间的大量无意识的内存复制、数组轮询等都是系统难以承受的。 因此,基于模型的服务器程序要实现10万级的并发访问是一个困难的任务。
那么,epoll 是时候登场了。
◆epoll IO复用模型实现机制
由于epoll的实现机制与/poll机制完全不同,因此上述缺点在epoll上不再存在。
想象一下以下场景:100 万个客户端同时维护与服务器进程的 TCP 连接。 每个时刻,通常只有数百或数千个 TCP 连接处于活动状态(事实上,大多数场景都是这种情况)。 这么高的并发是如何实现的呢?
在/poll时代,服务器进程每次都会将这100万个连接告知操作系统(将句柄数据结构从用户态复制到内核态),让操作系统内核查询这些套接字上是否发生了事件,并轮询后,将句柄数据复制到用户模式,并轮询服务器应用程序以处理已发生的网络事件。 这个过程消耗大量资源。 因此/poll一般只能处理几千个并发连接。
epoll的设计和实现完全不同。 epoll在Linux内核中申请一个简单的文件系统(文件系统一般用什么数据结构来实现?B+树)。 将原始 /poll 调用分为 3 部分:
1)调用()创建epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用将这100万个已连接的套接字添加到epoll对象中
3)调用连接收集发生的事件
这样,要实现上述场景,只需要在进程启动时创建一个epoll对象,然后在需要的时候添加或删除到这个epoll对象的连接即可。 同时效率也很高,因为调用时,这100万个连接的句柄数据并没有复制到操作系统中,内核不需要遍历所有连接。
epoll很重要,但是epoll和epoll有什么区别呢? epoll高效的原因是什么?
从网卡接收数据开始
下面是典型的计算机结构图。 计算机由CPU、存储器(存储器)和网络接口组成。 了解Epoll本质的第一步就是从硬件角度看计算机是如何接收网络数据的。
计算机结构图(图片来源:Linux内核完全注释微机结构)
下图为网卡接收数据的流程:
这个过程涉及到DMA传输、IO通道选择等硬件相关知识,但我们只需要知道网卡会将接收到的数据写入内存即可。
网卡接收数据的流程
通过硬件传输,网卡接收到的数据存储在内存中,操作系统可以读取它们。
你怎么知道数据已经收到了?
了解Epoll本质的第二步是从CPU的角度来看数据接收。 要理解这个问题,我们首先要理解一个概念:中断。
当计算机执行程序时,它有优先级要求。 例如,当计算机收到断电信号时,应立即保存数据。 保存数据的程序优先级较高(电容可以节省少量电量供CPU短时间运行)。
一般来说,硬件产生的信号需要CPU立即响应,否则数据可能会丢失,所以它的优先级非常高。
CPU应中断正在执行的程序来响应; 当CPU完成对硬件的响应后,应重新执行用户程序。
中断过程如下图所示。 它与函数调用类似,只不过函数调用有预定的位置,而中断位置是由“信号”决定的。
中断例程调用
以键盘为例,当用户按下键盘上的按键时,键盘会向CPU的中断引脚发送高电平。 CPU可以捕获该信号,然后执行键盘中断程序。
下图展示了各种硬件通过中断与CPU交互的过程:
当网卡将数据写入内存时,网卡向CPU发送中断信号。 操作系统这时就可以知道有新的数据到达,然后通过网卡中断程序来处理这些数据。
epoll的线程安全设计
1.对rbtree-->加锁;
2.对queue -->spinlock
3.epoll_wati,cond,mutex
开源项目中ET和LT的选择
ET模式
因为ET模式只有从unavailable到available才会触发,所以
读事件:需要使用while循环读取完,一般是读到EAGAIN,
也可以读到返回值小于缓冲区大小;
如果应用层读缓冲区满:那就需要应用层自行标记,
解决OS不再通知可读的问题
写事件:需要使用while循环写到EAGAIN,
也可以写到返回值小于缓冲区大小
如果应用层写缓冲区空(无内容可写):那就需要应用层自行标记,
解决OS不再通知可写的问题。
LT模式
因为LT模式只要available就会触发,所以:
读事件:因为一般应用层的逻辑是“来了就能读”,
所以一般没有问题,无需while循环读取到EAGAIN;
如果应用层读缓冲区满:就会经常触发,解决方式如下;
写事件:如果没有内容要写,就会经常触发,解决方式如下。
LT经常触发读写事件的解决办法:修改fd的注册事件,
或者把fd移出epollfd。
总结
目前好像还是LT方式应用较多,包括redis,libuv.
LT模式的优点在于:事件循环处理比较简单,
无需关注应用层是否有缓冲或缓冲区是否满,只管上报事件
缺点是:可能经常上报,可能影响性能。
在我目前阅读的开源项目中:
TARS用的是ET,Redis用的是LT,
Nginx可配置(开启NGX_HAVE_CLEAR_EVENT
--ET,开启NGX_USE_LEVEL_EVENT则为LT).