
优于select的epoll
17.1 epoll的理解及应用
第章通过让一个进程同时监视多个套接字,实现了复用。但随着连接数量增加,在监视对象管理和就绪检查上的成本会越来越明显。提供的正是为大规模监视设计的另一种机制。
select速度较慢的原因
主要存在两个性能问题:
- 函数返回后,程序需要遍历监视范围内的所有文件描述符,逐一调用寻找发生事件的对象。
- 每次调用前,都要重新复制并向操作系统传递完整的监视集合。
第一个问题会让检查成本随着最大文件描述符增大。即使当前只有少数套接字就绪,程序仍要扫描整个范围。第二个问题则意味着用户空间与内核空间之间需要反复传递监视信息,这种成本很难仅靠优化应用代码消除。
采用不同的管理方式:程序只在监视范围发生变化时通知操作系统,把文件描述符长期注册在内核维护的实例中;等待事件时,内核只返回已经就绪的对象。程序仍需遍历返回的事件数组,但不必扫描所有受监视的描述符。
select仍然有适用场景
是特有接口,不具备那样广泛的平台兼容性。若服务器连接数量较少,或者程序需要方便地移植到不同操作系统,仍然可能是更简单、合理的选择。
并不存在适用于所有场景的模型。连接规模、平台兼容性和实现复杂度都应纳入选择,而不能只因为通常性能更好就无条件使用。
epoll的三个核心函数
服务器主要使用三个函数:
- :创建保存监视对象的实例。
- :向实例添加、删除或修改文件描述符。
- :等待事件,并返回已经发生事件的描述符。
通过应用程序中的保存监视集合,而把监视关系交给操作系统维护。事件发生后,会把就绪信息写入数组:
#include <sys/epoll.h>
struct epoll_event{ uint32_t events; epoll_data_t data;};
typedef union epoll_data{ void *ptr; int fd; uint32_t u32; uint64_t u64;} epoll_data_t;表示关注或已经发生的事件类型,用于保存与事件关联的数据。简单服务器通常使用保存套接字文件描述符。
创建epoll实例
#include <sys/epoll.h>
int epoll_create(int size);//成功时返回epoll文件描述符,失败时返回-1。调用成功后,操作系统会创建一个实例,并返回用于标识它的文件描述符。在早期内核中用于向系统建议实例规模;从开始,该参数会被忽略,但仍需传入一个大于的值。
int epfd = epoll_create(50);实例也是由操作系统管理的资源。程序结束监视后,需要像关闭普通文件描述符一样调用。
注册和修改监视对象
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);//成功时返回0,失败时返回-1。指定实例,是需要操作的文件描述符,表示操作类型:
| 操作 | 含义 |
|---|---|
| EPOLL_CTL_ADD | 添加新的监视对象 |
| EPOLL_CTL_DEL | 删除已有监视对象 |
| EPOLL_CTL_MOD | 修改对象所关注的事件 |
将套接字加入实例,并关注可读事件:
struct epoll_event event;
event.events = EPOLLIN;event.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &event);从实例中删除描述符时,不再需要提供关注事件:
epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);常见事件标志如下:
| 标志 | 含义 |
|---|---|
| EPOLLIN | 存在可读取的数据 |
| EPOLLOUT | 可以立即写入数据 |
| EPOLLPRI | 收到紧急数据 |
| EPOLLRDHUP | 对方关闭连接或半关闭输出流 |
| EPOLLERR | 套接字发生错误 |
| EPOLLET | 使用边缘触发方式通知事件 |
| EPOLLONESHOT | 通知一次后暂时停止继续通知 |
多个标志可以通过按位或运算符组合。使用后,若要让描述符重新接收通知,需要通过再次设置事件。
等待事件
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);//成功时返回发生事件的描述符数量,失败时返回-1。用于保存已经发生的事件,表示数组容量。以毫秒为单位,传入表示一直等待,传入则立即返回。
struct epoll_event *ep_events;int event_cnt;
ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);函数返回后,到只保存本次已经就绪的对象。因此,后续循环的范围由就绪事件数决定,而不是由所有监视对象的数量决定。
基于epoll的回声服务器
基于的回声服务器仍由监听套接字和客户端套接字组成。区别在于,这些描述符会被注册到实例中,再由集中等待。
epfd = epoll_create(EPOLL_SIZE);ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);
event.events = EPOLLIN;event.data.fd = serv_sock;epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);
while (1){ event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) break;
for (i = 0; i < event_cnt; i++) { int fd = ep_events[i].data.fd;
if (fd == serv_sock) { clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz);
event.events = EPOLLIN; event.data.fd = clnt_sock; epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event); } else { str_len = read(fd, buf, BUF_SIZE);
if (str_len == 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); } else write(fd, buf, str_len); } }}监听套接字发生可读事件,表示有新的连接请求;已连接套接字发生可读事件,则表示有数据到达或对方已经断开。客户端关闭连接时,应先从实例删除描述符,再调用释放套接字。
17.2 条件触发和边缘触发
支持条件触发(,)和边缘触发(,)。二者的核心区别不在于监视什么事件,而在于同一就绪状态会在什么时机被再次通知。
条件触发
条件触发模式下,只要输入缓冲中仍有数据,就会继续报告该套接字的可读事件。
假设输入缓冲收到字节,而程序每次只读取字节:第一次读取后仍剩字节,所以再次调用时,该描述符仍会被返回。直到输入缓冲被读空,事件通知才会停止。
默认使用条件触发,也具有相同的通知特点。它的优点是程序即使一次没有读完数据,之后仍会得到提醒,处理逻辑相对直观。
event.events = EPOLLIN; //默认条件触发event.data.fd = clnt_sock;epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);边缘触发
边缘触发只在文件描述符的状态发生变化时通知一次。例如,输入缓冲从“无数据”变为“有数据”时产生事件;程序若只读取一部分,缓冲中虽然仍有旧数据,但状态没有再次发生变化,因此不会仅因这些剩余数据而重复通知。
event.events = EPOLLIN | EPOLLET;event.data.fd = clnt_sock;epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);这意味着边缘触发不能沿用“每次事件只读一次”的处理方式。若一次没有读完,残留数据可能长时间得不到处理。正确做法是在事件到来后循环读取,直到确认输入缓冲已经为空。
将套接字设为非阻塞模式
边缘触发需要持续读取到“当前没有更多数据”为止。如果套接字仍处于阻塞模式,最后一次可能在数据读完后继续等待,从而让整个事件循环停住。因此,边缘触发通常要配合非阻塞套接字。
可以通过读取原有属性,再添加:
#include <fcntl.h>
void setnonblockingmode(int fd){ int flag = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flag | O_NONBLOCK);}新连接建立后,先把客户端套接字设为非阻塞,再以边缘触发方式注册:
clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &adr_sz);
setnonblockingmode(clnt_sock);
event.events = EPOLLIN | EPOLLET;event.data.fd = clnt_sock;epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);利用errno判断是否读取完毕
非阻塞在当前没有数据可读时返回,并把设为。边缘触发服务器可以利用这一点判断输入缓冲已经被读空:
#include <errno.h>
while (1){ str_len = read(fd, buf, BUF_SIZE);
if (str_len > 0) { write(fd, buf, str_len); } else if (str_len == 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } else { if (errno == EAGAIN) break;
/* 处理其他读取错误 */ break; }}三种返回情况不能混为一谈:返回正数表示读到数据,返回表示对方关闭连接,返回且为才表示本轮数据已经读完、以后仍可继续等待新事件。
WARNING边缘触发的重点不是简单添加。若没有同时使用非阻塞并把当前数据读到,程序很容易遗漏缓冲中的剩余内容,或者因阻塞调用停住整个服务器。
边缘触发的应用意义
边缘触发减少了同一就绪状态的重复通知,也让程序更容易把“接收数据”和“处理数据”拆开。事件到来时,可以先把内核输入缓冲中的数据完整读入应用层缓冲,再根据业务需要排序、组合或稍后处理。

图中客户端、、可能以任意顺序发送数据,服务器可以先接收并保存它们,再按、、的顺序组合后转发。网络数据到达的时间与业务处理的时间不必完全一致。
若在条件触发模式下长期不读取内核缓冲中的数据,会反复返回同一可读事件;边缘触发则更适合先把数据从内核缓冲转移出来,再由程序自行决定处理时机。不过,这也把“必须读完当前数据”和管理应用层缓冲的责任交给了程序。
边缘触发更有机会减少无效唤醒并构建高性能事件循环,但不能简单理解为“边缘触发一定比条件触发快”。条件触发更容易编写和验证,边缘触发则提供更细的事件控制。最终仍应根据程序结构、正确性要求和实际负载选择。
