epoll
是 Linux 下提供的一种高效的 I/O 事件通知机制,全称是 event poll,用于处理大量文件描述符的可读写事件,是 select
和 poll
的现代替代品。
它由以下三个系统调用构成:
-
epoll_create()
:创建一个 epoll 实例; -
epoll_ctl()
:注册、修改、删除感兴趣的文件描述符; -
epoll_wait()
:等待发生事件的文件描述符。
epoll 支持 “水平触发(LT)” 和 “边缘触发(ET)” 两种模式,更灵活高效。
epoll 相对于 select 的优势
特性 | select | epoll |
---|---|---|
支持文件描述符数量 | 受限(默认 1024,上限受 FD_SETSIZE 限制) | 理论支持上万(只受系统最大 fd 限制) |
事件检测机制 | 每次调用都要遍历所有 fd,开销大 | 只关心真正发生事件的 fd |
内核与用户空间通信方式 | 每次调用都复制整个 fd 集合 | 使用内核事件就绪列表,只拷贝活跃 fd |
是否边缘触发支持 | 不支持 | 支持 |
是否支持回调通知机制 | 不支持 | 支持(通过 EPOLLONESHOT , EPOLLET 等实现更强逻辑) |
性能 | 随 fd 数量线性下降 | 与 fd 数量无关,表现更优(特别是高并发) |
那到底哪些因素是提高性能的更大障碍?是调用select函数后常见的针对所有文件描述符对象的循环语句?还是每次需要传递的监视对象信息?
只看代码的话很容易认为是循环。但相比于循环语句,更大的障碍是每次传递监视对象信息。因为传递监视对象信息具有如下含义∶
"每次调用select函数时向操作系统传递监视对象信息。"
应用程序向操作系统传递数据将对程序造成很大负担,而且无法通过优化代码解决,因此将成为性能上的致命弱点。
"那为何需要把监视对象信息传递给操作系统呢?"
有些函数不需要操作系统的帮助就能完成功能,而有些则必须借助于操作系统。假设各位定义了四则运算相关函数,此时无需操作系统的帮助。但select函数与文件描述符有关,更准确地说,是监视套接字变化的函数。而套接字是由操作系统管理的,所以select函数绝对需要借助于操作系统才能完成功能。
select函数的这一缺点可以通过这种方式弥补∶
"向操作系统传递1次监视对象,监视范围或内容发生变化只通知发生变化的事项。"
这样就无需每次调用select函数时都向操作系统传递监视对象信息,但前提是操作系统支持这种处理方式(每种操作系统支持的程度和方式存在差异)。Linux的支持方式是epoll,Windows 的支持方式是IOCP。
epoll_create
/ epoll_create1
#include<sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags); // 推荐使用这个
参数:
-
size
(已废弃,仅用于兼容,epoll实例的大小) -
flags
:可设置为0
或EPOLL_CLOEXEC
(fork 后自动关闭)
返回值:
-
成功:返回一个新的 epoll 文件描述符(epfd)
-
失败:返回
-1
,并设置errno
功能:
创建一个 epoll 实例,相当于创建了一个“事件监听器”。后续所有的监听 fd 都要注册到它上面。
你可以把它理解成一个 内核中的“事件集合”管理器的句柄。它本质上是:
一个由内核维护的,保存你关注的 I/O 事件(比如哪些 socket 需要读取)的数据结构的“入口”。
epoll_ctl
#include<sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
参数:
-
epfd
:通过epoll_create
创建的 epoll 文件描述符 -
op
:操作类型,取值如下:-
EPOLL_CTL_ADD
:注册新的 fd 到 epoll -
EPOLL_CTL_MOD
:修改已注册 fd 的监听事件 -
EPOLL_CTL_DEL
:从 epoll 中删除一个 fd
-
-
fd
:要操作的目标文件描述符 -
event
:监听的事件结构体(如果是DEL
操作,可以为 NULL)
struct epoll_event
结构体:
struct epoll_event {
uint32_t events; // 要监听的事件类型
epoll_data_t data; // 用户数据(比如可以放 fd)
};
typedef union epoll_data {
void *ptr;
int fd;//这里设置套接字
uint32_t u32;
uint64_t u64;
} epoll_data_t;
常用 events
类型:
事件 | 含义 | 应用场景 |
---|---|---|
EPOLLIN | 表示对应的文件描述符可读(包括普通读、对端关闭、优先数据等) | 监听 socket 是否有新数据到达 |
EPOLLOUT | 表示对应的文件描述符可写 | 检测发送缓冲区是否可写,例如延迟发送的场景 |
EPOLLPRI | 有紧急数据可读(带外数据) | 用于如串口、OOB(Out-Of-Band)通信 |
EPOLLRDHUP | 连接被对端关闭,或者半关闭(FIN) | 非常适合在边缘触发下判断连接是否断开 |
EPOLLERR | 发生错误(一般与 EPOLLIN/EPOLLOUT 一起返回) | 出错时用于辅助诊断,如 socket 出错 |
EPOLLHUP | 对端挂断 | 常用于检测 socket 的异常断开 |
EPOLLET | 边缘触发(Edge Triggered) | 高性能场景下使用,必须配合非阻塞 I/O |
EPOLLONESHOT | 只触发一次事件 | 防止多线程同时处理同一个 socket,需要手动 epoll_ctl(..., EPOLL_CTL_MOD) 重设 |
EPOLLEXCLUSIVE (Linux 4.5+) | 多个 epoll 实例监听同一个 fd 时,只唤醒一个 | 高性能多线程服务器优化用 |
EPOLLWAKEUP (Linux 3.5+) | 防止系统 suspend(需权限) | 保证 epoll 事件可以唤醒系统 |
EPOLLMSG (保留) | 目前没实际用处 | 为 future 保留,使用无效 |
特别说明:
-
EPOLLIN
和EPOLLOUT
是最基础的 I/O 事件。 -
EPOLLRDHUP
是一个 很实用 的扩展,在 边缘触发模式下判断连接关闭非常重要。 -
EPOLLONESHOT
常用于多线程网络服务中,防止重复处理。 -
EPOLLET
可减少系统调用次数,提高性能,但需要小心使用,必须完全读取或写入,否则可能收不到下一次事件。 -
EPOLLERR
和EPOLLHUP
总是会触发,即使你没有监听它们,但需要你自己读取后处理。
返回值:
-
成功:返回 0
-
失败:返回
-1
,并设置errno
功能:
管理 epoll 实例中要监听的文件描述符。可用于添加、修改或删除监听对象。
epoll_wait
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event*events,int maxevents,int timeout);
参数:
-
epfd
:epoll 文件描述符 -
events
:一个数组,用于接收就绪事件(由内核写入) -
maxevents
:events
数组的大小(>0) -
timeout
:超时时间(毫秒)-
-1
:无限等待 -
0
:立即返回(非阻塞) -
!=0:等待指定毫秒时间
-
返回值:
-
成功:返回就绪事件的数量(即
events[0...n-1]
是就绪事件) -
失败:返回
-1
,并设置errno
功能:
等待监听的 fd 中有事件发生,相当于在“监听所有注册过的事件”,并将结果写入传入的 events
数组中。
epoll 返回的事件可能同时包括读和写,需要自己通过位运算检测。
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; ++i) {
uint32_t ev = events[i].events;
if (ev & EPOLLIN) { printf("fd %d 可读\n", events[i].data.fd); }
if (ev & EPOLLOUT) { printf("fd %d 可写\n", events[i].data.fd); }
}
示例程序
服务器端:
#include <cstdio>
#include<iostream>
#include<cstring>
#include<unistd.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<sys/socket.h>
#include <sys/epoll.h>
#define BUF_SIZE 1024
#define EPOLL_SIZE 50
void error_handling(int res, const char* message) {//错误处理函数
if (res == -1) {
std::cerr << message << std::endl;
exit(EXIT_FAILURE);
}
}
int main(int argc, char* argv[]) {
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(EXIT_FAILURE);
}
int serv_sock, clnt_sock;
sockaddr_in serv_adr, clnt_adr;
socklen_t clnt_adr_sz;
int str_len;
char buf[BUF_SIZE];
epoll_event* ep_events;
epoll_event event;
int epfd, event_cnt;
serv_sock = socket(AF_INET, SOCK_STREAM, 0);
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = INADDR_ANY;
serv_adr.sin_port = htons(atoi(argv[1]));
error_handling(bind(serv_sock,reinterpret_cast<sockaddr*>(&serv_adr),sizeof(serv_adr)),"bind");
error_handling(listen(serv_sock,5),"listen");
epfd = epoll_create1(0);
error_handling(epfd, "epoll create");
ep_events = new epoll_event[EPOLL_SIZE];
event.events = EPOLLIN;//默认是设置条件触发的方式
event.data.fd = serv_sock;
error_handling(epoll_ctl(epfd,EPOLL_CTL_ADD,serv_sock,&event),"epoll control");
while (1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE,-1);
error_handling(event_cnt, "epoll wait");
for (int i = 0; i < event_cnt; i++) {
if (ep_events[i].data.fd == serv_sock) {//处理服务器套接字
clnt_adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, reinterpret_cast<sockaddr*>(&clnt_adr), &clnt_adr_sz);
error_handling(clnt_sock, "accept");
event.events = EPOLLIN;
event.data.fd = clnt_sock;
error_handling(epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event),"epoll control");
printf("connected client: %d \n", clnt_sock);
}
else {//处理客户端
memset(buf, 0, BUF_SIZE);
str_len = recv(ep_events[i].data.fd, buf, BUF_SIZE,0);
if (str_len == 0) // close request!
{
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("closed client: %d \n", ep_events[i].data.fd);
}
else
{
send(ep_events[i].data.fd, buf, str_len,0); // echo!
}
}
}
}
close(serv_sock);
close(epfd);
delete[] ep_events;
return 0;
}
客户端:
#include <cstdio>
#include<cstdlib>
#include<iostream>
#include<cstring>
#include<unistd.h>
#include<sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define BUF_SIZE 1024
void error_handling(int res, const char* message) {
if (res == -1) {
std::cerr << message << std::endl;
exit(EXIT_FAILURE);
}
}
int main(int argc, char* argv[])
{
int sock;
char message[BUF_SIZE];
int str_len;
sockaddr_in serv_adr;
if (argc != 3) {//指定服务器ip和端口
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(EXIT_FAILURE);
}
sock = socket(PF_INET, SOCK_STREAM, 0);
error_handling(sock, "socket");
serv_adr.sin_family = AF_INET;
inet_pton(AF_INET, argv[1], &serv_adr.sin_addr);//ip
serv_adr.sin_port = htons(atoi(argv[2]));//端口
error_handling(connect(sock, reinterpret_cast<sockaddr*>(&serv_adr), sizeof(serv_adr)), "connect");
std::cout << "Connected......" << std::endl;
while (1) {
memset(message, 0, BUF_SIZE);
std::cout << "Input message(Q to quit):" << std::endl;
std::cin >> message;
if (strcmp(message, "Q") == 0) break;//退出
send(sock, message, strlen(message) + 1, 0);//发送
memset(message, 0, BUF_SIZE);
str_len = recv(sock, message, BUF_SIZE, 0);
std::cout << "Message from server:" << message << std::endl;
}
close(sock);
}
epoll 和 select 在代码结构上非常相似 —— 都是“注册关注的事件”,然后 “等待事件发生”,接着 “遍历并处理事件”。
但 epoll 相比 select 更强大的地方,并不体现在代码结构,而是在 内核实现机制和性能扩展性上。
简单例子说明
假设你要监听 10,000 个客户端 socket:
select
-
每次
select()
都要把 10,000 个 fd 全部拷贝到内核,再线性扫描一次。 -
即使只有 1 个 fd 就绪,系统也必须检查全部。
-
CPU 时间白白浪费。
epoll
-
你只需通过
epoll_ctl
注册 10,000 个 fd 一次。 -
之后
epoll_wait
只返回真正 “发生事件” 的那几个。 -
这使得在高并发场景下效率非常高。
水平触发和边缘触发
水平触发/条件触发(LT)
-
默认行为,类似
select
/poll
-
只要缓冲区中还有数据/空间,就会不断通知你
-
你可以“慢慢处理”数据,不用一次读完写完
举例:
-
你注册了一个
EPOLLIN
的 socket。 -
socket 收到数据 -> 内核通知你。
-
你读了一部分,缓冲区还有数据。
-
下次
epoll_wait()
,它会继续通知你这个 socket 还可读,直到你把它读空为止。
特点:
-
稳妥、简单、易用。
-
不容易漏掉事件。
-
性能一般,因为每次都可能通知你旧的事件。
边缘触发(ET)
-
使用
EPOLLET
标志启用。 -
只在状态发生“变化”的“边缘”时通知一次
-
必须一次性处理干净缓冲区的数据(读光/写满)
举例:
-
socket 收到数据 -> 通知你一次。
-
如果你没一次性读完,下次不会再提醒你,除非新数据再次到来。
特点:
-
更高性能,适合高并发服务器。
-
但必须用 非阻塞IO + 循环读/写,一次性处理干净,不然容易遗漏数据(陷入“死等”)。
-
易错但高效。
如果你追求极致性能(如 10w+ 并发连接),可以尝试 ET 模式 + 非阻塞IO,同时确保:
-
每次
recv()
时循环读取直到返回EAGAIN
-
每次
send()
尝试写满直到EAGAIN
示例程序
我们原本的示例程序是默认水平触发模式。以下是边缘触发模式的服务器代码:
#include <cstdio>
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <cerrno>
#define BUF_SIZE 1024
#define EPOLL_SIZE 50
void error_handling(int res, const char* message) {
if (res == -1) {
perror(message);
exit(EXIT_FAILURE);
}
}
void set_non_blocking(int fd) {//将套接字设置为非阻塞模式
int flags = fcntl(fd, F_GETFL, 0);//系统调用的文章有讲过该函数
error_handling(flags, "fcntl F_GETFL");
error_handling(fcntl(fd, F_SETFL, flags | O_NONBLOCK), "fcntl F_SETFL");
}
int main(int argc, char* argv[]) {
if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
return -1;
}
int serv_sock, clnt_sock;
sockaddr_in serv_adr{}, clnt_adr{};
socklen_t clnt_adr_sz;
char buf[BUF_SIZE];
epoll_event* ep_events;
epoll_event event;
int epfd, event_cnt;
serv_sock = socket(AF_INET, SOCK_STREAM, 0);
error_handling(serv_sock, "socket");
set_non_blocking(serv_sock); // 设置非阻塞
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = INADDR_ANY;
serv_adr.sin_port = htons(atoi(argv[1]));
error_handling(bind(serv_sock, reinterpret_cast<sockaddr*>(&serv_adr), sizeof(serv_adr)), "bind");
error_handling(listen(serv_sock, 5), "listen");
epfd = epoll_create1(0);
error_handling(epfd, "epoll_create1");
ep_events = new epoll_event[EPOLL_SIZE];
event.events = EPOLLIN | EPOLLET;
event.data.fd = serv_sock;
error_handling(epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event), "epoll_ctl add server");
while (true) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
error_handling(event_cnt, "epoll_wait");
for (int i = 0; i < event_cnt; i++) {
int fd = ep_events[i].data.fd;
if (fd == serv_sock) {
// 接收所有连接
while (true) {
clnt_adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, reinterpret_cast<sockaddr*>(&clnt_adr), &clnt_adr_sz);//服务端套接字的accept也是非阻塞模式了
if (clnt_sock == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
else {
perror("accept");
break;
}
}
set_non_blocking(clnt_sock);//将与客户端通信的套接字设为非阻塞,这样recv和send也是非阻塞的
event.events = EPOLLIN | EPOLLET;
event.data.fd = clnt_sock;
error_handling(epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event), "epoll_ctl add client");
printf("Connected client: %d\n", clnt_sock);
}
} else {
// 循环读取直到没有数据
while (true) {
int str_len = recv(fd, buf, BUF_SIZE, 0);
if (str_len == 0) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
printf("Closed client: %d\n", fd);
break;
} else if (str_len < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
else {
perror("recv");
close(fd);
break;
}
} else {
send(fd, buf, str_len, 0); // echo
}
}
}
}
}
close(serv_sock);
close(epfd);
delete[] ep_events;
return 0;
}
套接字设置为非阻塞的效果是什么?
阻塞 vs 非阻塞:
-
阻塞模式(默认):
如果你调用了recv()
、accept()
等函数,但此时没有数据或连接,这个函数会卡在那里等,直到有结果。 -
非阻塞模式(O_NONBLOCK):
函数立即返回,如果此时没有数据或连接,就返回 -1,并设置errno
为EAGAIN
或EWOULDBLOCK
。
在 Linux 下,一个套接字被设置为非阻塞(通过 fcntl(fd, F_SETFL, O_NONBLOCK)
),所有对这个 fd 的系统调用都会变为非阻塞,包括:
-
accept()
-
recv()
-
send()
-
connect()
(客户端)
errno 的 EAGAIN 和 EWOULDBLOCK 是什么意思?
这两个值其实本质上是 一样的,只是出现在不同的历史环境中。
错误码 | 意义 |
---|---|
EAGAIN | 暂时没有可用数据,现在不能进行操作 |
EWOULDBLOCK | 非阻塞操作暂时不能完成 |
在 Linux 上:
#define EAGAIN 11
#define EWOULDBLOCK EAGAIN