Epoll编程——流程、易错、关键参数
基本概念
epoll能够管理多个文件描述符,方便服务器对多个链接进行管理。一般来说,一个链接就是一个文件描述符。
设定监听描述符,专门负责所有客户端的TCP连接
int main() {
int listen_fd = socket(AF_INET,SOCK_STREAM,0);
std::cout<<listen_fd<<std::endl;
int opt = 1;
std::cout<<setsockopt(listen_fd,SOL_SOCKET,SO_REUSEADDR|SO_REUSEPORT,&opt,sizeof(opt));
struct sockaddr_in sockaddr_d;
sockaddr_d.sin_family = AF_INET;
sockaddr_d.sin_addr.s_addr = INADDR_ANY;
sockaddr_d.sin_port = htons(8888);
std::cout<<bind(listen_fd,(struct sockaddr*)(&sockaddr_d),sizeof(sockaddr_d));
if(listen(listen_fd,1024)==-1)
{
return -1;
//设置套接字进入监听状态,告知操作系统内核这个套接字可以接受客户端的连接请求,并且初始化一个连接请求队列
//当客户端发起连接请求时,操作系统会将这些请求放入这个队列中。
//后续一般有一个accept调用,是阻塞的。
//accept从监听队列中取出一个连接请求,并返回一个新的套接字文件描述符,用于和该客户端进行通信。
}
else std::cout<<"listen";
初始化
int epoll_fd = epoll_create(100);//最多可以管理多少个文件描述符
管理描述符
epoll_event evd;
evd.data.ptr = new temp_struct(listen_fd);
evd.events = EPOLLIN|EPOLLET;
epoll_ctl(epoll_fd,EPOLL_CTL_ADD,listen_fd,&evd);
//如果是第一次添加这个文件描述符,用ADD,否则用MOD
这里的理解非常关键。
ptr是 void*指针,一般是自定义数据
evens分为两类:设定该文件描述符感兴趣的事件与模式标志
这里的思路是这样的:只有文件符发生了我们感兴趣的事件,epoll_wait才会返回该文件描述符对应的epoll_event结构体。
1、事件类
EPOLLIN:表示对应的文件描述符可以进行读操作。当文件描述符关联的读缓冲区中有数据可读时,会触发该事件。
EPOLLOUT:表示对应的文件描述符可以进行写操作。当文件描述符关联的写缓冲区有足够的空间可以写入数据时,会触发该事件。
EPOLLRDHUP:表示对端关闭连接或者半关闭连接(即对端关闭了写端)。这个标志在处理 TCP 连接时非常有用,可以及时检测到对端的关闭操作。
EPOLLPRI、EPOLLERR、EPOLLHUP,不说了
2、模式标志
EPOLLET:表示采用边缘触发(Edge-Triggered,ET)模式。在边缘触发模式下,只有当文件描述符上的事件状态发生变化时(如从无数据到有数据),才会通知应用程序。与默认的水平触发(Level-Triggered,LT)模式相比,边缘触发模式可以减少不必要的事件通知,提高效率,但要求应用程序必须一次性处理完所有的数据。
EPOLLONESHOT:表示只监听一次事件。当该文件描述符上的事件被触发后,epoll会自动将其从监听列表中移除。除非再次调用epoll_ctrl
处理事件
epoll_event* all_eve = new epoll_event[10];
int i = epoll_wait(epoll_fd,all_eve,10,500);
获取发生事件的文件描述符。
i是发生事件的数量,all_eve是epoll_event数组,500是超时时间,10是最多事件数量。
问题
场景
1、现在假设有一个客户端,向服务器发起链接请求,TCP已经握手成功。
2、服务端一侧accept创建了文件描述符,并且用epoll_ctl成功将该描述符new_fd添加到epoll_fd的管理中。
3、客户端不发送任何消息,直接close文件描述符,此时TCP的FIN信号被服务端接收,并且触发可读事件。
4、此时服务端read函数返回为0。但是服务端我们并没有close该文件描述符,处理如上述代码所示。
现象
多次执行epoll_wait(),发现每次执行new_fd都会返回可读事件。
代码
#define BUFFER_SIZE 1024
#include<sys/epoll.h>
#include<stdlib.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<unistd.h>
#include<errno.h>
#include<iostream>
struct temp_struct
{
int fd = -1;
int d = 1;
temp_struct(int fd_)
{
fd = fd_;
}
};
epoll_event* all_eve;
int main()
{
int listen_fd = socket(AF_INET,SOCK_STREAM,0);
std::cout<<listen_fd<<std::endl;
int opt = 1;
std::cout<<setsockopt(listen_fd,SOL_SOCKET,SO_REUSEADDR| SO_REUSEPORT,&opt,sizeof(opt));
struct sockaddr_in sockaddr_d;
sockaddr_d.sin_family = AF_INET;
sockaddr_d.sin_addr.s_addr = INADDR_ANY;
sockaddr_d.sin_port = htons(8888);
std::cout<<bind(listen_fd,(struct sockaddr*) (&sockaddr_d),sizeof(sockaddr_d));
if(listen(listen_fd,1024)==-1)
{
return -1;
}
else std::cout<<"listen";
int epoll_fd = epoll_create(100);
std::cout<<epoll_fd;
epoll_event evd;
evd.data.ptr = new temp_struct(listen_fd);
evd.events = EPOLLIN|EPOLLET;
epoll_ctl(epoll_fd,EPOLL_CTL_ADD,listen_fd,&evd);
all_eve = new epoll_event[10];
char buff[1024];
size_t coutt = 0;
while(1)
{
int evt = epoll_wait(epoll_fd,all_eve,10,500);
if(evt==0)
{
std::cout<<"no even"<<std::endl;
continue;
}
else if(evt<0)
{
std::cout<<"error"<<std::endl;
}
else
{
for(int i=0;i<evt;i++)
{
int even_fd = reinterpret_cast<temp_struct*>(all_eve[i].data.ptr)->fd;
if(even_fd==listen_fd)
{
sockaddr_in sock_a;
socklen_t lent;
int new_fd = accept(listen_fd,(sockaddr*)(&sock_a),&lent);
epoll_event evdn;
evdn.data.ptr = new temp_struct(new_fd);
evdn.events = EPOLLIN|EPOLLET;
epoll_ctl(epoll_fd,EPOLL_CTL_ADD,new_fd,&evdn);
}
else
{
coutt++;
ssize_t t = read(even_fd,buff,1024);
if(errno==0 && t==0)
std::cout<<"fd is closed by client "<<coutt<<std::endl;
epoll_event evdn;
evdn.data.ptr = all_eve[i].data.ptr;
evdn.events = EPOLLIN|EPOLLET;
epoll_ctl(epoll_fd,EPOLL_CTL_MOD,even_fd,&evdn);
}
}
}
}
return 0;
}
原因分析
客户端断开链接后,服务器这个端口会收到TCP的FIN信号。文件描述符会触发可读事件。此时如果读文件描述符new_fd,返回为0,errno=0。但是文件描述符依然是可读状态。
按理说,如果我们设定为ET,仅EPOLL_IN(可读事件出现时)才会触发epoll事件。因此只有第一次执行epoll_wait会返回new_fd对应的epoll_even,不会反复触发。
问题在于:
epoll_ctl(epoll_fd,EPOLL_CTL_MOD,even_fd,&evdn);
当调用 epoll_ctl(EPOLL_CTL_MOD) 重新注册事件时,内核会重新检查套接字的当前状态。
此时:如果套接字仍处于“可读”状态,epoll_ctl会立即触发一次事件,被epoll_wait返回。
另外一种情况
同样的情境下,如果我们不采用边缘触发模式,而是采用水平触发模式,并删掉ctl语句
#define BUFFER_SIZE 1024
#include<sys/epoll.h>
#include<stdlib.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<unistd.h>
#include<errno.h>
#include<iostream>
struct temp_struct
{
int fd = -1;
int d = 1;
temp_struct(int fd_)
{
fd = fd_;
}
};
epoll_event* all_eve;
int main() {
int listen_fd = socket(AF_INET,SOCK_STREAM,0);
std::cout<<listen_fd<<std::endl;
int opt = 1;
std::cout<<setsockopt(listen_fd,SOL_SOCKET,SO_REUSEADDR|SO_REUSEPORT,&opt,sizeof(opt));
struct sockaddr_in sockaddr_d;
sockaddr_d.sin_family = AF_INET;
sockaddr_d.sin_addr.s_addr = INADDR_ANY;
sockaddr_d.sin_port = htons(8888);
std::cout<<bind(listen_fd,(struct sockaddr*)(&sockaddr_d),sizeof(sockaddr_d));
if(listen(listen_fd,1024)==-1)
{
return -1;
//设置套接字进入监听状态,告知操作系统内核这个套接字可以接受客户端的连接请求,并且初始化一个连接请求队列
//当客户端发起连接请求时,操作系统会将这些请求放入这个队列中。
//后续一般有一个accept调用,是阻塞的。
//accept从监听队列中取出一个连接请求,并返回一个新的套接字文件描述符,用于和该客户端进行通信。
}
else std::cout<<"listen";
int epoll_fd = epoll_create(100);
std::cout<<epoll_fd;
epoll_event evd;
evd.data.ptr = new temp_struct(listen_fd);
evd.events = EPOLLIN|EPOLLET;
epoll_ctl(epoll_fd,EPOLL_CTL_ADD,listen_fd,&evd);
all_eve = new epoll_event[10];
char buff[1024];
size_t coutt = 0;
while(1)
{
int evt = epoll_wait(epoll_fd,all_eve,10,500);
if(evt==0)
{
std::cout<<"no even"<<std::endl;
continue;
}
else if(evt<0)
{
std::cout<<"error"<<std::endl;
}
else
{
for(int i=0;i<evt;i++)
{
int even_fd = reinterpret_cast<temp_struct*>(all_eve[i].data.ptr)->fd;
if(even_fd==listen_fd)
{
sockaddr_in sock_a;
socklen_t lent;
int new_fd = accept(listen_fd,(sockaddr*)(&sock_a),&lent);
epoll_event evdn;
evdn.data.ptr = new temp_struct(new_fd);
evdn.events = EPOLLIN;
epoll_ctl(epoll_fd,EPOLL_CTL_ADD,new_fd,&evdn);
}
else
{
coutt++;
ssize_t t = read(even_fd,buff,1024);
if(errno==0 && t==0)
std::cout<<"fd is closed by client "<<coutt<<std::endl;
}
}
}
}
return 0;
}
依然在每次epoll_wait执行的时候都会返回new_fd对应的epoll_event。这是因为我们将fd注册为水平触发状态(默认)。意味着:只要文件描述符可读状态下,EPOLL_IN事件就不会消失,直到没有数据为止。这里的可读状态不一定是缓冲区中有数据,当收到FIN后,即便没有数据,也会是可读状态。
情况1:水平触发条件:文件描述符fd的读缓冲区中有数据,我们一次没有读取完,那么这个可读事件不会消失。下一次epoll_wait依然还会返回这个文件描述符(因为他有可读事件)。
情况2:客户端close文件描述符,fd在接收到FIN信号后,持续处于可读状态,触发可读事件。此时服务端read返回0,errno=0。如果fd不关闭,那么fd一直有可读事件。
概念精华总结
1、如果文件描述符处于可读状态,这段代码会马上触发一次EPOLL_IN事件。
epoll_event evdn;
evdn.data.ptr = new temp_struct(new_fd);
evdn.events = EPOLLIN|EPOLLET;
epoll_ctl(epoll_fd,EPOLL_CTL_MOD,even_fd,&evdn);
2、客户端close后,服务端收到FIN,会触发一次文件描述符的可读事件。如果read=0,errno=0,代表对端关闭fd。
3、ET是当事件发生时,触发可读。例如收到数据时,触发可读事件,收到FIN时,触发可读。默认情况下是LT,就是只要文件描述符处于可读状态(缓冲区有数据,或者收到FIN后fd持续处于可读状态),可读事件就一直存在。
EPOLL_ONESHOT(场景:ET+并发)
即使可以用 ET 模式,一个 socket 上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程在读取完某个 socket 上的数据后开始处理这些数据,而在数据的处理过程中该 socket 上又有新数据可读(EPOLLIN 再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个 socket 的局面。 一个 socket 连接在任一时刻都只被一个线程处理,可以使用 epoll 的 EPOLLONESHOT 实现。
对于注册了 EPOLLONESHOT 的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl 函数重置该文件描述符上注册的 EPOLLONESHOT 事件。这样,当一个线程在处理某个 socket 时候,其他线程是不可能有机会操作该 socket 的。但反过来思考,注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕,该线程就应该立即重置这个 socket 上的 EPOLLONESHOT 事件,以确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket。