1. IO 多路转接之epoll
1.1 epoll概述
epoll
是Linux内核为处理大规模并发网络连接而设计的高效I/O多路转接技术。它基于事件驱动模型,通过在内核中维护一个事件表,能够快速响应多个文件描述符上的I/O事件,如可读、可写、异常等,避免了像select
和poll
那样频繁地遍历文件描述符集合,从而大大提高了系统的性能和响应速度。epoll
主要适用于需要处理大量并发连接的服务器应用场景,如Web服务器、邮件服务器、实时通信服务器等,能够轻松应对高并发情况下的I/O处理需求,为用户提供高效、稳定的服务。
1.2 epoll相关函数
1.2.1 epoll_create
- 函数原型:
int epoll_create(int size);
- 参数说明:
size
:从Linux 2.6.8版本开始,该参数已被忽略,但必须大于0。- 返回值说明:
- 成功时返回一个非负的文件描述符,用于后续的
epoll
操作。这个文件描述符将指向内核中的epoll
实例。- 失败时返回 -1,并设置相应的错误码。可能的错误码包括
ENOMEM
(内存不足)等。
1.2.2 epoll_ctl函数
- 函数原型:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 参数说明:
epfd
:epoll_create
返回的epoll
文件描述符。op
:操作类型,有以下几种取值:
EPOLL_CTL_ADD
:向epoll
实例中添加一个文件描述符及其关注的事件。EPOLL_CTL_MOD
:修改已注册文件描述符关注的事件。EPOLL_CTL_DEL
:从epoll
实例中删除一个文件描述符。fd
:要操作的文件描述符。event
:指向struct epoll_event
结构体的指针,用于指定要关注的事件类型和相关数据。- 返回值说明:
- 成功时返回0。
- 失败时返回 -1,并设置相应的错误码。可能的错误码包括
EBADF
(文件描述符无效)、EEXIST
(添加已存在的文件描述符)、ENOENT
(删除不存在的文件描述符)、ENOMEM
(内存不足)等。
struct epoll_event
结构体用于描述epoll
事件,其定义如下:
struct epoll_event {
uint32_t events; // 表示关注的事件类型,如EPOLLIN(可读)、EPOLLOUT(可写)等
epoll_data_t data; // 用于存储与文件描述符相关的用户数据
};
// epoll_data_t结构体定义
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
其中struct epoll_event
结构中有两个成员,第一个成员 events
表示的是需要监视的事件,第二个成员 data
是一个联合体结构,一般选择使用该结构当中的 fd
,表示需要监听的文件描述符。
其中 events
的常用取值如下:
事件取值 | 含义 |
---|---|
EPOLLIN | 表示对应的文件描述符可以读(包括对端SOCKET正常关闭) |
EPOLLOUT | 表示对应的文件描述符可以写 |
EPOLLPRI | 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来) |
EPOLLERR | 表示对应的文件描述符发送错误 |
EPOLLHUP | 表示对应的文件描述符被挂断,即对端将文件描述符关闭了 |
EPOLLET | 将epoll的工作方式设置为边缘触发(Edge Triggered)模式 |
EPOLLONESHOT | 只监听一次事件,当监听完这次事件之后,如果还需要继续监听该文件描述符的话,需要重新将该文件描述符添加到epoll模型中 |
这些取值实际都是以宏的方式进行定义的,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的。所以可以通过按位或操作符实现同时关心两种方式。
1.2.3 epoll_wait函数
- 函数原型:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
- 参数说明:
epfd
:epoll_create
返回的epoll
文件描述符。events
:指向struct epoll_event
结构体数组的指针,用于接收就绪事件的信息。maxevents
:events
数组的大小,即最多能接收的就绪事件数量,该值不能大于创建epoll
模型时传入的 ``size` 值。timeout
:超时时间,单位为毫秒。取值如下:
- -1:阻塞等待,直到有事件就绪或捕获到信号。
- 0:非阻塞等待,立即返回,无论是否有事件就绪。
- 大于0:在指定时间内阻塞等待,超时后返回。
- 返回值说明:
- 成功时返回就绪事件的数量。
- 超时返回0。
- 失败返回 -1,并设置相应的错误码。可能的错误码包括
EBADF
(文件描述符无效)、EFAULT
(events
数组指针无效)、EINTR
(被信号中断)等。
1.3 epoll 的工作原理
epoll
的工作原理是基于eventpoll
结构体。当进程调用epoll_create
函数时,内核创建eventpoll
结构体,其中有红黑树rbr
和就绪队列rdlist
。
struct eventpoll{
//...
//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监视的事件
struct rb_root rbr;
//就绪队列中则存放着将要通过epoll_wait返回给用户的满足条件的事件
struct list_head rdlist;
//...
}
红黑树存储要监视的事件对应的文件描述符和事件类型,epoll_ctl
函数用于对红黑树进行增删改操作。文件描述符可作为红黑树的键值,设置EPOLLONESHOT
选项的事件就绪后会从红黑树自动删除,没设置则一直存在,除非手动删除。
就绪队列存放已就绪的事件,epoll_wait
函数用于从就绪队列获取这些事件。每个事件对应一个epitem
结构体,红黑树和就绪队列的节点分别基于epitem
中的rbn
和rdllink
成员,ffd
记录文件描述符,event
记录事件类型。
struct epitem{
struct rb_node rbn; //红黑树节点
struct list_head rdllink; //双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
在回调机制方面,添加到红黑树的事件会和设备驱动程序建立ep_poll_callback
回调方法。与select
和poll
不同,epoll
不需要操作系统主动轮询检测事件是否就绪。当监视的事件就绪时,自动调用回调方法,将事件添加到就绪队列。
总结来说,epoll
的使用过程就是三步:首先用epoll_create
创建epoll
模型;接着用epoll_ctl
注册要监控的文件描述符;最后用epoll_wait
等待文件描述符就绪。而且因为就绪队列可能被多个执行流访问,eventpoll
结构中有用于保护的锁,本身是线程安全的,还涉及等待队列wq
来处理多个执行流访问同一epoll
模型的情况。
1.4 服务端代码
然后我们就可以编写一个基于 epoll
多路转接的 TCP 服务端,并且编写流程也与 select
服务端类似:
#include "Sock.hpp"
#include <sys/epoll.h>
#define NUM 1024
// EpollServer类,用于实现基于epoll的服务器功能
class EpollServer
{
public:
// 构造函数,初始化服务器监听端口
EpollServer(int port)
: _port(port)
{
}
// 初始化服务器相关设置,包括创建、绑定和监听套接字,同时创建epoll模型
void InitPollServer()
{
// 创建套接字
_listensock.Socket();
// 将套接字绑定到指定端口
_listensock.Bind(_port);
// 开始监听套接字
_listensock.Listen();
// 创建epoll模型,参数NUM用于指定监听事件数量的上限
_epfd = epoll_create(NUM);
if (_epfd < 0)
{
// 如果创建失败,输出错误信息并退出程序
std::cerr << "epoll_create error" << std::endl;
exit(4);
}
}
// 运行服务器,处理客户端连接和数据读取等操作
void Run()
{
// 将监听套接字添加到epoll模型中,监听可读事件
AddEvent(_listensock.Fd(), EPOLLIN);
while (true)
{
struct epoll_event revs[NUM];
int num;
// 调用epoll_wait等待事件发生,根据返回值进行不同处理
switch (num = epoll_wait(_epfd, revs, NUM, -1))
{
case 0:
// epoll_wait返回0,表示超时
std::cout << "time out..." << std::endl;
break;
case -1:
// epoll_wait返回-1,表示发生错误
std::cerr << "epoll error" << std::endl;
break;
default:
// epoll_wait返回其他值,表示有就绪事件,调用处理函数进行处理
HandlerEvent(revs, num);
break;
}
}
}
// 析构函数,关闭监听套接字和epoll模型对应的文件描述符
~EpollServer()
{
if (_listensock.Fd() >= 0)
{
_listensock.Close();
}
if (_epfd >= 0)
{
close(_epfd);
}
}
private:
// 处理就绪事件的函数
void HandlerEvent(struct epoll_event revs[], int num)
{
for (int i = 0; i < num; i++)
{
int fd = revs[i].data.fd;
// 如果是监听套接字且有可读事件,表示有新的客户端连接
if (fd == _listensock.Fd() && revs[i].events & EPOLLIN)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
memset(&peer, 0, len);
std::string clientip;
uint16_t clientport;
// 接受新的客户端连接,获取客户端的套接字描述符、IP地址和端口号
int sock = _listensock.Accept(&clientip, &clientport);
std::cout << "get a new link[" << clientip << ":" << clientport << "]" << std::endl;
// 将新连接的套接字添加到epoll模型中,监听可读事件
AddEvent(sock, EPOLLIN);
}
// 如果不是监听套接字且有可读事件,表示有数据可读,进行数据读取和处理
else if (revs[i].events & EPOLLIN)
{
char buffer[1024];
ssize_t n = read(fd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
// 如果读取到数据,添加字符串结束符并输出数据内容
buffer[n] = 0;
std::cout << "echo# " << buffer << std::endl;
}
else if (n == 0)
{
// 如果读取到的字节数为0,表示客户端已断开连接,关闭对应的套接字并从epoll模型中删除该事件
std::cout << "client quit..." << std::endl;
close(fd);
epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
}
else
{
// 如果读取发生错误,输出错误信息,关闭对应的套接字并从epoll模型中删除该事件
std::cerr << "read error" << std::endl;
close(fd);
epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
}
}
}
}
// 将指定套接字添加到epoll模型中,设置要监听的事件类型
void AddEvent(int sockfd, uint32_t event)
{
struct epoll_event ev;
ev.events = event;
ev.data.fd = sockfd;
// 使用epoll_ctl函数将套接字和事件添加到epoll模型中,操作类型为添加
epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
}
private:
Sock _listensock;
int _port;
int _epfd; // epoll模型对应的文件描述符
};
我们同样可以使用 telnet
进行测试。
1.5 epoll工作模式
epoll
有水平触发(LT,Level Triggered)和边缘触发(ET,Edge Triggered)两种工作方式,这和数字电路中的高电平触发、上升沿触发类似。
epoll
默认工作模式是水平触发(LT)模式。
1.5.1 水平触发模式
当文件描述符就绪时,epoll_wait
会返回该文件描述符,并且如果应用程序没有对该文件描述符进行完全的读写操作(即缓冲区数据没有被全部处理完),那么下次epoll_wait
调用时仍然会立即返回该文件描述符,直到应用程序完成对该文件描述符的I/O操作,使得其状态不再就绪。
例如,一个套接字有可读数据(接收缓冲区中有数据),在水平触发模式下,epoll_wait
会一直通知应用程序该套接字可读,直到应用程序将接收缓冲区中的数据全部读取完。
1.5.2 边缘触发模式
如果想将 epoll
改为ET工作模式,则需要在添加事件时设置EPOLLET
选项。
当文件描述符的状态发生变化(如从不可读到可读,或从不可写到可写)时,epoll_wait
会返回该文件描述符。之后,只有当该文件描述符的状态再次发生变化时,epoll_wait
才会再次通知应用程序。这意味着应用程序在处理事件时必须一次性将数据读取完或写入完,否则可能会错过后续的I/O事件。
对于一个监听套接字,当有新的连接请求到来时(从无连接请求到有连接请求,状态变化),epoll_wait
会返回该监听套接字的就绪事件。如果应用程序接受了连接并得到新的连接套接字,那么这个新的连接套接字在有数据可读(从无数据到有数据,状态变化)时,epoll_wait
会通知应用程序。但如果应用程序在处理可读事件时没有将接收缓冲区中的数据全部读取完,下次epoll_wait
不会再次通知,直到有新的数据到达导致状态再次变化。
在 epoll
的 ET(边缘触发)工作模式中,仅当底层就绪事件从无到有或从有到更多时才通知用户。这就要求用户在读事件就绪时需一次性读完所有数据,写事件就绪时要一次性写满发送缓冲区,否则可能因为此后底层再也没有事件就绪而失去读写机会。
对于读操作,要循环调用 recv
函数。当底层读事件就绪,持续循环调用 recv
,直至某次调用时实际读取字节数小于期望读取字节数,这表明底层数据已读完。不过,存在一种情况,即最后一次调用 recv
时实际读取字节数和期望读取字节数相等,但此时底层数据恰好也读完了,若再次调用 recv
,由于底层无数据,recv
函数将阻塞。这种阻塞问题严重,以单进程服务器为例,若 recv
阻塞且数据不再就绪,服务器就相当于瘫痪了。因此,在 ET 模式下循环调用 recv
读取数据时,必须将对应的文件描述符设置为非阻塞状态。
写操作同理,写数据时要循环调用 send
函数,并且也要将对应的文件描述符设置为非阻塞状态。总之,在 ET 工作模式下,recv
和 send
操作的文件描述符必须设置为非阻塞状态,这是强制要求。