epoll——IO事件通知机制
我们知道 XiaoTuNetBox 的并发能力完全是建立在IO复用的技术基础上的。 我们通过系统调用 poll 同时监听多个文件描述符上的IO事件, 并基于 reactor 模式对其进行了封装, 通过 PollLoop 和 PollEventHandler 完成事件分发,进而实现了 TCP 和 HTTP 的服务器。
在使用 poll 的过程中,我们维护了一个元素为struct pollfd
的线性表。在该列表中,
记录了需要监听的文件描述符,以及感兴趣的事件类型。无论该列表中哪个文件描述符有事件发生,都将导致系统调用 poll 返回。
此时,我们需要遍历一遍所有的文件描述符和事件类型,才能确定搞事情的对象。如果有大量的事件需要监听,比如说,有很多网络请求需要处理,
poll 的这种遍历的方式就显得比较低效了。
幸运的是,做操作系统的人注意到了这一点,开发了 epoll。本文中,我们就来看看它的使用方法。
epoll - I/O event notification facility
1. epoll 简介
epoll 又是一种 I/O 复用的机制。它的功能与 poll 类似,都是监听多个文件描述符上的IO事件。 所不同的是 epoll 接口有边沿触发(edge-triggered)和电平触发(level-triggered)两种形式,适合监听超大规模的文件描述符。 当 epoll 工作在电平触发的形式下时,其实现的功能与 poll 是等价的,只是获取事件的方式略有差异。
epoll 的接口稍微多了一点,需要先使用 epoll_create 来构造 epoll 实例,然后通过 epoll_ctl 注册事件,最后调用 epoll_wait 接口监听事件。 调用 epoll_wait 时,线程会进入阻塞的状态,直到内核捕获到了事件。epoll 接口的核心就是这个通过 epoll_create 构造出来的 epoll 实例。 从我们用户的角度来看,epoll 实例就是关注(interest)和就绪(ready)两个列表。我们通过 epoll_ctl 向关注列表中添加需要监听的文件描述符和事件。 系统内核会根据实际的 IO 活动更新就绪列表,记录下那些准备就绪的 IO 操作,通过 epoll_wait 告知用户,用户只需要检查这个就绪列表就可以了。 不必像 poll 那样遍历一遍所有的文件描述符。
epoll 接口最主要的一个特性就是,它有边沿触发(edge-triggered)和电平触发(level-triggered)两种形式。 这里我特意将 edge 和 level 翻译成边沿和电平,如果读者做过嵌入式的开发,相信对这两个名词再熟悉不过了。 如右图所示,假设有一个数字信号将要从 0 切换到 1 再切换回 0。在电路上,我们通常用不同的低/高电压来表示这个0/1逻辑。 如果有示波器可以显示出电压的变化波形,低/高电压就是两个水平的线,被称为低/高电平。而状态发生变化的一瞬间会有一个陡峭的边沿, 从0变到1就是一个上升的过程,称为上升沿,从1变到0就是一个下降的过程,称为下降沿。那么所谓的边沿触发, 就是在状态发生变化的时候触发一次。而电平触发则是只要信号仍然是高电平就不停的通知用户。
比如说,我们在一个 epoll 实例中监听了一个文件描述符(fd)的可读事件。我们把输入缓存中是否有数据看做是一个数字信号, 0表示没有数据可读,1表示有数据可读。一开始输入缓存是空的,然后我们以某种手段在这个缓存中写入了2k字节的数据,此时无论是边沿触发还是电平触发, 都会导致 epoll_wait 返回。因为信号发生了变化,并且此时还是高电平。返回之后,通过 fd 我们只读走了1k字节的数据,就再次调用了 epoll_wait。 那么在边沿触发的工作模式下,程序会再次阻塞,因为状态并没有发生变化,不存在上升沿。 而电平触发的工作模式下,会立即返回,因为此时仍然是高电平。
2. epoll 调用接口
2.1. 构建 epoll 实例
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
我们可以在Linux的终端中通过指令$ man epoll_create
查看 epoll 实例的构建方式。如右,有两个实现原型。
它们都会促使系统内核构建一个 epoll 实例,并返回一个与之关联的文件描述符。需要注意的是,当不再使用 epoll 实例的时候,
我们应当通过系统调用 close 将之关闭。如此,当所有与该实例关联的文件描述符都被关闭了,内核就会释放该实例。
epoll_create出现的时间相对比较早,其输入参数 size 本来是要告诉内核将有多少个文件描述符挂载到该实例上,现在已经无所谓了, 但是调用的时候必须给一个大于0的数。因为参数 size 已经被弃用了,所以当输入参数 flags 是 0 的时候,epoll_create1 与 epoll_create 的功能是一样的。 这个 flags 可以指定实例的一些特性,目前只有 EPOLL_CLOEXEC 可以用。
2.2. 管理 epoll 事件
我们通过接口 epoll_ctl 来管理需要关注的事件,其函数原型如下所示,有四个输入参数。其中 epfd 就是 epoll 实例的文件描述符, op 则是事件的增删改指令,fd 是关注的文件描述符,结构体指针 event 则具体描述了需要关注的事件和触发方式。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数 op 一共有三种取值,EPOLL_CTL_ADD 用于添加事件到 epoll 实例的关注列表中,EPOLL_CTL_MOD 用于修改已经与 epfd 关联的 fd 的事件和触发配置, EPOLL_CTL_DEL 用于将 fd 从 epoll 实例的关注列表中移除。
下面是结构体 epoll_event 的定义,它有两个字段。其中,events 具体描述了需要关注的事件,比较常用的有可读事件(EPOLLIN)、可写事件(EPOLLOUT)、 出错事件(EPOLLERR)等,还有 EPOLLET 用来配置是否使用边沿触发。字段 data 可以写入一些用户数据,它是一个联合体, 可以给指针、文件描述符、整形数据。
|
|
2.3 监听 epoll 事件
如下面的函数原型所示,我们可以通过 epoll_wait 和 epoll_pwait 来监听关注的事件。epoll_pwait 比 epoll_wait 多了一个参数 sigmask, 它是用于捕获系统信号(signal)的,输入为 NULL 的时候与 epoll_wait 的功能一致。
#include <sys/epoll.h>
int epoll_wait (int epfd, struct epoll_event *events, int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events, int maxevents, int timeout, const sigset_t *sigmask);
其它几个参数中,epfd 是 epoll 实例的关联文件描述符;events 是一个输出列表,当 epoll_wait 返回的时候,它将记录就绪的文件描述符和事件; 参数 maxevents 则约束了一次最多能够从就绪列表中获取多少个文件描述符。参数 timeout 设定了一个超时的毫秒数, 即超过 timeout 指定的时间之后仍然没有事件发生,epoll_wait将会返回 EINTR。
当 epoll_wait 成功退出的时候,它将返回实际从就绪列表中取出的文件描述符数量。
3. 基于 epoll 的 echo 服务器
在下面的示例代码中,我们通过刚刚介绍的 epoll 的三个接口实现了一个简单的 TCP 服务器。该服务器监听 65530 端口上的连接,把从该端口上接收到的数据原样发送回去。
#include <XiaoTuNetBox/Address.h>
#include <XiaoTuNetBox/Socket.h>
#include <cassert>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
const int max_conn = 3; // 最大连接数量
const int max_evs = 3; // 一次最多处理事件数量
struct epoll_event ev; // acceptor 的事件对象
struct epoll_event events[max_evs]; // 新建连接的事件对象
char buf[1024];
xiaotu::net::IPv4 connections[max_conn];
int main() {
int epoll_fd = epoll_create1(EPOLL_CLOEXEC);
printf("epoll_fd: %d\n", epoll_fd);
xiaotu::net::IPv4 serverIp(65530);
xiaotu::net::Socket sock(AF_INET, SOCK_STREAM, 0);
sock.SetReuseAddr(true);
sock.SetKeepAlive(true);
sock.BindOrDie(serverIp);
sock.ListenOrDie(max_conn);
ev.events = EPOLLIN;
ev.data.fd = sock.GetFd();
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sock.GetFd(), &ev) == -1) {
perror("epoll_ctl: EPOLL_CTL_ADD");
exit(-1);
}
while (1) {
int nready = epoll_wait(epoll_fd, events, max_evs, -1);
printf("nready = %d\n", nready);
if (nready == -1) {
perror("epoll_wait");
exit(-1);
}
for (int i = 0; i < nready; ++i) {
if (events[i].data.fd == sock.GetFd()) {
assert(events[i].events & EPOLLIN);
int idx = 0;
for (; idx < max_conn; ++idx)
if (0 == connections[idx].GetPort())
break;
int conn_fd = sock.Accept(connections[idx]);
if (max_conn == idx) {
printf("连接太多了\n");
close(conn_fd);
} else {
int fl = fcntl(conn_fd, F_GETFL);
fl |= O_NONBLOCK;
if (-1 == fcntl(conn_fd, F_SETFL, fl)) {
perror("修改NONBLOCK出错");
return false;
}
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
}
} else {
if (EPOLLIN & events[i].events) {
int conn_fd = events[i].data.fd;
int nread = read(conn_fd, buf, 1024);
if (nread <= 0) {
std::cout << "close conn_fd = " << conn_fd << std::endl;
close(conn_fd);
} else {
send(conn_fd, buf, nread, 0);
}
}
}
}
}
close(epoll_fd);
return 0; }
下面的截图是在我的个人电脑上的运行的结果,左侧是示例代码 u_raw_epoll_echo 运行过程中的输出日志,右侧是一个测试 echo 服务器的工具。从输出的日志中, 可以看到,我们运行 u_stdin_ipv4_talk之后,与 echo 服务器建立了一个 TCP 连接,然后先后发送了 "123456" 和 "abcde" 两个字符串到 echo 服务器上, 服务器也将接收到的数据返回回来了。
4. 完
虽然,通过系统调用 poll 我们可以方便的实现 IO 复用,并基于此实现有并发能力的网络服务器。但是每次 poll 调用返回之后,我们都需要遍历一遍所有的文件描述符, 才能确定搞事情的那个。如此一来,当有大量的连接时,poll的这种遍历方式就比较低效了。未解决该问题,我们在本文中探讨了 epoll 的工作方式和 epoll_create, epoll_ctl, epoll_wait 三个调用接口,最后通过 epoll 实现了一个 echo 服务器。