epoll概述与优势
epoll 是 Linux 内核提供的高性能 I/O 多路复用机制,从 Linux 2.5.44 开始引入,专为处理大规模并发连接而设计。与传统的 select 和 poll 不同,epoll 采用事件驱动模型,仅在就绪的文件描述符上触发通知,避免了遍历全部描述符的开销。
为什么需要epoll
- 描述符数量限制:select 默认最多监控 1024 个描述符(FD_SETSIZE)
- 线性扫描:select/poll 每次调用需遍历全部描述符,O(n) 复杂度
- 数据拷贝:select/poll 每次调用需将 fd_set 从用户态拷贝到内核态
epoll 的核心优势:无数量限制(仅受系统内存约束)、事件通知(O(1) 获取就绪事件)、共享红黑树与就绪链表避免重复拷贝。
epoll API接口
epoll_create - 创建epoll实例
#include <sys/epoll.h>
/* 创建epoll实例,size仅作提示,内核动态调整 */
int epfd = epoll_create(1024);
if (epfd == -1) { perror("epoll_create"); return -1; }
/* epoll_create1 支持更多标志 */
int epfd2 = epoll_create1(EPOLL_CLOEXEC); /* exec时自动关闭 */
epoll_ctl - 控制事件注册
struct epoll_event ev;
ev.events = EPOLLIN; /* 关注可读事件 */
ev.data.fd = sockfd;
/* 添加 / 修改 / 删除 */
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd, &ev);
epoll_ctl(epfd, EPOLL_CTL_DEL, sockfd, NULL);
常用事件类型:EPOLLIN(可读)、EPOLLOUT(可写)、EPOLLRDHUP(对端半关闭)、EPOLLERR(错误,始终监控)、EPOLLHUP(挂起,始终监控)、EPOLLET(启用ET模式)、EPOLLONESHOT(只通知一次)。
epoll_event 结构体
struct epoll_event {
uint32_t events; /* epoll事件掩码 */
epoll_data_t data; /* 用户数据 */
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
epoll_wait - 等待事件就绪
#define MAX_EVENTS 64
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
if (errno == EINTR) goto retry;
perror("epoll_wait");
}
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd)
/* 处理新连接 */;
else
/* 处理数据 */;
}
timeout 参数:-1 阻塞等待、0 非阻塞立即返回、>0 等待指定毫秒。
LT水平触发模式
LT(Level Triggered)是 epoll 的默认模式。只要描述符上有数据可读,epoll_wait 就会持续通知,直到所有数据被处理完毕。
- 持续通知:条件满足时每次
epoll_wait都返回该事件 - 编程简单:可分批处理数据,不必一次读完
- 兼容性好:与 select/poll 行为一致
/* LT模式:可以只读一部分数据 */
void handle_read_lt(int fd) {
char buf[128];
ssize_t n = read(fd, buf, sizeof(buf));
if (n <= 0) { close(fd); return; }
/* 只读了128字节,若缓冲区还有数据,
下次 epoll_wait 仍会通知 EPOLLIN */
write(fd, buf, n);
}
ET边沿触发模式
ET(Edge Triggered)模式只在描述符状态变化时通知一次。如果一次没有读完所有数据,epoll 不会再次通知,直到新的数据到达。
- 仅通知一次:状态变化时触发,之后不再重复
- 必须读完:需循环读取直到
EAGAIN - 非阻塞必须:描述符必须设为非阻塞,否则读空后会阻塞
- 性能更优:减少内核到用户的通知次数
#include <fcntl.h>
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) return -1;
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
/* ET模式:必须循环读取直到 EAGAIN */
void handle_read_et(int fd) {
char buf[512];
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
write(fd, buf, n);
} else if (n == 0) {
close(fd); return;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; /* 数据已全部读完 */
close(fd); return;
}
}
}
LT与ET核心差异:LT重复通知直到数据读完,ET仅通知一次;LT可分批处理,ET必须一次读完;ET必须配合非阻塞I/O;ET减少通知次数,高并发下更高效。
epoll与select/poll对比
- 最大描述符数:select 1024(可改需重编译);poll/epoll 无限制
- 时间复杂度:select/poll O(n);epoll O(1)
- 内存拷贝:select/poll 每次全量拷贝;epoll 仅首次注册时拷贝
- 触发模式:select/poll 仅 LT;epoll 支持 LT 和 ET
- 跨平台:select 跨平台;poll 大多数平台;epoll 仅 Linux
在 10000 个并发连接、仅 10 个活跃的场景下,select/poll 需遍历全部描述符,epoll 仅处理 10 个就绪事件,性能优势明显。select 仍适合跨平台、低并发、高精度超时场景。
实战:高性能回显服务器
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define MAX_EVENTS 1024
#define BUF_SIZE 4096
static volatile int running = 1;
static void sig_handler(int sig) { running = 0; }
static int set_nonblocking(int fd) {
int fl = fcntl(fd, F_GETFL, 0);
return (fl < 0) ? -1 : fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
static void handle_accept(int epfd, int lfd) {
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int cfd = accept(lfd, (struct sockaddr *)&cli, &len);
if (cfd < 0) {
if (errno == EAGAIN) break;
break;
}
set_nonblocking(cfd);
struct epoll_event ev = {
.events = EPOLLIN | EPOLLET,
.data.fd = cfd
};
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
}
}
static void handle_client(int epfd, int fd) {
char buf[BUF_SIZE];
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
write(fd, buf, n); /* 回显 */
} else if (n == 0) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd); return;
} else {
if (errno == EAGAIN) break;
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd); return;
}
}
}
int main(void) {
signal(SIGPIPE, SIG_IGN);
signal(SIGINT, sig_handler);
int lfd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(PORT),
.sin_addr.s_addr = htonl(INADDR_ANY)
};
bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
listen(lfd, 128);
set_nonblocking(lfd);
printf("Echo server on port %d (epoll ET)\n", PORT);
int epfd = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN | EPOLLET, .data.fd = lfd };
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[MAX_EVENTS];
while (running) {
int nfds = epoll_wait(epfd, events, MAX_EVENTS, 1000);
if (nfds < 0) { if (errno == EINTR) continue; break; }
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == lfd)
handle_accept(epfd, lfd);
else if (events[i].events & (EPOLLERR | EPOLLHUP))
close(events[i].data.fd);
else if (events[i].events & EPOLLIN)
handle_client(epfd, events[i].data.fd);
}
}
close(lfd); close(epfd);
return 0;
}
# 编译与测试
gcc -o echo_server echo_server.c
./echo_server
nc 127.0.0.1 8888
总结
- epoll_create/epoll_ctl/epoll_wait - 三大核心接口,创建实例、注册事件、等待就绪
- LT模式 - 默认模式,持续通知直到条件不满足,编程简单安全
- ET模式 - 高性能模式,仅通知一次状态变化,必须配合非阻塞I/O和循环读取
- 性能优势 - O(1) 事件获取、无描述符数量限制、零拷贝设计
- 实战要点 - ET模式下 accept/read 都需循环处理直到 EAGAIN,正确处理 EPOLLERR/EPOLLHUP
epoll 是 Linux 网络编程的基石技术,Nginx、Redis 等高性能服务器均基于 epoll 构建。掌握 LT/ET 模式的差异和非阻塞I/O的配合使用,是编写高性能网络程序的关键。建议从 LT 模式入手,理解 epoll 机制后再切换到 ET 模式以获得更优性能。