一、前言
在上一文中,我们介绍多路转接select,发现他存在许多的问题,例如:
- 每次调用 select, 都需要手动设置 fd 集合, 从接口使用角度来说非常不便.
- select 支持的文件描述符数量太小.
- 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很 多时会很大
- 同时每次调用 select 都需要在内核遍历传递进来的所有 fd,这个开销在 fd 很多时也很大
由于关心的fd集是用户自己设定的,只能从用户态拷贝到内核态,所以缺点3是无法避免的,而缺点4的遍历开销可以通过使用别的数据结构来优化。
多路转接poll就可以解决select的前两个缺点
二、多路转接poll
2.1 poll的使用
poll 函数接口:
C #include <poll.h> int poll(struct pollfd *fds, nfds_t nfds, int timeout); // pollfd 结构 struct pollfd { int fd; /* file descriptor */ short events; /* requested events */ short revents; /* returned events */ };
参数说明:
1. struct pollfd *fds
是一个 poll 函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合。其中events和revents的取值可以为:
- POLLIN:有数据可读。
- POLLOUT:写操作不会阻塞。
- POLLERR:发生错误。
- POLLHUP:发生挂起(hang up)。
- POLLNVAL:无效的文件描述符。
2. nfds_t nfds
表示 fds 数组的长度,即需要监视的文件描述符的数量
3. int timeout
timeout 表示 poll 函数的超时时间, 单位是毫秒(ms).
- 如果timeout 为-1,则poll 函数将一直等待,直到有事件发生。
- 如果timeout 为0,则poll 函数立即返回,不等待任何事件发生。
- 如果timeout 为正整数,则poll 函数将等待指定的毫秒数,如果在这段时间内没有事件发生,则返回。
返回值:
- 返回值小于 0, 表示出错;
- 返回值等于 0, 表示 poll 函数等待超时;
- 返回值大于 0, 表示 poll 由于监听的文件描述符就绪而返回.
2.2 poll 的优点
不同于 select 使用三个位图来表示三个 fdset 的方式,poll 使用一个 pollfd 的指针实现,可以根据实际情况动态开辟,解决了select支持的文件描述符数量太少的问题
- pollfd 结构包含了要监视的 event 和发生的 revent,接口使用比 select 更方便.
- poll 并没有最大数量限制 (但是数量过大后性能也是会下降).
2.3 poll 的缺点
poll 中监听的文件描述符数目增多时
- 和 select 函数一样,poll 返回后,需要轮询 pollfd 来获取就绪的描述符.
- 每次调用 poll 都需要把大量的 pollfd 结构从用户态拷贝到内核中.
- 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降(因为也要遍历)
2.4 代码举例:
class PollServer
{
const static int gdefaultfd = -1;
const static int gnum = 1;
public:
PollServer(uint16_t port)
: _port(port), _listensock(std::make_unique<TcpSocket>()), _num(0), _capacity(gnum)
{
_fd_events = new struct pollfd[gnum];
_listensock->BuildListenSocket(_port);
}
~PollServer()
{
}
void Init()
{
for (int i = 0; i < _capacity; i++)
{
_fd_events[i].fd = gdefaultfd;
_fd_events[i].events = 0;
_fd_events[i].revents = 0;
}
_fd_events[_num].fd = _listensock->Sockfd();
_fd_events[_num].events = POLLIN;
_num++;
}
void Accepter()
{
InetAddr addr;
int sockfd = _listensock->Accepter(&addr);
if (sockfd > 0)
{
LOG(DEBUG, "get a new link, client info %s:%d\n", addr.IP().c_str(), addr.Port());
// 扩容
if (_num == _capacity)
{
struct pollfd *tmp = new struct pollfd[_capacity * 2];
for (int i = 0; i < _capacity * 2; i++)
{
tmp[i].fd = gdefaultfd;
tmp[i].events = 0;
tmp[i].revents = 0;
}
for (int i = 0; i < _num; i++)
{
tmp[i].fd = _fd_events[i].fd;
tmp[i].events = _fd_events[i].events;
tmp[i].revents = _fd_events[i].revents;
}
_capacity = _capacity * 2;
_fd_events = tmp;
LOG(INFO, "fd_events is expend!,_capacity:%d\n", _capacity);
}
_fd_events[_num].fd = sockfd;
_fd_events[_num].events = POLLIN;
_num++;
LOG(INFO, "_num is %d\n", _num);
LOG(INFO, "add %d to fd_array success!\n", sockfd);
}
}
void HanderIO(int i)
{
char buffer[1024];
ssize_t n = ::recv(_fd_events[i].fd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
std::cout << buffer << std::endl;
std::string respstr = "HTTP/1.0 200 OK\r\n";
std::string content = "<html><body><h1>hello</h1></body></html>";
respstr += "Content-Type: text/html\r\n";
respstr += "Content-Length: " + std::to_string(content.size()) + "\r\n";
respstr += "\r\n";
respstr += content;
::send(_fd_events[i].fd, respstr.c_str(), respstr.size(), 0);
}
else if (n == 0)
{
LOG(INFO, "client quit...\n");
// 关闭fd
::close(_fd_events[i].fd);
_fd_events[i].fd = gdefaultfd;
for (int j = i; j < _num - 1; j++)
{
_fd_events[j] = _fd_events[j + 1];
}
--_num;
LOG(INFO, "_num is %d\n", _num);
}
else
{
LOG(ERROR, "recv error\n");
// 关闭fd
::close(_fd_events[i].fd);
_fd_events[i].fd = gdefaultfd;
for (int j = i; j < _num - 1; j++)
{
_fd_events[j] = _fd_events[j + 1];
}
--_num;
LOG(INFO, "_num is %d\n", _num);
}
}
void HandlerEvent()
{
for (int i = 0; i < _num; i++)
{
if (_fd_events[i].revents & POLLIN)
{
if (_listensock->Sockfd() == _fd_events[i].fd)
{
Accepter();
}
else
{
HanderIO(i);
}
}
}
}
void Loop()
{
while (true)
{
int timeout = 3000;
int n = ::poll(_fd_events, _num, timeout);
switch (n)
{
case 0:
LOG(DEBUG, "time out\n");
break;
case -1:
LOG(ERROR, "poll error\n");
break;
default:
LOG(INFO, "haved event ready, n : %d\n", n);
HandlerEvent();
break;
}
}
}
private:
uint16_t _port;
SockSPtr _listensock;
struct pollfd *_fd_events;
int _num;
int _capacity;
};
三、多路转接epoll(重要)
3.1 epoll初识
按照 man 手册的说法: 是为处理大批量句柄而作了改进的 poll. 它是在 2.5.44 内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44) 它几乎具备了之前所说的一切优点,被公认为 Linux2.6 下性能最好的多路 I/O 就绪通知方法.
3.2 epoll 的相关系统调用
1. epoll_create
epoll_create是一个用于创建一个epoll实例的系统调用函数
#include <sys/epoll.h> int epoll_create(int size);
参数:
size参数表示事件表的大小,即在创建epoll实例时,预期会监听的文件描述符的数量。然而,从Linux 2.6.8版本开始,这个参数被内核忽略,但仍然需要传递一个大于0的值以保持向后兼容性。
返回值:
成功时,epoll_create返回一个非负整数的文件描述符,该描述符代表了新创建的epoll实例。
失败时,返回-1,并设置errno来指示错误类型。可能的错误码包括:
EINVAL
:如果size参数不是正数。EMFILE
:达到了每个用户对/proc/sys/fs/epoll/max_user_instances
施加的epoll实例数量的限制。ENFILE
:已达到打开文件总数的系统限制。ENOMEM
:没有足够的内存来创建内核对象。注意:
每创建一个epoll句柄,会占用一个文件描述符(fd)。因此,当不再需要epoll实例时,应使用close函数关闭epoll_create返回的文件描述符,以释放资源。否则,可能导致文件描述符被耗尽。
2. epoll_ctl
epoll_ctl 函数是Linux系统中用于操作epoll实例的一个关键函数。它允许用户向epoll实例中添加、修改或删除文件描述符及其关联的事件。
#include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
int epfd
:由epoll_ctl 函数返回的文件描述符,它标识了一个epoll实例。
int op
:表示要执行的操作类型,常见的操作类型有:
EPOLL_CTL_ADD
:向epoll实例中添加一个新的文件描述符及其事件。EPOLL_CTL_MOD
:修改epoll实例中已经注册的文件描述符的事件。EPOLL_CTL_DEL
:从epoll实例中删除一个文件描述符及其事件。
int fd
:要进行操作的目标文件描述符,即要注册、修改或删除的文件描述符。
struct epoll_event *event
:指向一个epoll_event
结构体的指针,该结构体包含了要注册或修改的事件信息。如果是删除操作(EPOLL_CTL_DEL
),该参数可以为NULL
。struct epoll_event结构体
events 可以是以下几个宏的集合:
- EPOLLIN : 表示对应的文件描述符可以读 (包括对端 SOCKET 正常关闭);
- EPOLLOUT : 表示对应的文件描述符可以写;
- EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外 数据到来);
- EPOLLERR : 表示对应的文件描述符发生错误;
- EPOLLHUP : 表示对应的文件描述符被挂断;
- EPOLLET : 将 EPOLL 设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.
- EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继 续监听这个 socket 的话, 需要再次把这个 socket 加入到 EPOLL 队列里.
3.3 epoll_wait
#include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数:
int epfd
:指向epoll实例的文件描述符,该实例是在调用epoll_create
系统调用时创建的。struct epoll_event *events
:用于存储发生事件的文件描述符和事件类型的数组。这个数组由用户分配,其大小应大于等于maxevents
。int maxevents
:指定events
数组可以容纳的最大事件数,即epoll_wait
最多可以返回的准备就绪的事件数目。int timeout
:指定等待超时时间,以毫秒为单位。如果设置为-1,表示永远等待直到有事件发生;如果设置为0,表示立即返回,不等待任何事件发生;如果设置为一个正整数N,表示等待最多N毫秒。返回值:
- 成功时,epoll_wait 返回发生事件的文件描述符数量。
- 失败时,返回-1,并设置全局变量errno以指示错误类型。
工作原理:
epoll_wait 函数会阻塞当前线程,直到有注册的文件描述符上发生了指定的事件之一,或者超时。当事件发生时,epoll_wait 会将发生的事件填充到用户提供的events数组中,并返回发生事件的文件描述符数量。用户可以通过遍历这个数组来获取发生事件的文件描述符和对应的事件类型。
3.3 epoll工作原理
当某一进程调用 epoll_create 方法时,Linux 内核会创建一个 eventpoll 结构 体,这个结构体中有两个成员与 epoll 的使用方式密切相关.
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到 epoll 中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过 epoll_wait 返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
- 每一个 epoll 对象都有一个独立的 eventpoll 结构体(每个epoll对象都有一个红黑树和就绪队列),用于存放通过 epoll_ctl 方 法向 epoll 对象中添加进来的事件.
- 这些事件都会挂载在红黑树中,红黑树是一种自平衡二叉查找树,其搜索、插入和删除操作的时间复杂度都是O(logN)。这使得epoll在添加、删除或查找文件描述符时具有较高的效率。
- 而所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说当响应的事件发生时会调用这个回调方法.
- 这个回调方法在内核中叫 ep_poll_callback,它会将发生的事件添加到 rdlist 双链表中。例如,当传输层接受缓冲区有数据后,进行正常的工作以后,就调用这个回调,将事件添加到就绪队列中。
在 epoll 中,对于每一个事件,都会建立一个 epitem 结构体
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的 eventpoll 对象
struct epoll_event event; //期待发生的事件类型
}
当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 双链表中是否有 epitem 元素即可.
如果 rdlist 不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户. 这个操作的时间复杂度是 O(1).
总结一下, epoll 的使用过程就是三部曲:
- 调用 epoll_create 创建一个 epoll 句柄;
- 调用 epoll_ctl, 将要监控的文件描述符进行注册;
- 调用 epoll_wait, 等待文件描述符就绪;
3.4 epoll 的优点(和 select 的缺点对应)
- 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要 每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开
- 数据拷贝轻量: 每次调用select或poll函数时,用户需要将所有要监视的文件描述符集合从用户空间拷贝到内核空间。这意味着,无论文件描述符的数量有多少,每次调用都需要进行完整的拷贝操作,当有事件发生时,内核需要将触发事件的文件描述符集合从内核空间拷贝回用户空间,以便用户进程进行处理。epoll使用epoll_ctl函数注册要监听的文件描述符和事件。在这个过程中,文件描述符和事件信息会被拷贝到内核空间,并存储在红黑树中。但这一拷贝操作是在注册时进行的,而不是每次调用epoll_wait时都进行。当有事件发生时,内核只需要将触发事件的文件描述符(而不是整个集合)从内核空间拷贝到用户空间。这是因为epoll维护了一个就绪队列,其中只包含已经触发事件的文件描述符。
- 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符 结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度 O(1). 即使文件描述符数目很多, 效率也不会受到影响.
- 没有数量限制: 文件描述符数目无上限.
有些博客说, epoll 中使用了内存映射机制
内存映射机制: 内核直接将就绪队列通过 mmap 的方式映射到用户态. 避免了 拷贝内存这样的额外性能开销.
这种说法是不准确的. 我们定义的 struct epoll_event 是我们在用户空间中分配好的内存. 势必还是需要将内核的数据拷贝到这个用户空间的内存中的.
3.5 epoll 工作方式
epoll 有 2 种工作方式-水平触发(LT)和边缘触发(ET)
水平触发 Level Triggered 工作模式
假如有这样一个例子:我们已经把一个 tcp socket 添加到 epoll 描述符,这个时候 socket 的另一端被写入了 2KB 的数据,调用 epoll_wait,并且它会返回. 说明它已经准备好读取操作,然后调用 read, 只读取了 1KB 的数据,继续调用 epoll_wait......
epoll 默认状态下就是 LT 工作模式.
- 当 epoll 检测到 socket 上事件就绪的时候, 可以不立刻进行处理. 或者只处理一 部分.
- 如上面的例子, 由于只读了 1K 数据, 缓冲区中还剩 1K 数据, 在第二次调用 epoll_wait 时, epoll_wait 仍然会立刻返回并通知 socket 读事件就绪.
- 直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
- 支持阻塞读写和非阻塞读写
边缘触发 Edge Triggered 工作模式
如果我们在第 1 步将 socket 添加到 epoll 描述符的时候使用了 EPOLLET 标志, epoll 进 入 ET 工作模式.
- 当 epoll 检测到 socket 上事件就绪时, 必须立刻处理.
- 如上面的例子, 虽然只读了 1K 的数据, 缓冲区还剩 1K 的数据, 在第二次调用 epoll_wait 的时候, epoll_wait 不会再返回了.
- 也就是说, ET 模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
- ET 的性能比 LT 性能更高( epoll_wait 返回的次数少了很多). Nginx 默认采用 ET 模式使用 epoll.
- 只支持非阻塞的读写
ps:select 和 poll 其实也是工作在 LT 模式下. epoll 既可以支持 LT, 也可以支持 ET.
对比 LT 和 ET
使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中 就把所有的数据都处理完. 相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.
另一方面, ET 的代码复杂程度更高了.
理解 ET 模式和非阻塞文件描述符
使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞. 这个不是接口上的要求, 而是 "工 程实践" 上的要求. 假设这样的场景: 服务器接收到一个 10k 的请求, 会向客户端返回一个应答数据. 如果客 户端收不到应答, 不会发送第二个 10k 请求.
如果服务端写的代码是阻塞式的 read, 并且一次只 read 1k 数据的话(read 不能保证一 次就把所有的数据都读出来), 剩下的 9k 数据就会待在缓冲区中.
此时由于 epoll 是 ET 模式, 并不会认为文件描述符读就绪. epoll_wait 就不会再次返回. 剩下的 9k 数据会一直在缓冲区中. 直到下一次客户端再给服务器写数据. epoll_wait 才能返回
但是问题来了.
- 服务器只读到 1k 个数据, 要 10k 读完才会给客户端返回响应数据.
- 客户端要读到服务器的响应, 才会发送下一个请求
- 客户端发送了下一个请求, epoll_wait 才会返回, 才能去读缓冲区中剩余的数据.
所以, 为了解决上述问题(阻塞 read 不一定能一下把完整的请求读完), 于是就可以使用 非阻塞轮训的方式来读缓冲区, 保证一定能把完整的请求都读出来.
而如果是 LT 没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪.
3.6 epoll 的使用场景
epoll 的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll 的性能可能适得其反.
对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用 epoll.
例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网 APP 的入口服务器, 这样的服务器就很适合 epoll. 如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用 epoll 就并不合适. 具体要根据需求和场景特点来决定使用哪种 IO 模型.
3.7 代码举例
class EpollServer
{
const static int size = 128;
const static int num = 128;
public:
EpollServer(uint16_t port)
: _port(port), _listensock(std::make_unique<TcpSocket>())
{
_listensock->BuildListenSocket(_port);
_epfd = ::epoll_create(size);
if (_epfd < 0)
{
LOG(FATAL, "epoll create error!\n");
exit(1);
}
LOG(INFO, "epoll create success! epfd:%d\n", _epfd);
}
~EpollServer()
{
if (_epfd >= 0)
{
close(_epfd);
}
_listensock->Close();
}
void Init()
{
// 将listensock添加关心
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = _listensock->Sockfd();
int n = ::epoll_ctl(_epfd, EPOLL_CTL_ADD, _listensock->Sockfd(), &ev);
if (n < 0)
{
LOG(ERROR, "listensock add error\n");
}
LOG(ERROR, "listensock add success!\n");
}
void Accepter()
{
InetAddr addr;
int sockfd = _listensock->Accepter(&addr);
if (sockfd < 0)
{
LOG(ERROR, "listensock accept error!\n");
return;
}
LOG(INFO, "get a new link,client info %s:%d!\n", addr.IP(), addr.Port());
// 将sockfd添加到关心
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
int n = ::epoll_ctl(_epfd, EPOLL_CTL_ADD, sockfd, &ev);
if (n < 0)
{
LOG(ERROR, "sockfd add error\n");
}
LOG(INFO, "epoll_ctl success, add new sockfd : %d\n", sockfd);
}
void HanderIO(int fd)
{
char buffer[1024];
int n = ::recv(fd, buffer, sizeof(buffer) - 1, 0);
if (n > 0)
{
buffer[n] = 0;
std::cout << buffer << std::endl;
std::string respstr = "HTTP/1.0 200 OK\r\n";
std::string content="<html><body><h1>hello</h1></body></html>";
respstr += "Content-Type: text/html\r\n";
respstr += "Content-Length: " + std::to_string(content.size()) + "\r\n";
respstr += "\r\n";
respstr+=content;
::send(fd,respstr.c_str(),respstr.size(),0);
}
else if (n == 0)
{
LOG(INFO, "client quit!\n");
// 1.将fd在epoll移出
int n = ::epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
if (n < 0)
{
LOG(ERROR, "IO_fd DEL error,fd:%d\n", fd);
exit(2);
}
// 2.关闭文件描述符
::close(fd);
}
else
{
LOG(ERROR, "recv error!\n");
// 1.将fd在epoll移出
int n = ::epoll_ctl(_epfd, EPOLL_CTL_DEL, fd, nullptr);
if (n < 0)
{
LOG(ERROR, "IO_fd DEL error,fd:%d\n", fd);
exit(2);
}
// 2.关闭文件描述符
::close(fd);
}
}
void HanderEvent(int n)
{
for (int i = 0; i < n; i++)
{
int fd = _recvs[i].data.fd;
if (_recvs[i].events & EPOLLIN)
{
if (fd == _listensock->Sockfd())
{
Accepter();
}
else
{
HanderIO(fd);
}
}
}
}
void Loop()
{
int timeout = 3000;
while (true)
{
int n = ::epoll_wait(_epfd, _recvs, num, timeout);
switch (n)
{
case 0:
// 超时了
LOG(INFO, "epoll_wait timeout!\n");
break;
case -1:
LOG(INFO, "epoll_wait error!\n");
sleep(3);
break;
default:
// 有事件发生
HanderEvent(n);
break;
}
}
}
private:
uint16_t _port;
std::unique_ptr<TcpSocket> _listensock;
int _epfd;
struct epoll_event _recvs[num];
};