首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

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 可以写入一些用户数据,它是一个联合体, 可以给指针、文件描述符、整形数据。

       struct epoll_event {
           uint32_t     events;      /* Epoll events */
           epoll_data_t data;        /* User data variable */
       };
        typedef union epoll_data {
           void        *ptr;
           int          fd;
           uint32_t     u32;
           uint64_t     u64;
       } epoll_data_t;

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 服务器。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1