在当今互网时代 ,从青服务器面临的铜到透高并发场景愈发常见。想象一下,核心一个热门的机制电商网站在促销活动期间 ,瞬间涌入成千上万的从青用户请求;或者一个在线游戏服务器 ,同时承载着海量玩家的铜到透实时交互 。在这些场景下,核心服务器需要高效地处理大量并发连接,机制以确保用户体验的从青流畅性。而这其中 ,铜到透I/O 处理效率成为了关键因素 。核心传统的服务器租用机制 I/O 模型,如阻塞 I/O 、从青非阻塞 I/O 和 I/O 多路复用(select/poll) ,铜到透在处理大量并发连接时逐渐暴露出其局限性。核心阻塞 I/O 模型中 ,当一个线程发起 I/O 操作时,它会被阻塞,直到操作完成 。这意味着在高并发情况下 ,大量线程会被阻塞 ,导致系统资源的浪费和性能的下降。例如,在一个简单的 Web 服务器中 ,源码下载如果使用阻塞 I/O,每个客户端连接都会占用一个线程,当并发连接数增多时,线程资源会被迅速耗尽 ,服务器响应能力急剧下降 。
非阻塞 I/O 虽然避免了线程的阻塞 ,但它需要应用程序不断地轮询检查 I/O 操作的状态,这会消耗大量的 CPU 资源。在并发连接数较少时,这种方式可能还能接受 ,但当连接数大幅增加 ,频繁的轮询会使 CPU 不堪重负,免费模板系统性能反而降低;I/O 多路复用中的 select 和 poll 虽然可以通过一个线程同时监控多个文件描述符 ,但它们也存在明显的缺点。select 的文件描述符数量有限 ,通常在 1024 个左右 ,难以满足大规模并发连接的需求。
而且,select 每次调用都需要将所有文件描述符从用户空间拷贝到内核空间,检查完后再拷贝回来 ,这会带来较大的开销。poll 虽然解决了文件描述符数量的限制问题,建站模板但它同样存在内核空间和用户空间的数据拷贝开销 ,并且在处理大量文件描述符时,性能会随着文件描述符数量的增加而急剧下降 。这些传统 I/O 模型在高并发场景下的局限性,促使我们寻找更高效的解决方案,而 epoll 正是为解决这些问题而诞生的。
epoll 是 Linux 内核为处理大批量文件描述符而作了改进的云计算 poll ,是 Linux 下多路复用 I/O 接口 select/poll 的增强版本 。它诞生于 Linux 2.5.44 内核版本 ,专为解决高并发场景下 I/O 处理的效率问题。epoll 能显著提高程序在大量并发连接中只有少量活跃的情况下的系统 CPU 利用率 。其核心原理是通过内核与用户空间共享内存 ,在内核中维护一个事件表,当有文件描述符就绪时,内核会将其加入就绪队列 ,用户空间通过 epoll_wait 函数获取就绪的香港云服务器文件描述符,而无需像 select 和 poll 那样遍历整个文件描述符集合。
在 I/O 多路复用机制中,select 和 poll 是 epoll 的 “前辈” ,但它们存在一些明显的不足,而 epoll 正是为克服这些不足而出现的 。
select 是最早被广泛使用的 I/O 多路复用函数 ,它允许一个进程监视多个文件描述符 。然而 ,select 存在一个硬伤 ,即单个进程可监视的文件描述符数量被限制在 FD_SETSIZE(通常为 1024),这在高并发场景下远远不够。例如,一个大型的在线游戏服务器,可能需要同时处理成千上万的玩家连接 ,select 的这个限制就成为了性能瓶颈 。此外 ,select 每次调用时,都需要将所有文件描述符从用户空间拷贝到内核空间,检查完后再拷贝回用户空间,并且返回后需要通过遍历 fd_set 来找到就绪的文件描述符 ,时间复杂度为 O (n)。当文件描述符数量较多时,这种无差别轮询会导致效率急剧下降 ,大量的 CPU 时间浪费在遍历操作上。
poll 在一定程度上改进了 select 的不足,它没有了文件描述符数量的硬限制,使用 pollfd 结构体数组来表示文件描述符集合,并且将监听事件和返回事件分开,简化了编程操作。但 poll 本质上和 select 没有太大差别 ,它同样需要将用户传入的数组拷贝到内核空间 ,然后查询每个 fd 对应的设备状态。在处理大量文件描述符时,poll 每次调用仍需遍历整个文件描述符数组 ,时间复杂度依然为 O (n),随着文件描述符数量的增加,性能也会显著下降。而且 ,poll 在用户态与内核态之间的数据拷贝开销也不容忽视。
epoll 则在设计上有了质的飞跃。它没有文件描述符数量的上限 ,能轻松处理成千上万的并发连接,这使得它非常适合高并发的网络应用场景 。epoll 采用事件驱动模式 ,通过 epoll_ctl 函数将文件描述符和感兴趣的事件注册到内核的事件表中,内核使用红黑树来管理这些文件描述符,保证了插入 、删除和查找的高效性 。当有事件发生时,内核会将就绪的文件描述符加入到就绪链表中 ,应用程序通过 epoll_wait 函数获取这些就绪的文件描述符 ,只需处理有状态变化的文件描述符即可,避免了遍历所有文件描述符的开销 ,时间复杂度为 O (1)。这种高效的机制使得 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 epfd = epoll_create(...); epoll_ctl(epfd, ...); //将所有需要监听的 socket 添加到 epfd 中 while(1){ int n = epoll_wait(...) for(接收到数据的 socket){ //处理 } }1.2.3.4.5.6.7.功能分离,使得 epoll 有了优化的可能 。
措施二 :就绪列表select 低效的另一个原因在于程序不知道哪些 socket 收到数据,只能一个个遍历 。如果内核维护一个“就绪列表”,引用收到数据的 socket,就能避免遍历 。
水平触发 ,缺省方式,同时支持block和no-block socket ,在这种做法中,内核告诉我们一个文件描述符是否被就绪了 ,如果就绪了,你就可以对这个就绪的fd进行IO操作。如果你不作任何操作 ,内核还是会继续通知你的,所以,这种模式编程出错的可能性较小。传统的select\poll都是这种模型的代表 。
ET(edge-triggered)边沿触发,高速工作方式 ,只支持no-block socket。在这种模式下 ,当描述符从未就绪变为就绪状态时 ,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个描述符发送更多的就绪通知 ,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如:你在发送 、接受或者接受请求,或者发送接受的数据少于一定量时导致了一个EWOULDBLOCK错误)。但是请注意 ,如果一直不对这个fs做IO操作(从而导致它再次变成未就绪状态),内核不会发送更多的通知 。
区别 :LT事件不会丢弃 ,而是只要读buffer里面有数据可以让用户读取,则不断的通知你。而ET则只在事件发生之时通知。
创建一个epoll句柄,size用来告诉内核 ,这个句柄监听的数目一共有多大,当创建好句柄以后,他就会占用一个fd值 ,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的 ,所以在使用完epoll后 ,必须调用close()关闭,否则可能导致fd被耗尽。
复制int epoll_ctl(int epfd, int op, int fd, struct epoll_event event)*1.epoll的事件注册函数, 它不同于select是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_crete()的返回值,第二个参数表示动作,用三个宏来表示:LL_CTL_ADD: 注册新的 fd 到 epfd 中;EPOLL_CTL_MOD:修改已经注册的fd的监听事件;EPOLL_CTL_DEL :从epfd中删除一个fd第三个参数是要监听的fd ,第四个参数是告诉内核需要监听什么事件,struct epoll_event结构如下:
复制typedef union epoll_data{ void *ptr; int fd; __uint32_t u32; __uint64_t u64; }epoll_data_t; struct epoll_event{ __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ }1.2.3.4.5.6.7.8.9.10.11.events 可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);EPOLLOUT :表示对应的文件描述符可以写;EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);EPOLLERR:表示对应的文件描述符发生错误;EPOLLHUP:表示对应的文件描述符被挂断;EPOLLET :将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话 ,需要再次把这个socket加入到EPOLL队列里 复制int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);1.等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大 ,这个 maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒 ,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时 。
关于ET、LT两种工作模式 :可以得出这样的结论
ET 模式仅当状态发生变化的时候才获得通知,这里所谓的状态的变化并不包括缓冲区中还有未处理的数据,也就是说,如果要采用ET模式,需要一直 read/write直到出错为止,很多人反映为什么采用ET模式只接收了一部分数据就再也得不到通知了,大多因为这样;而LT模式是只要有数据没有处理 就会一直通知下去的。
epoll是Linux操作系统提供的一种事件驱动的I/O模型,用于高效地处理大量并发连接的网络编程。它相比于传统的select和poll方法,具有更高的性能和扩展性。使用epoll可以实现以下几个优势:
高效处理大量并发连接:epoll采用了事件驱动的方式,只有当有可读或可写事件发生时才会通知应用程序,避免了遍历所有文件描述符的开销。内核与用户空间数据拷贝少 :使用epoll时 ,内核将就绪的文件描述符直接填充到用户空间的事件数组中,减少了内核与用户空间之间数据拷贝次数。支持边缘触发(Edge Triggered)模式 :边缘触发模式下,仅在状态变化时才通知应用程序 。这意味着每次通知只包含最新状态的文件描述符信息 ,可以有效避免低效循环检查 。支持水平触发(Level Triggered)模式:水平触发模式下 ,在就绪期间不断地进行通知,直到应用程序处理完该文件描述符。(2)select与poll的缺陷 ?select 和 poll 都是Unix系统中用来监视一组文件描述符的变化的系统调用 。它们可以监视文件描述符的三种变化:可读性、可写性和异常条件。select 和 poll 的主要缺陷如下:
文件描述符数量限制:select 和 poll 都有一个限制,就是它们只能监视少于1024个文件描述符的变化。这对于现代的网络编程来说是不够的,因为一个进程往往需要监视成千上万的连接 。效率问题:虽然 select 和 poll 可以监视多个文件描述符,但是它们在每次调用的时候都需要传递所有要监视的文件描述符集合 ,这会导致效率的降低。信息不足:select 和 poll 返回的只是哪些文件描述符已经准备好了,但是它们并不告诉你具体是哪一个。这就需要对所有要监视的文件描述符进行遍历,直到找到准备好的文件描述符为止。信号中断:select 和 poll 调用可以被信号中断 ,这可能会导致调用失败 。为了解决这些问题,现代操作系统中引入了新的系统调用 epoll 来替代 select 和 poll 。epoll 没有文件描述符的限制 ,它可以监视大量的文件描述符 ,并且可以实现即开即用 ,无需传递所有文件描述符集合。此外 ,epoll 可以直接告诉你哪些文件描述符已经准备好,这大大提高了处理效率。epoll 在 linux 内核中申请了一个简易的文件系统,把原先的一个 select 或者 poll 调用分为了三个部分:调用 epoll_create 建立一个 epoll 对象(在 epoll 文件系统中给这个句柄分配资源)、调用 epoll_ctl 向 epoll 对象中添加连接的套接字、调用 epoll_wait 收集发生事件的连接。这样只需要在进程启动的时候建立一个 epoll 对象,并在需要的时候向它添加或者删除连接就可以了,因此 ,在实际收集的时候,epoll_wait 的效率会非常高,因为调用的时候只是传递了发生 IO 事件的连接。
(1)epoll实现我们以 linux 内核 2.6 为例 ,说明一下 epoll 是如何高效的处理事件的 ,当某一个进程调用 epoll_create 方法的时候,Linux 内核会创建一个 eventpoll 结构体 ,这个结构体中有两个重要的成员。
复制第一个是 rb_root rbr,这是红黑树的根节点,存储着所有添加到 epoll 中的事件,也就是这个 epoll 监控的事件 。 第二个是 list_head rdllist 这是一个双向链表 ,保存着将要通过 epoll_wait 返回给用户的、满足条件的事件。1.2.每一个 epoll 对象都有一个独立的 eventpoll 结构体,这个结构体会在内核空间中创造独立的内存,用于存储使用 epoll_ctl 方法向 epoll 对象中添加进来的事件。这些事件都会挂到 rbr 红黑树中,这样就能够高效的识别重复添加的节点 。
所有添加到 epoll 中的事件都会与设备(如网卡等)驱动程序建立回调关系 ,也就是说 ,相应的事件发生时会调用这里的方法。这个回调方法在内核中叫做 ep_poll_callback,它把这样的事件放到 rdllist 双向链表中。在 epoll 中 ,对于每一个事件都会建立一个 epitem 结构体 。
当调用 epoll_wait 检查是否有发生事件的连接时,只需要检查 eventpoll 对象中的 rdllist 双向链表中是否有 epitem 元素,如果 rdllist 链表不为空,则把这里的事件复制到用户态内存中的同时 ,将事件数量返回给用户。通过这种方法 ,epoll_wait 的效率非常高。epoll-ctl 在向 epoll 对象中添加 、修改、删除事件时 ,从 rbr 红黑树中查找事件也非常快。这样 ,epoll 就能够轻易的处理百万级的并发连接。
(2)epoll工作模式epoll有两种工作模式,LT(水平触发)模式与 ET(边缘触发)模式。默认情况下 ,epoll采用 LT 模式工作 。
两个的区别是:
Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写 。如果这次没有把数据一次性全部读写完(如读写缓冲区太小) ,那么下次调用 epoll_wait() 时 ,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你 。如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回 ,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率 。
Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时 ,epoll_wait() 会通知处理程序去读写 。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时 ,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高 ,系统不会充斥大量你不关心的就绪文件描述符 。
当然,在 LT 模式下开发基于 epoll 的应用要简单一些 ,不太容易出错,而在 ET 模式下事件发生时 ,如果没有彻底地将缓冲区的数据处理完,则会导致缓冲区的用户请求得不到响应 。注意 ,默认情况下 Nginx 采用 ET 模式使用 epoll 的。
如下图所示 ,当某个进程调用epoll_create方法时,内核会创建一个eventpoll对象(也就是程序中epfd所代表的对象) 。eventpoll对象也是文件系统中的一员,和socket一样 ,它也会有等待队列。
图片
最后 epoll_create 生成的文件描述符如下图所示 :
图片
本文讲述的是 kernel 是如何将就绪事件传递给 epoll 并唤醒对应进程上 ,因此在这里主要聚焦于 (wait_queue_head_t wq) 等成员。
(2)就绪列表的数据结构就绪列表引用着就绪的socket ,所以它应能够快速的插入数据。
程序可能随时调用epoll_ctl添加监视socket ,也可能随时删除 。当删除时 ,若该socket已经存放在就绪列表中 ,它也应该被移除 。
所以就绪列表应是一种能够快速插入和删除的数据结构 。双向链表就是这样一种数据结构,epoll使用双向链表来实现就绪队列(对应上图的rdllist)。
(3)索引结构既然epoll将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的socket。至少要方便的添加和移除,还要便于搜索 ,以避免重复添加 。红黑树是一种自平衡二叉查找树,搜索 、插入和删除时间复杂度都是O(log(N)),效率较好。epoll使用了红黑树作为索引结构(对应上图的rbr)
ps :因为操作系统要兼顾多种功能 ,以及由更多需要保存的数据,rdlist并非直接引用socket ,而是通过epitem间接引用,红黑树的节点也是epitem对象。同样,文件系统也并非直接引用着socket。为方便理解 ,本文中省略了某些间接结构。
(4)维护监视列表创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket 。以添加socket为例 ,如下图,如果通过epoll_ctl添加sock1 、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。
图片
当socket收到数据后 ,中断程序会操作eventpoll对象,而不是直接操作进程 。
(5)接收数据当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用 。如下图展示的是sock2和sock3收到数据后,中断程序让rdlist引用这两个socket 。
图片
eventpoll对象相当于是socket和进程之间的中介 ,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态 。
当程序执行到epoll_wait时 ,如果rdlist已经引用了socket,那么epoll_wait直接返回 ,如果rdlist为空 ,阻塞进程。
(6)阻塞和唤醒进程假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
图片
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程 ,进程A再次进入运行状态(如下图) 。也因为rdlist的存在,进程A可以知道哪些socket发生了变化 。
图片
图片
图片
我们知道 ,对于 linux 来说 ,I/O 设备为特殊的文件 ,读写和文件是差不多的 ,但是 I/O 设备因为读写与内存读写相比 ,速度差距非常大。与 cpu 读写速度更是没法比 ,所以相比于对内存的读写,I/O 操作总是拖后腿的那个 。网络 I/O 更是如此,我们很多时候不知道网络 I/O 什么时候到来,就好比我们点了一份外卖,不知道外卖小哥们什么时候送过来,这个时候有两个处理办法:
复制第一个是我们可以先去睡觉,外卖小哥送到楼下了自然会给我们打电话 ,这个时候我们在醒来取外卖就可以了。 第二个是我们可以每隔一段时间就给外卖小哥打个电话 ,这样就能实时掌握外卖的动态信息了 。1.2.第一种方式对应的就是阻塞的 I/O 处理方式 ,进程在进行 I/O 操作的时候,进入睡眠 ,如果有 I/O 时间到达,就唤醒这个进程。第二种方式对应的是非阻塞轮询的方式 ,进程在进行 I/O 操作后,每隔一段时间向内核询问是否有 I/O 事件到达,如果有就立刻处理 。
①阻塞的原理
工作队列
阻塞是进程调度的关键一环 ,指的是进程在等待某事件(如接收到网络数据)发生之前的等待状态 ,recv 、select和epoll都是阻塞方法,以简单网络编程为例。
下图中的计算机中运行着A 、B 、C三个进程 ,其中进程A执行着上述基础网络程序,一开始,这3个进程都被操作系统的工作队列所引用 ,处于运行状态,会分时执行:
图片
当进程A执行到创建socket的语句时,操作系统会创建一个由文件系统管理的socket对象(如下图)。这个socket对象包含了发送缓冲区 、接收缓冲区 、等待队列等成员 。等待队列是个非常重要的结构,它指向所有需要等待该socket事件的进程 。
图片
当程序执行到recv时,操作系统会将进程A从工作队列移动到该socket的等待队列中(如下图)。由于工作队列只剩下了进程B和C,依据进程调度,cpu会轮流执行这两个进程的程序 ,不会执行进程A的程序。所以进程A被阻塞 ,不会往下执行代码,也不会占用cpu资源 。
图片
ps :操作系统添加等待队列只是添加了对这个“等待中”进程的引用 ,以便在接收到数据时获取进程对象 、将其唤醒 ,而非直接将进程管理纳入自己之下。上图为了方便说明,直接将进程挂到等待队列之下 。
②唤醒进程
当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态 ,继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。
(2)线程池OR轮询在现实中,我们当然选择第一种方式,但是在计算机中,情况就要复杂一些 。我们知道 ,在 linux 中,不管是线程还是进程都会占用一定的资源,也就是说,系统总的线程和进程数是一定的。如果有许多的线程或者进程被挂起,无疑是白白消耗了系统的资源 。而且,线程或者进程的切换也是需要一定的成本的,需要上下文切换 ,如果频繁的进行上下文切换,系统会损失很大的性能 。一个网络服务器经常需要连接成千上万个客户端 ,而它能创建的线程可能之后几百个,线程耗光就不能对外提供服务了。这些都是我们在选择 I/O 机制的时候需要考虑的 。这种阻塞的 I/O 模式下,一个线程只能处理一个流的 I/O 事件,这是问题的根源 。
这个时候我们首先想到的是采用线程池的方式限制同时访问的线程数,这样就能够解决线程不足的问题了 。但是这又会有第二个问题了,多余的任务会通过队列的方式存储在内存只能够,这样很容易在客户端过多的情况下出现内存不足的情况 。
还有一种方式是采用轮询的方式 ,我们只要不停的把所有流从头到尾问一遍 ,又从头开始。这样就可以处理多个流了 。
(3)代理采用轮询的方式虽然能够处理多个 I/O 事件 ,但是也有一个明显的缺点,那就是会导致 CPU 空转。试想一下,如果所有的流中都没有数据,那么 CPU 时间就被白白的浪费了 。
为了避免CPU空转,可以引进了一个代理。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流 ,这就是 select 与 poll 所做的事情,可见,采用 I/O 复用极大的提高了系统的效率。
如下图所示,进程在recv阻塞期间,计算机收到了对端传送的数据(步骤①)。数据经由网卡传送到内存(步骤②) ,然后网卡通过中断信号通知CPU有数据到达,CPU执行中断程序(步骤③) 。此处的中断程序主要有两项功能,先将网络数据写入到对应socket的接收缓冲区里面(步骤④) ,再唤醒进程A(步骤⑤),重新将进程A放入工作队列中。
图片
唤醒线程的过程如下图所示 :
图片
epoll主要通过三个系统调用实现其强大的功能,分别是 epoll_create、epoll_ctl 和 epoll_wait。下面我们详细介绍这三个系统调用的功能 、参数和返回值 ,并结合代码示例展示它们的使用方法。
epoll_create用于创建一个 epoll 实例,返回一个文件描述符 ,后续对 epoll 的操作都将通过这个文件描述符进行。在 Linux 2.6.8 之后 ,size参数被忽略 ,但仍需传入一个大于 0 的值 。epoll_create1是epoll_create的增强版本 ,flags参数可以设置为 0 ,功能与epoll_create相同;也可以设置为EPOLL_CLOEXEC,表示在执行exec系列函数时自动关闭该文件描述符。
例如 :
复制int epfd = epoll_create1(0); if (epfd == -1) { perror("epoll_create1"); return 1; }1.2.3.4.5.上述代码创建了一个 epoll 实例,并检查创建是否成功。如果返回值为 - 1,说明创建失败 ,通过perror打印错误信息 。
epoll_ctl用于控制 epoll 实例 ,对指定的文件描述符fd执行操作op 。epfd是epoll_create返回的 epoll 实例文件描述符;op有三个取值 :EPOLL_CTL_ADD表示将文件描述符fd添加到 epoll 实例中 ,并监听event指定的事件;EPOLL_CTL_MOD用于修改已添加的文件描述符fd的监听事件;EPOLL_CTL_DEL则是将文件描述符fd从 epoll 实例中删除,此时event参数可以为 NULL 。
event是一个指向epoll_event结构体的指针,该结构体定义如下:
复制typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };1.2.3.4.5.6.7.8.9.10.11.events字段表示要监听的事件类型,常见的有EPOLLIN(表示对应的文件描述符可以读)、EPOLLOUT(表示对应的文件描述符可以写) 、EPOLLRDHUP(表示套接字的一端已经关闭,或者半关闭)、EPOLLERR(表示对应的文件描述符发生错误) 、EPOLLHUP(表示对应的文件描述符被挂起)等 。data字段是一个联合体 ,可用于存储用户自定义的数据,通常会将fd存储在这里,以便在事件触发时识别是哪个文件描述符。
例如 ,将标准输入(STDIN_FILENO)添加到 epoll 实例中,监听可读事件:
复制struct epoll_event event; event.events = EPOLLIN; event.data.fd = STDIN_FILENO; if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event) == -1) { perror("epoll_ctl"); close(epfd); return 1; }1.2.3.4.5.6.7.8.上述代码将标准输入的文件描述符添加到 epoll 实例中 ,监听可读事件EPOLLIN。如果epoll_ctl调用失败,打印错误信息并关闭 epoll 实例 。
epoll_wait用于等待 epoll 实例上的事件发生 。epfd是 epoll 实例的文件描述符;events是一个指向epoll_event结构体数组的指针,用于存储发生的事件;maxevents表示events数组最多能容纳的事件数量;timeout是超时时间 ,单位为毫秒 。如果timeout为 - 1 ,表示无限期等待,直到有事件发生;如果为 0,则立即返回,不等待任何事件;如果为正数,则等待指定的毫秒数 ,超时后返回 。
返回值为发生的事件数量,如果返回 0 表示超时且没有事件发生;如果返回 - 1,表示发生错误,可通过errno获取具体错误信息。
例如:
复制struct epoll_event events[10]; int nfds = epoll_wait(epfd, events, 10, -1); if (nfds == -1) { perror("epoll_wait"); close(epfd); return 1; } for (int i = 0; i < nfds; i++) { if (events[i].data.fd == STDIN_FILENO) { char buffer[1024]; ssize_t count = read(STDIN_FILENO, buffer, sizeof(buffer)); if (count == -1) { perror("read"); return 1; } printf("Read %zd bytes\n", count); } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.上述代码使用epoll_wait等待 epoll 实例上的事件发生,最多等待 10 个事件 ,无限期等待。当有事件发生时,遍历events数组 ,检查是否是标准输入的可读事件 。如果是 ,读取标准输入的数据并打印读取的字节数。
通过这三个系统调用,我们可以创建 epoll 实例,注册文件描述符及其感兴趣的事件 ,然后等待事件发生并处理,实现高效的 I/O 多路复用。
epoll 能够高效地处理大量并发连接,离不开其精心设计的数据结构 。epoll 主要基于红黑树和双向链表这两种数据结构 ,它们相互配合,为 epoll 的高性能提供了坚实的基础。
epoll 使用红黑树来管理所有注册的文件描述符 。红黑树是一种自平衡的二叉搜索树,它具有以下优点,使其非常适合用于 epoll 的文件描述符管理:
高效的查找、插入和删除操作 :红黑树的查找 、插入和删除操作的时间复杂度均为 O (log n),其中 n 是树中节点的数量 。在 epoll 中,当需要添加一个新的文件描述符进行监听(通过epoll_ctl的EPOLL_CTL_ADD操作)时,红黑树能够快速找到合适的位置插入新节点;当需要删除一个文件描述符(通过epoll_ctl的EPOLL_CTL_DEL操作)时 ,也能高效地找到并删除对应的节点;在查询某个文件描述符是否已经注册时 ,同样能快速定位到对应的节点。这使得 epoll 在处理大量文件描述符时 ,能够保持高效的操作性能,不会因为文件描述符数量的增加而导致性能急剧下降 。有序性 :红黑树的节点是按照键值有序排列的。在 epoll 中,每个文件描述符作为红黑树的一个节点 ,其对应的键值可以是文件描述符的值本身。这种有序性使得在遍历红黑树时 ,可以按照文件描述符的顺序进行 ,方便对所有注册的文件描述符进行管理和操作。例如,在一个高并发的 Web 服务器中,可能会同时有数千个客户端连接 ,每个连接对应一个文件描述符 。epoll 使用红黑树管理这些文件描述符,当有新的客户端连接到来时,能够迅速将其对应的文件描述符插入红黑树中;当某个客户端断开连接时,也能快速从红黑树中删除对应的文件描述符,确保服务器能够高效地管理大量的并发连接。
epoll使用双向链表来存储就绪的文件描述符及其对应的事件。当某个文件描述符上发生了感兴趣的事件(如可读 、可写等) ,内核会将该文件描述符及其事件信息封装成一个节点,添加到双向链表中 。双向链表的主要优点如下:
快速的插入和删除操作 :双向链表在插入和删除节点时 ,只需要修改相邻节点的指针 ,时间复杂度为 O (1) 。在 epoll 中,当有文件描述符变为就绪状态时,能够快速将其插入到双向链表的头部或尾部;当处理完某个就绪事件后,也能迅速将对应的节点从双向链表中删除。这种高效的插入和删除操作,使得 epoll 能够及时响应事件的发生,提高系统的实时性。遍历方便:双向链表可以从链表的头部或尾部开始遍历 ,方便获取所有就绪的文件描述符 。当应用程序调用epoll_wait时,内核会将双向链表中的就绪节点信息复制到用户空间的epoll_event数组中 ,应用程序通过遍历这个数组,就可以获取到所有就绪的文件描述符及其事件信息,进而进行相应的处理。例如 ,在一个网络聊天服务器中,当有多个客户端同时发送消息时,这些客户端对应的文件描述符会因为有可读数据而变为就绪状态,内核会将它们添加到双向链表中。当服务器调用epoll_wait时,能够迅速从双向链表中获取到这些就绪的文件描述符 ,读取客户端发送的消息并进行处理,确保聊天的实时性和流畅性 。
通过红黑树和双向链表的结合,epoll 实现了高效的文件描述符管理和事件通知机制。红黑树负责高效地管理所有注册的文件描述符 ,确保插入、删除和查找操作的高效性;双向链表则专注于存储就绪的文件描述符 ,使得应用程序能够快速获取到需要处理的事件,大大提高了 epoll 在高并发场景下的性能和效率 。
epoll 凭借其高效的 I/O 多路复用能力,在众多实际项目场景中发挥着关键作用 。
在高性能网络服务器领域,epoll 是当之无愧的 “宠儿”。以知名的 Web 服务器 Nginx 为例 ,它广泛应用 epoll 来处理大量并发的 HTTP 请求。在高并发的 Web 应用中,Nginx 通过 epoll 能够同时监听数以万计的客户端连接。当有新的 HTTP 请求到达时,epoll 能迅速捕获到事件并通知 Nginx 进行处理 ,确保服务器能够快速响应客户端的请求,实现高效的数据传输。在 “双 11” 这样的电商购物狂欢节期间,大量用户同时访问电商网站 ,Nginx 利用 epoll 机制可以轻松应对海量的并发连接,保障网站的稳定运行 ,为用户提供流畅的购物体验。
在实时通信系统中 ,如即时通讯软件、在线游戏服务器等,对消息的实时性和系统的并发处理能力要求极高 。epoll 同样大显身手 。在一款热门的在线游戏中,服务器需要同时与成千上万的玩家保持实时连接,处理玩家的操作指令、同步游戏状态等。通过 epoll,游戏服务器可以高效地管理这些并发连接 ,及时接收和处理玩家发送的操作数据 ,并将游戏状态的更新推送给玩家。当玩家在游戏中进行实时对战时,epoll 确保了服务器能够快速响应玩家的操作,如移动、攻击等指令 ,使得游戏画面流畅,玩家之间的交互更加实时 ,极大地提升了游戏的趣味性和竞技性。
在分布式系统中,各个节点之间需要频繁进行通信和数据交互。epoll 可以用于实现分布式系统中的消息队列、RPC(远程过程调用)框架等组件。在一个大规模的分布式电商系统中,订单处理模块、库存管理模块 、支付模块等多个组件之间需要通过消息队列进行异步通信 。epoll 被应用于消息队列服务器 ,用于高效地处理大量的消息收发事件,确保各个模块之间的通信稳定、高效,从而保证整个分布式系统的正常运行 。
下面我们通过一个完整的代码示例 ,展示如何使用 epoll 实现一个简单的 TCP 服务器。这个示例将逐步解释代码逻辑,包括创建 socket 、设置非阻塞、注册 epoll 事件、处理连接和数据读写等操作 。
复制#include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/socket.h> #include <arpa/inet.h> #include <sys/epoll.h> #include <unistd.h> #include <fcntl.h> #define PORT 8888 #define MAX_EVENTS 10 #define BUFFER_SIZE 1024 // 设置文件描述符为非阻塞模式 int setnonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags == -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { int listen_fd, conn_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_len = sizeof(client_addr); int epoll_fd; struct epoll_event event, events[MAX_EVENTS]; char buffer[BUFFER_SIZE]; // 创建监听socket listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd == -1) { perror("socket creation failed"); return 1; } // 设置socket地址和端口 server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = INADDR_ANY; server_addr.sin_port = htons(PORT); // 绑定socket到指定地址和端口 if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) { perror("bind failed"); close(listen_fd); return 1; } // 开始监听 if (listen(listen_fd, 10) == -1) { perror("listen failed"); close(listen_fd); return 1; } // 创建epoll实例 epoll_fd = epoll_create1(0); if (epoll_fd == -1) { perror("epoll_create1 failed"); close(listen_fd); return 1; } // 设置监听socket为非阻塞模式 if (setnonblocking(listen_fd) == -1) { perror("setnonblocking failed"); close(listen_fd); close(epoll_fd); return 1; } // 将监听socket添加到epoll实例中,监听可读事件 event.events = EPOLLIN; event.data.fd = listen_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event) == -1) { perror("epoll_ctl add listen_fd failed"); close(listen_fd); close(epoll_fd); return 1; } // 进入事件循环 while (1) { // 等待事件发生,最多等待10个事件 ,-1表示无限期等待 int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (nfds == -1) { perror("epoll_wait failed"); break; } for (int i = 0; i < nfds; i++) { if (events[i].data.fd == listen_fd) { // 有新的连接请求 conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len); if (conn_fd == -1) { perror("accept failed"); continue; } // 设置新连接的socket为非阻塞模式 if (setnonblocking(conn_fd) == -1) { perror("setnonblocking for new connection failed"); close(conn_fd); } else { // 将新连接的socket添加到epoll实例中 ,监听可读事件 event.events = EPOLLIN; event.data.fd = conn_fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &event) == -1) { perror("epoll_ctl add conn_fd failed"); close(conn_fd); } } } else { // 有数据可读 conn_fd = events[i].data.fd; ssize_t bytes_read = read(conn_fd, buffer, sizeof(buffer)); if (bytes_read == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { // 没有数据可读,继续循环 continue; } else { perror("read failed"); close(conn_fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, conn_fd, NULL); } } else if (bytes_read == 0) { // 对方关闭连接 printf("Client closed connection\n"); close(conn_fd); epoll_ctl(epoll_fd, EPOLL_CTL_DEL, conn_fd, NULL); } else { buffer[bytes_read] = \0; printf("Received: %s\n", buffer); // 回显数据给客户端 ssize_t bytes_written = write(conn_fd, buffer, bytes_read); if (bytes_written == -1) { perror("write failed"); } } } } } // 关闭监听socket和epoll实例 close(listen_fd); close(epoll_fd); return 0; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.94.95.96.97.98.99.100.101.102.103.104.105.106.107.108.109.110.111.112.113.114.115.116.117.118.119.120.121.122.123.124.125.126.127.128.129.130.131.132.133.134.135.136.137.138.139.140.141.142.143.144.145.146.147.148.149. 创建 socket:使用socket函数创建一个 TCP 套接字 ,指定协议族为AF_INET(IPv4),套接字类型为SOCK_STREAM(流式套接字) 。设置 socket 地址和端口 :填充sockaddr_in结构体 ,指定服务器的 IP 地址为INADDR_ANY(表示接受任意 IP 地址的连接),端口号为PORT(这里设置为 8888) 。绑定 socket:使用bind函数将创建的 socket 绑定到指定的地址和端口 ,确保服务器能够在该地址和端口上监听连接请求。开始监听:调用listen函数,将 socket 设置为监听模式,允许客户端连接,参数10表示最大连接队列长度 。创建 epoll 实例:通过epoll_create1函数创建一个 epoll 实例,返回一个文件描述符epoll_fd ,后续对 epoll 的操作都将通过这个文件描述符进行 。设置非阻塞模式:使用setnonblocking函数将监听 socket 设置为非阻塞模式,这样在没有数据可读或可写时 ,read和write操作不会阻塞线程,而是立即返回错误码EAGAIN或EWOULDBLOCK,提高程序的并发处理能力 。注册 epoll 事件:将监听 socket 添加到 epoll 实例中 ,使用epoll_ctl函数,操作类型为EPOLL_CTL_ADD ,表示添加一个新的文件描述符到 epoll 实例中,并指定监听事件为EPOLLIN(可读事件) 。事件循环:进入一个无限循环 ,调用epoll_wait函数等待 epoll 实例上的事件发生 。epoll_wait会阻塞线程,直到有事件发生或超时 。当有事件发生时,它会返回发生事件的文件描述符数量nfds 。处理新连接:如果发生事件的文件描述符是监听 socket ,说明有新的连接请求到来。调用accept函数接受连接,返回一个新的 socket 描述符conn_fd ,用于与客户端进行通信。然后将新连接的 socket 也设置为非阻塞模式,并添加到 epoll 实例中 ,监听可读事件。处理数据读写:如果发生事件的文件描述符不是监听 socket,说明是已连接的客户端有数据可读。调用read函数读取客户端发送的数据 。如果读取到的数据长度为 0,说明客户端关闭了连接 ,关闭对应的 socket 并从 epoll 实例中删除;如果读取过程中出现错误,且错误码不是EAGAIN或EWOULDBLOCK,则打印错误信息并关闭 socket 和从 epoll 实例中删除;如果读取到数据,则将数据打印出来,并使用write函数将数据回显给客户端。通过这个示例,我们可以看到如何使用 epoll 实现一个简单而高效的 TCP 服务器 ,能够同时处理多个客户端的连接和数据读写操作,充分体现了 epoll 在高并发网络编程中的强大能力 。
在使用 epoll 时,开发者常常会遇到一些棘手的问题 ,其中 ET 模式下数据读取不完整以及 epoll 惊群问题较为典型 。
在 ET 模式下 ,数据读取不完整是一个常见的 “陷阱” 。由于 ET 模式的特性,只有当文件描述符的状态发生变化时才会触发事件通知 。在读取数据时,如果没有一次性将缓冲区中的数据全部读完,后续即使缓冲区中仍有剩余数据 ,只要状态不再变化 ,就不会再次触发可读事件通知。这就导致可能会遗漏部分数据,影响程序的正常运行。
例如,在一个网络通信程序中 ,客户端向服务器发送了一个较大的数据包 ,服务器在 ET 模式下接收数据。如果服务器在第一次读取时只读取了部分数据,而没有继续读取剩余数据 ,那么剩余的数据就会被 “遗忘”,导致数据传输的不完整 。解决这个问题的关键在于 ,当检测到可读事件时 ,要循环读取数据,直到read函数返回EAGAIN错误,表示缓冲区中已无数据可读。这样才能确保将缓冲区中的数据全部读取完毕,避免数据丢失 。
epoll惊群问题也是使用epoll时需要关注的重点。epoll惊群通常发生在多个进程或线程使用各自的epoll实例监听同一个socket的场景中。当有事件发生时 ,所有阻塞在epoll_wait上的进程或线程都会被唤醒 ,但实际上只有一个进程或线程能够成功处理该事件,其他进程或线程在处理失败后又会重新休眠。这会导致大量不必要的进程或线程上下文切换 ,浪费系统资源,降低程序性能 。在一个多进程的 Web 服务器中,多个工作进程都使用 epoll 监听同一个端口 。当有新的 HTTP 请求到来时 ,所有工作进程的epoll_wait都会被唤醒,但只有一个进程能够成功接受连接并处理请求 ,其他进程的唤醒操作就成为了无效的开销 。
为了避免epoll 惊群问题 ,可以使用epoll的EPOLLEXCLUSIVE模式,该模式在 Linux 4.5 + 内核版本中可用。当设置了EPOLLEXCLUSIVE标志后,epoll 在唤醒等待事件的进程或线程时,只会唤醒一个,从而避免了多个进程或线程同时被唤醒的情况 ,有效减少了系统资源的浪费 。同时 ,也可以结合使用SO_REUSEPORT选项,每个进程或线程都有自己独立的 socket 绑定到同一个端口,内核会根据四元组信息进行负载均衡 ,将新的连接分配给不同的进程或线程,进一步优化高并发场景下的性能 。
为了充分发挥 epoll 的优势 ,提升程序性能,我们可以从以下几个方面进行优化 :
合理设置epoll_wait的超时时间至关重要 。epoll_wait的timeout参数决定了等待事件发生的最长时间 。如果设置为 - 1 ,表示无限期等待,直到有事件发生;设置为 0 ,则立即返回 ,不等待任何事件;设置为正数,则等待指定的毫秒数。在实际应用中,需要根据具体业务场景来合理选择。
在一些对实时性要求极高的场景 ,如在线游戏服务器 ,可能需要将超时时间设置为较短的值 ,以确保能够及时响应玩家的操作。但如果设置得过短,可能会导致频繁的epoll_wait调用 ,增加系统开销。因此,需要通过测试和调优,找到一个平衡点,既能满足实时性需求,又能降低系统开销。可以根据业务的平均响应时间和事件发生的频率来估算合适的超时时间,然后在实际运行中根据性能指标进行调整 。
批量处理事件也是提高 epoll 性能的有效方法。当epoll_wait返回多个就绪事件时,一次性处理多个事件可以减少函数调用和上下文切换的开销。在一个高并发的文件服务器中 ,可能同时有多个客户端请求读取文件。当epoll_wait返回多个可读事件时 ,可以将这些事件对应的文件描述符放入一个队列中,然后批量读取文件数据 。可以使用线程池或协程来并行处理这些事件 ,进一步提高处理效率 。通过批量处理事件,能够充分利用系统资源,提高程序的吞吐量 。
使用EPOLLONESHOT事件可以避免重复触发带来的性能问题。对于注册了EPOLLONESHOT的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常的事件,且只触发一次 ,除非使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这在多线程环境中尤为重要,它可以确保一个 socket 在同一时刻只被一个线程处理 ,避免多个线程同时操作同一个 socket 导致的竞态条件。
在一个多线程的网络爬虫程序中 ,每个线程负责处理一个网页的下载和解析。通过为每个 socket 设置EPOLLONESHOT事件,可以保证每个 socket 在下载过程中不会被其他线程干扰 ,提高程序的稳定性和性能 。在处理完事件后,要及时重置EPOLLONESHOT事件 ,以便该 socket 在后续有新事件发生时能够再次被触发 。