在上一篇的学习中,我们已经简单的使用了epoll的三个接口,但是仅仅了解那些东西是完全不够的!!接下来我们将更深入的学习epoll
1.epoll的两种工作模式——LT和ET
下面来举一个例子帮助大家理解ET和LT模式的区别(送快递的例子)
新上任的快递员小李要给学24宿舍楼的张三送快递,张三买了很多的快递,估摸着有6个快递,小李到了学24的楼底,然后就给楼上的张三打电话,通知张三下来拿快递,但是张三正在和他的狐朋狗友开黑打游戏呢,于是张三就嘴上答应着我马上下去,但始终就不下去,老实人小李见张三迟迟不下来拿快递,又给张三打电话,让张三下来拿快递,但张三嘴上又说,我马上下去拿快递,真的马上,但过了一会儿张三依旧还是不下来,小李又只能给张三打电话,张三啊,你的快递到了,你赶快下来取快递吧,终于张三和自己的狐朋狗友推完对面的水晶了,下楼来取快递了,但是张三一个人一次只拿走了3个快递,还剩下三个快递,张三也没办法了,张三一个人一次只能拿这么多快递啊,于是张三就拿着他的三个快递上楼了,继续和他的舍友开黑打游戏。结果没一会儿,小李又给张三打电话,说张三啊,你的快递没拿完呢,你买了6样东西,你只拿了3样,还剩3个包裹你没拿呢,张三又嘴上说,好的好的,我马上下去拿,但其实又重复着前面的动作,好一会儿才下楼拿走了剩余的3个包裹,当包裹全部被拿走之后,小李才不会给张三打电话了。
老油条快递员小王恰巧也要给学24宿舍楼的张三送快递,恰巧的是,张三这次又买了6个快递,所以小王也碰巧要给张三送6个包裹。小王到了张三楼底下,给张三打了一个电话,说 张三啊,我只给你打一次电话,你现在要是不下来取快递,我后面是不会给你打电话的,除非你又买了新的快递,我手上你的快递数量变多的时候,我才会稍微好心的再给你打一个电话,否则其他情况下,我只会打一次,你要是不下来取快递,那我就不管你了,我给其他客户送快递去了。张三一听,这不行啊,我要是现在不下来取快递,这个快递员以后就不给我打电话了,那我下楼找不到快递员,拿不到我的快递怎么办,所以张三就立马下楼取快递去了。张三一次拿不了这么多快递啊,但张三又不能漏下一些快递,因为小王下一次不会再给张三打电话了,所以张三刚到楼上放下手中的三个快递,又立马返回楼下取走剩余的三个快递了。
在上面的这两个例子中,其实小李的工作模式就是水平触发Level Triggered模式,简称LT模式,小王的工作模式就是边缘触发Edge Triggered模式,简称ET模式,也是多路转接接口高效的模式。
- 水平触发(Level-Triggered, LT)模式
在LT模式下,当epoll检测到某个文件描述符(如socket)上有就绪的事件(如可读事件)时,epoll_wait会立即返回,并通知程序该事件已经就绪。此时,程序可以选择读取部分数据,或者完全不读取数据。如果程序没有读取完所有的数据,那么下一次调用epoll_wait时,只要该文件描述符上仍然有未读取的数据,epoll_wait仍然会返回并通知程序该事件就绪。这意味着,只要文件描述符上有数据可读,并且这些数据还没有被程序完全读取,LT模式下的epoll_wait就会持续通知程序。
这种方式允许程序在需要时逐步处理数据,而不必担心在单次操作中处理完所有数据。然而,这也可能导致epoll_wait的频繁返回,如果程序处理数据的速度跟不上数据到达的速度,可能会导致性能问题。
- 边缘触发(Edge-Triggered, ET)模式
在ET模式下,当epoll首次检测到某个文件描述符上有数据可读时,epoll_wait会返回并通知程序。与LT模式不同的是,如果程序没有在一次epoll_wait返回后读取完所有的数据,并且后续没有新的数据到达,那么下一次调用epoll_wait时,即使文件描述符上仍然有未读取的数据,epoll_wait也不会返回通知。只有当有新的数据到达并且触发了新的可读事件时,epoll_wait才会再次返回。
这种方式要求程序必须在一次epoll_wait返回后,尽可能多地读取数据,直到没有更多数据可读为止。这有助于减少epoll_wait的调用次数,从而提高性能。但是,这也增加了编程的复杂性,因为程序需要能够处理可能到达的任意数量的数据,并且在没有新数据到达的情况下不会再次被通知。
总之,LT模式提供了更大的灵活性,允许程序逐步处理数据;而ET模式则要求程序更高效地处理数据,以减少不必要的epoll_wait调用。在选择使用哪种模式时,需要根据具体的应用场景和需求来决定。
1.1.水平触发模式(LT,Level-Triggered)
注意:epoll默认的工作模式就是LT,不需要任何额外设置
在水平触发模式下,当一个文件描述符上的I/O事件就绪时,epoll会立即通知应用程序,然后应用程序可以对就绪事件进行处理。即,只要文件描述符处于就绪状态,epoll就会持续通知应用程序,直到应用程序处理完所有就绪事件并且再次进入阻塞等待状态。
对于非阻塞I/O,如果一个文件描述符上有可读或可写事件发生,应用程序可以立即进行读或写操作,即使读写操作无法一次完成。如果读或写操作不能立即完成,应用程序可以再次调用epoll等待新的事件通知。
在水平触发模式下,当epoll监控的文件描述符上的I/O事件(如可读、可写)就绪时,epoll会通知应用程序。与边缘触发(ET, Edge Triggered)模式不同的是,只要事件条件持续存在(即文件描述符仍然处于就绪状态),epoll就会不断地通知应用程序。这意味着,如果应用程序没有一次性读取完所有可读数据,或者没有处理完所有就绪的事件,epoll将在下一次调用epoll_wait时再次通知这些事件。
水平触发(LT)模式适用的情况:
- 需要持续处理就绪事件:在水平触发模式下,只要文件描述符上的事件(如可读、可写)就绪,epoll 就会持续通知应用程序,直到应用程序明确处理了这些事件(或者事件本身不再就绪)。这特别适用于需要处理多个相关事件或一次性处理大量数据的情况。水平触发模式适用于需要不断检查和处理文件描述符上就绪事件的情况。例如,当处理网络数据时,如果一次读取操作不能接收完所有数据包,则应用程序可以在下次事件通知时继续读取。
- 阻塞和非阻塞I/O操作混合使用:水平触发模式适用于既有阻塞又有非阻塞I/O操作的情况,可以在阻塞操作中循环调用读取或写入操作。注意:水平触发模式本身并不直接“在阻塞操作中循环调用读取或写入操作”。相反,它是关于事件通知的机制。
1.2.边缘触发模式(ET,Edge-Triggered)
在边缘触发模式下,当一个文件描述符上的状态发生变化时(例如从不可读变为可读,或者从不可写变为可写),epoll会通知应用程序。
与水平触发模式不同的是,边缘触发模式只在状态变化的瞬间通知应用程序,通知仅发送一次。如果应用程序没有及时处理完这个事件,下次等待时将会错过该事件,即使事件仍然处于就绪状态。因此,在边缘触发模式下,应用程序需要确保尽可能完整地处理每个事件,以避免遗漏事件。
边缘触发模式适用于需要及时响应状态变化的场景,通常可以提供更高的性能,因为它最大程度上减少了不必要的事件通知。
在使用 ET 模式时,通常需要将文件描述符设置为非阻塞模式。这是因为如果文件描述符是阻塞的,并且程序没有读取完所有数据,那么下一次调用 read 或类似的 I/O 操作时,程序可能会阻塞在那里,等待更多的数据到达,而 epoll_wait 则不会再次被调用,因为它已经因为之前的状态变化而被通知过了。
将文件描述符设置为非阻塞模式后,如果 read 操作没有读取到任何数据(因为缓冲区为空),它将立即返回一个错误(通常是 EAGAIN 或 EWOULDBLOCK),而不是阻塞在那里。这样,程序就可以继续执行其他任务,或者再次调用 epoll_wait 等待新的事件。
- 怎么保证ET模式下一次性读完所有数据?
在ET(边缘触发)模式下处理数据时,你需要确保在一个事件通知中尽可能多地读取数据,直到read调用返回EAGAIN或EWOULDBLOCK,这确实表示当前没有更多数据可读。这是ET模式的一个关键特性,也是与LT(水平触发)模式的主要区别之一。
在LT模式下,只要文件描述符处于“就绪”状态(例如,有数据可读),epoll_wait就会持续报告事件,无论你是否已经读取了数据。这意味着,如果你没有在一个事件通知中读取所有数据,epoll_wait将在下一次调用时再次报告该事件,直到你读取了所有数据。
然而,在ET模式下,一旦文件描述符的状态从非就绪变为就绪,并且epoll_wait报告了一个事件,那么即使还有未读取的数据,如果没有新的数据到达使文件描述符再次变为就绪状态,epoll_wait也不会再次报告该事件。因此,你必须在一个事件通知中尽可能多地读取数据,直到没有更多数据可读(即read返回EAGAIN或EWOULDBLOCK)。
这通常意味着你需要在一个循环中调用read,直到它返回EAGAIN或EWOULDBLOCK,或者遇到其他错误(如连接被关闭)。这样做可以确保你不会错过任何数据,并且能够在下次调用epoll_wait时及时响应新的事件。
需要注意的是,EAGAIN和EWOULDBLOCK在大多数UNIX-like系统中是等价的,它们都表示资源暂时不可用(在这种情况下,是没有更多数据可读)。然而,在不同的系统和库实现中,它们可能会有所不同,但在这个上下文中,你可以将它们视为可互换的。
相比于LT模式,关于ET(边缘触发)模式的使用,确实需要关注这几个关键点:
- 设置EPOLLET标志:在将文件描述符(如socket)添加到epoll实例时,你需要通过epoll_event结构体中的events字段设置EPOLLET标志,以启用边缘触发模式。这告诉epoll,你希望在该文件描述符的状态从非就绪变为就绪时只接收一次事件通知。
- 将socket文件描述符设置为非阻塞:由于ET模式要求你能够在一个事件通知中尽可能多地处理数据,直到没有更多数据可读,因此将socket设置为非阻塞模式是非常重要的。这允许你的read调用在没有数据可读时立即返回EAGAIN或EWOULDBLOCK,而不是阻塞等待。
- 循环调用read直到返回EAGAIN或EWOULDBLOCK:在接收到一个ET模式的事件通知后,你需要在一个循环中调用read函数,不断尝试从socket中读取数据,直到read返回EAGAIN或EWOULDBLOCK。这表示当前没有更多数据可读,你可以安全地继续等待下一个事件通知。
这里是一个简化的代码示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h> #define MAX_EVENTS 10 // 设置文件描述符为非阻塞
int set_non_blocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags == -1) { perror("fcntl: get flags"); return -1; } if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) { perror("fcntl: set non-blocking"); return -1; } return 0;
} int main() { int epoll_fd = epoll_create1(0); if (epoll_fd == -1) { perror("epoll_create1"); exit(EXIT_FAILURE); } // 假设我们有一个socket fd,这里为了简化,我们使用stdin作为示例 // 注意:在实际应用中,stdin通常不设置为非阻塞,因为标准输入的行为可能不是你所期望的 // 但为了演示ET模式,我们仍然这样做 int fd = 0; // stdin的文件描述符 if (set_non_blocking(fd) == -1) { exit(EXIT_FAILURE); } struct epoll_event event, events[MAX_EVENTS]; event.events = EPOLLIN | EPOLLET; // 关心读事件和使用ET模式 event.data.fd = fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) == -1) { //将事件添加到epoll的红黑树里面去perror("epoll_ctl: add fd"); exit(EXIT_FAILURE); } char buffer[1024]; ssize_t num_bytes; while (1) { int num_events = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (num_events == -1) { if (errno == EINTR) { continue; // 处理中断,例如信号处理 } perror("epoll_wait"); exit(EXIT_FAILURE); } for (int i = 0; i < num_events; i++) { if (events[i].data.fd == fd) { // 在ET模式下,需要循环读取直到EAGAIN,表示没有更多数据可读 while ((num_bytes = read(fd, buffer, sizeof(buffer) - 1)) > 0) { buffer[num_bytes] = '\0'; // 确保字符串以null结尾 printf("Received: %s", buffer); // 注意:这里没有处理EAGAIN/EWOULDBLOCK,因为对于stdin来说, // 在非阻塞模式下,如果没有数据可读,read会立即返回-1并设置errno为EAGAIN // 但对于套接字等,你需要检查并适当处理 } if (num_bytes == -1) { if (errno != EAGAIN && errno != EWOULDBLOCK) { // 真正的错误处理 perror("read"); } // EAGAIN或EWOULDBLOCK时,不需要做任何处理,只是表示没有数据可读 } } } } // 程序实际上不会到达这里,因为有一个无限循环 close(epoll_fd); return 0;
} // 注意:上面的代码将stdin设置为非阻塞,这在实际应用中是不常见的,
// 因为它会改变标准输入的行为,可能导致不期望的结果。
// 通常,ET模式用于套接字等文件描述符,它们可以很好地与非阻塞模式一起工作。
在这个示例中,我们尝试将 stdin 设置为非阻塞,但这在实际应用中可能不是最佳实践,因为标准输入的行为可能会因此变得复杂且难以预测。ET模式更常用于套接字编程,其中文件描述符(套接字)可以很好地与非阻塞模式结合使用。
请注意,在ET模式下处理数据时,你需要确保在一个事件通知中尽可能多地读取数据,直到 read 调用返回 EAGAIN 或 EWOULDBLOCK,这表示没有更多数据可读。如果忽略这一点,并且只读取部分数据,那么当新数据到达时,你可能不会收到新的 epoll 事件通知,直到再次有数据可读并且状态从非就绪变为就绪。
1.3.见一见LT和ET
- 见见LT
首先我们要明白,LT模式的持续通知是什么?
所谓持续通知就是,我某个关心的事件就绪了,那么我们只要调用epoll_wait函数,每次都会立马返回,通知我们。
例如,下面这个
for (;;){int n = epoller_ptr->EpollerWait(revs, num); // 返回值代表有n个事件就绪if (n > 0) // 有事件就绪{std::cout << "event happened,fd :" << revs[0].data.fd << std::endl;// HandlerEvent(revs, n); // 事件就绪的本质就是看他的文件描述符在不在就绪队列里面}else if (n == 0) // 超时了{std::cout << "time out..." << std::endl;}else // 出错了{std::cerr << "EpollWait error" << std::endl;}}
如果有关心的事件就绪了,由于循环的存在,会一直调用epoll_wait函数,然后每次都会立马返回通知我们(由于我们不处理),返回值n肯定大于一,所以会一直打印event happend.....
在前一篇文章中我们写过epoll_server,当然epoll_server的默认工作模式也是LT模式,在下面的代码中我将处理就绪事件的接口HandlerEvent( )屏蔽掉了,当客户端连接到来时,服务器的epoll_wait一定会检测到listensock上的读事件就绪了,所以epoll_wait会返回,告知程序员要处理数据了,但如果程序员一直不处理数据的话,那epoll_wait每次都会告知程序员要处理数据了,所以从显示器的输出结果来看,epoll_wait返回后,根据返回值n,一定是进入到了default分支中,并且每次epoll_wait都会告知程序员事件就绪,所以显示器会一直疯狂打印have events ready,因为只要底层有事件就绪,对于listensock来说,只要内核监听队列有就绪的连接,那就是就绪,epoll_wait就会一直通知程序员事件就绪了,赶快处理吧。(就像小李一样,只要张三不拿走快递,小李就会一直给张三打电话)
- 见见ET
首先我们要明白,ET模式的通知是什么?
所谓通知一次就是,我某个关心的事件就绪了,那么我们循环调用epoll_wait函数,只有第一次调用会立马返回,通知我们。其他调用都会阻塞
例如,下面这个
for (;;){int n = epoller_ptr->EpollerWait(revs, num); // 返回值代表有n个事件就绪if (n > 0) // 有事件就绪{std::cout << "event happened,fd :" << revs[0].data.fd << std::endl;// HandlerEvent(revs, n); // 事件就绪的本质就是看他的文件描述符在不在就绪队列里面}else if (n == 0) // 超时了{std::cout << "time out..." << std::endl;}else // 出错了{std::cerr << "EpollWait error" << std::endl;}}
如果有关心的事件就绪了,由于循环的存在,会一直调用epoll_wait函数,只有第一次调用会返回,后续调用会阻塞掉
2.ET模式高效的原因(fd必须是非阻塞的)
这是非常重要的一个面试题,许多的面试官在问到网络环节时,都会让我们讲一下select poll epoll各自的用法,epoll的底层原理,三个接口的优缺点,还有就是epoll的两种工作模式,以及ET模式高效的原因,ET模式高效的原因也是一个高频的问题。
ET模式下,只有底层数据从无到有,从有到多的时候,才会通知上层一次,通知的机制就是rbtree+ready_queue+cb,所以ET这种通知机制就会倒逼程序员一次将底层的数据全部读走,如果不一次读走,就可能造成数据丢失,你无法保证对方一定会继续给你发数据啊,如果无法保证这点,那就无法保证epoll_wait还会通知你下一次,如果无法保证这一点,那就有可能你只读取了sock的部分数据,但后续epoll_wait可能不会再通知你了,从而导致后续的数据你永远都读不上来了,所以你必须一次将底层的数据全部读走。
如何保证一次将底层的数据全部读走呢?
那就只能循环读取了,如果只调用recv一次,是无法保证一次将底层的数据全部读走的。所以我们可以打个while循环一直读sock接收缓冲区中的数据,直到读取不上来数据,但这里其实就又有一个问题了,如果sock是阻塞的,循环读读到最后一定会没数据,而此时由于sock是阻塞的,那么服务器就会阻塞在最后一次的recv系统调用处,直到有数据到来,而此时服务器就会被挂起,服务器一旦被挂起,那就完蛋了~
服务器被挂起,那就无法运行了,无法给客户提供服务了,这就很有可能造成很多公司盈利上的损失,所以服务器一定不能停下来,更不能被挂起,需要一直运行,以便给客户提供服务。而如果使用非阻塞文件描述符,当recv读取完接收缓冲区中的所有数据时,recv会返回-1,同时错误码被设置为EAGAIN和EWOULDBLOCK,这俩错误码的值是一样的,此时我们就在ET模式下读取完毕了所有的数据了,而我们一次读取完毕所有数据其实本身就是ET模式高效性的一种体现。
所以在工程实践上,epoll以ET模式工作时,文件描述符必须设置为非阻塞,防止服务器由于等待某种资源就绪从而被挂起。
解释完ET模式下fd必须是非阻塞的原因后,那为什么ET模式是高效的呢?
可能有人会说,因为ET模式只会通知一次,倒逼程序员将数据一次全部读走,所以ET模式就是高效的,如果这个问题满分100分的话,你这样的回答只能得到20分,因为你的回答其实仅仅只是答案的引线,真正最重要的部分你还是没说出来。
倒逼程序员一次将数据全部读走,那不就是让上层尽快取走数据吗?尽快取走数据后,就可以给对方发送一个更大的16位窗口大小,让对方更新出更大的滑动窗口大小,提高底层数据发送的效率,更好的使用TCP延迟应答,滑动窗口等策略!!!这才是ET模式高效的最本质的原因!!!
因为ET模式可以更好的利用TCP提高数据发送效率的种种策略,例如延迟应答,滑动窗口等。
之前在讲TCP的时候,TCP报头有个字段叫做PSH,其实这个字段如果被设置的话,epoll_wait就会将此字段转换为通知机制,再通知一次上层,让其尽快读走数据。
3.LT和ET模式使用时的读取方式
在LT模式下,如果fd是阻塞的,那么上次只能读一次,这是出于工程需求,因为我们不能让服务器阻塞挂起,而在文件描述符是阻塞的情况下,如果我们进行循环读,则最后一次肯定会读取不到数据,那么此时服务器进程就会阻塞住,等待fd的skbuff中到来数据,但服务器是不能被阻塞挂起的,所以我们只能读取一行。
如果fd是非阻塞的,那其实就不用担心了,我们进行循环读就可以,这样是比较高效的,因为在非阻塞且是LT工作模式的情况下,无论我们是一行读还是循环读服务器都是不会被阻塞挂起的。对于读一次来说,在LT模式下也是不会出问题的,因为只要skbuff中有数据,那么epoll_wait就会一直通知程序员来尽快取走数据,我们不用担心丢失数据的情况发生。
在ET模式下,fd必须是非阻塞的,因为出于工程实践的角度考虑,为了让数据被程序员完整的拿到,我们只能进行循环读,而只要你进行循环读,fd万万就不能是阻塞的,因为循环读的最后一次读取一定会读不到数据,只要读不到数据,且fd是阻塞的,那么服务器就被挂起了,这并不是我们想要看到的结果,所以在ET模式下,没得商量,fd必须是非阻塞的,同时程序员在应用层读取数据的方式也必须是循环读,不可以读一行。