看完上面博客之后,大家了解了epoll的基本函数用法,上文略微提到了ET和LT模式的概念,这篇博文就讲讲ET和LT具体的区别和怎么去使用它。
LT称为水平触发(Level Triggered),它的概念上文已经提到,就来谈谈我的理解(有不同的理解可以在评论中指出,欢迎大家讨论~)。
我的理解就是在LT模式下,有数据到达,epoll就会返回通知我们有事件发生,比如EPOLLIN(有可读事件),这个时候我们就会去用recv或者read函数接收,如果我们一次没有将可读缓冲区中的数据读取完,那epoll接下来还是会通知我们,直到我们将该可读缓冲区数据读取完。(只要这次事件到达,我们没有将该事前全部处理完,epoll会一直通知我们直到事件处理完成为止)
大家先看这个栗子
#include"head.hpp"
int main(int argc, char *argv[])
{
int epollfd, listenfd;
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8888);
//接收任何地址的连接
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
int temp;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT, (char*)&temp, sizeof(temp));
listenfd = socket(AF_INET, SOCK_STREAM, 0);
int old_flag = fcntl(listenfd, F_GETFL);
//设置为非阻塞
fcntl(listenfd, F_SETFL, old_flag | O_NONBLOCK);
socklen_t addr_len = sizeof(serv_addr);
//为了简洁就不进行出错处理,大家在写代码一定记得进行判断
bind(listenfd, (struct sockaddr*)&serv_addr, addr_len);
listen(listenfd, 5);
epollfd = epoll_create(5);
epoll_event serv_event;
serv_event.data.fd = listenfd;
serv_event.events = EPOLLIN;
//设置为et模式
serv_event.events |= EPOLLET;
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &serv_event);
int count;
int i, fd;
int connfd;
int flag;
char readbuf[2] = {0};
while(1)
{
std::cout << "epoll_wait\n";
struct epoll_event events[64];
count = epoll_wait(epollfd, events, 64, -1);
if(count < 0)
{
//...错误处理
std::cout << "epoll_wait failed\n";
exit(-1);
}
for(i = 0; i < count; ++i)
{
fd = events[i].data.fd;
if(fd == listenfd)
{
std::cout << "有新连接到来\n";
//有新连接到来
connfd = accept(listenfd, (struct sockaddr*)&serv_addr, &addr_len);
//设置为非阻塞
int old_option = fcntl(connfd, F_GETFL);
fcntl(connfd, F_SETFL, old_option | O_NONBLOCK);
epoll_event cli_event;
cli_event.data.fd = connfd;
cli_event.events = EPOLLIN ;
//使用et模式
cli_event.events |= EPOLLET;
epoll_ctl(epollfd, EPOLL_CTL_ADD, connfd, &cli_event);
}
else
{
//我们每次只读取一个字节
flag = recv(fd, readbuf, 1, 0);
if(flag == 0)
{
//用户断开连接,我们将其从epoll事件集中删掉
epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, events);
close(fd);
}
else if(flag < 0)
{
//。。。错误处理
std::cout << "recv failed\n";
}
else
{
std::cout << "recv data: " << readbuf[0] << std::endl;
}
}
}
}
close(listenfd);
}
上述代码我们一次只接收一字节的数据。
-
当我们用LT模式工作时,我们用nc命令来模拟客户端给服务器发送"abcde"。
因为我们没有处理完数据,所以epoll会一直通知我们直到将数据处理完毕。
-
当我们用ET模式工作时, 我们用nc命令来模拟客户端给服务器发送"abcde"。
-
我们只接收了一个字节的数据,所以只打印了a,因为是ET工作模式,所以epoll不会在通知我们进行处理。
-
看完了栗子之后,下面我们会详细对LT和ET模式代码进行分析,尤其是ET模式下怎么将数据一次全部处理完成。
LT模式下工作流程的代码。
#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 1024
/*设置为非阻塞*/
int setnonblocking(int fd)
{
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
/*将文件描述符fd上的EPOLLIN注册到epollfd指示的epoll内核事件表中,参数enable_et指定是否对fd启用ET模式*/
void addfd(int epollfd, int fd, bool enable_et)
{
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN;
if(enable_et)
{
event.events |= EPOLLET; //条件满足,将其设为ET模式
}
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd);
}
/*LT模式工作流程*/
void lt(epoll_event* events, int number, int epollfd, int listenfd)
{
char buf[BUFFER_SIZE];
for(int i = 0; i < number; ++i)
{
int sockfd = events[i].data.fd;
if(sockfd == listenfd) //如果是监听套接字证明有新的连接
{
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int connfd = accept(listenfd, (struct sockaddr*)&cli, &len);
addfd(epollfd, connfd, false); //添加到epoll事件集中,LT模式
}
else if(events[i].events & EPOLLIN)
{
/*只要socket读缓存中还有未读出的数据,这段代码就将触发*/
printf("event trigger once\n");
memset(buf, '\0', sizeof(buf));
int ret = recv(sockfd, buf, BUFFER_SIZE -1, 0);
if(ret <= 0)
{
close(sockfd);
continue;
}
printf("get %d bytes of content: %s\n", ret, buf);
}
else
{
printf("something else happened \n");
}
}
}
当epoll在LT模式工作下,epoll会通知我们套接字有事件发生,我们可以选择不立刻处理,epoll还会继续通知我们。
如果我们的recv一次没有将套接字缓冲区中的数据接收完,那么epoll还会继续通知我们,该套接字还有消息未处理完毕,所以我们无需关注数据是否被处理完。但在LT模式下,同一个事件可能被重复通知,导致效率较低。但是LT模式下逻辑简单,且不会出现很多bug因为当没有消息处理完,epoll还是会通知程序进行处理。
ET称为边缘触发(Edge Triggered),该模式下如果epoll如果有事件发生,比如EPOLLIN(可读事件),我们会用recv或read去接收,如果我们没有将读缓冲区中的数据接收完,epoll就再也不会通知我们。所以在该模式下一定要注意当有事件发生时,我们一定要将该事件全部处理完毕,否则就再也收不到该事件的通知。
/*ET工作模式流程*/
void et(epoll_event* events, int number, int epollfd, int listenfd)
{
char buf[BUFFER_SIZE];
for(int i = 0; i < number; ++i)
{
int sockfd = events[i].data.fd;
if(sockfd == listenfd) //有新的连接
{
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int connfd = accept(listenfd, (struct sockaddr*)&cli, &len);
addfd(epollfd, connfd, true); //添加到epoll事件集中并且设置ET工作模式
}
else if(events[i].events & EPOLLIN)
{
/*核心代码*/
/*因为是ET工作模式,所以这段代码不能被重复触发,所以我们循环读取数据,
以确保把socket读缓存中的所有数据读出*/
printf("event trigger once\n");
while(1)
{
memset(buf, '\0', BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
if(ret < 0)
{
/*对于非阻塞I/O,下面的条件成立表示数据已经全部读取完毕。此后,epoll就能再次触发sockfd上的EPOLLIN事件,以驱动下一次读操作*/
if( (errno = EAGAIN) || (errno == EWOULDBLOCK))
{
/*这个错误经常出现在当应用程序进行一些非阻塞(non-blocking)操作(对文件或socket)的时候。
例如,以 O_NONBLOCK的标志打开文件/socket/FIFO,如果你连续做read操作而没有数据可读。
此时程序不会阻塞起来等待数据准备就绪返 回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。
又
*/
printf("read later\n");
break;
}
close(sockfd);
break;
}
else if(ret == 0)
{
close(sockfd);
}
else
{
printf("get %d bytes of content: %s\n", ret, buf);
}
}
}
else
{
printf("something else hanppend\n");
}
}
}
在ET模式下时,当套接字有事件发生时,会通知我们,如果我们不进行处理或者未将该套接字上的消息接收完,内核会将数据丢弃并且不再通知我们。
所以在这种情况下,我们的逻辑就要进行变化,看上面代码,当有可读事件发生时,我们采用while(1)循环,一直读取该套接字上的数据,直至recv返回0或者返回EAGAIN或者EWOULDBLOCK错误时,我们就处理完成。
我们什么时候用LT什么时候用ET呢?
这个就要看大家的看法了。
LT模式的优点就是,我们每次取的数据的长度可以由我们自己定义,或者何时接收连接,但是可能会导致多次触发。
使用ET模式,我们必须一次将数据处理完毕或者必须立即用accept进行连接,其优点是触发次数较少。
看了以上代码,大家可能也发现为什么我们要将描述符都设置为非阻塞呢?
为什么我们用了epoll监听套接字还要将套接字设置为非阻塞呢?
查看man手册可以得知
man 2 select[BUGS]:
Under Linux, select() may report a socket file descriptor as "ready for
reading", while nevertheless a subsequent read blocks. This could for
example happen when data has arrived but upon examination has wrong
checksum and is discarded. There may be other circumstances in which a
file descriptor is spuriously reported as ready. Thus it may be safer
to use O_NONBLOCK on sockets that should not block.
数据不会被读走也可能会被内核丢弃(内核丢弃之后,我们的recv或者read就接收不到数据被阻塞住,直到该套接字有新的数据到来),如果采用阻塞的方式,则工作线程可能永远都将被阻塞住!
惊群现象
多个进程或线程通过select或epoll监听一个listen socket,当有新的连接发生时,所有进程或者线程都会被select/epoll唤醒,但是最终只有一个进程或者线程能accept到这个连接,若采用了阻塞I/O没有accept到的线程或进程就会被block住。
还有一个栗子
当有新的套接字连接我们的服务器时,这个时候我们就会调用accept函数接受该连接,如果服务器繁忙没有及时处理连接,并且客户端在服务器繁忙的时候断开(客户端终止了连接),那么服务器accept将被阻塞住,直到有新的连接到来。所以这就是为什么有了多路复用我们还要将套接字设置为非阻塞的。
UNP上的专业术语
- 这个已完成的连接会被服务器TCP驱除队列,服务器TCP收到来自客户的RST。
- 服务器调用accept,但是由于没有任何已完成的连接,服务器于是阻塞。
- 所以即使使用多路复用,我们还是要将套接字设置为非阻塞的。