IO复用:epoll
时间复杂度优秀select/poll:每次调用都需要把整个关注的文件描述符集合(fd set)从用户态拷贝到内核态,并且内核需要线性扫描整个集合来找出哪些 fd 就绪,时间复杂度是 O(n)。epoll:只需要在初次注册兴趣事件时(epoll_ctl)拷贝一次,之后每次 epoll_wait 调用时,内核直接返回已经就绪的事件列表,时间复杂度接近 O(1)(实际上是 O(就绪事件数量)),不需要再
一、epoll含义
epoll 是 Linux 特有的高性能 I/O 事件通知机制,主要用来替代传统的 select/poll,尤其在连接数非常多时性能优势极其明显,被几乎所有高并发网络服务器广泛使用。
二、为什么需要epoll?
| 机制 | 最大描述符数限制 | 每次调用时间复杂度 | 是否需要每次都拷贝 fd 集合到内核 | 返回后是否需要遍历全部 fd |
|---|---|---|---|---|
| select | 通常 1024(可改) | O(n) | 是(fd_set 拷贝) | 是(O(n) 轮询) |
| poll | 无硬性限制 | O(n) | 是(pollfd 数组拷贝) | 是(O(n) 轮询) |
| epoll | 无硬性限制 | O(1) 注册,O(k) 等待(k 为就绪事件数) | 只需要注册一次 |
只需要遍历就绪的 k 个 |
三、epoll的三种核心系统调用
// 1. 创建一个 epoll 实例(返回一个 epoll 文件描述符)
int epoll_create(int size); // size 只是提示,内核现在忽略它
int epoll_create1(int flags); // 推荐使用,flags 可设 EPOLL_CLOEXEC
// 2. 往 epoll 实例中添加/修改/删除感兴趣的文件描述符和事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
op 可取值:
EPOLL_CTL_ADD // 注册
EPOLL_CTL_MOD // 修改已注册 fd 的事件
EPOLL_CTL_DEL // 删除(event 可为 NULL)
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;
// 常用 events 标志(可 | 组合)
EPOLLIN // 可读(包括对端正常关闭)
EPOLLOUT // 可写
EPOLLERR // 错误
EPOLLHUP // 挂起(对端异常关闭)
EPOLLRDHUP // 半关闭(对端 shutdown WR 或关闭,Linux 2.6.17+)
EPOLLPRI // 带外数据
EPOLLONESHOT // 一旦触发后自动禁用,需重新 EPOLL_CTL_MOD 启用(防惊群)
EPOLLET // 边缘触发 Edge Triggered(关键!默认是水平触发 LT)
// 3. 等待事件发生(类似 select)
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
// timeout: -1 永久阻塞,0 立即返回,>0 毫秒
// 返回值:就绪的事件个数(0 表示超时)
四、两种工作模式
| 特性 | 水平触发 LT(默认) | 边缘触发 ET(需显式设置 EPOLLET) |
|---|---|---|
| 触发条件 | 只要缓冲区还有数据可读/可写就会一直通知 | 只有在状态变化时才通知一次(比如从不可读→可读) |
| 是否需要读完所有数据 | 不需要,读一部分下次还会再通知 | 必须一次读完(用非阻塞 + 循环读到 EAGAIN 为止) |
| 编程难度 | 简单,和 select/poll 几乎一样 | 复杂,容易漏事件,必须配合非阻塞 I/O |
| 性能 | 稍低(可能产生更多系统调用) | 更高(系统调用次数最少) |
| 典型使用场景 | 初学者、逻辑简单、少量连接 | 高并发服务器(Nginx、Redis 都是 ET 模式) |
ET 模式必备技巧:
把 socket 设置为非阻塞(fcntl(fd, F_SETFL, O_NONBLOCK))
读时循环 read/recv 直到返回 EAGAIN
写时同理(通常只有在 write 返回 EAGAIN 时才关注 EPOLLOUT)
五、高并发tcp服务器
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <errno.h>
#define MAXFD 10
int socket_init();
void setnonblock(int fd)
{
int oldfl = fcntl(fd,F_GETFL);
int newfl = oldfl | O_NONBLOCK;
if( fcntl(fd,F_SETFL,newfl) == -1)
{
printf("fcntl err\n");
}
}
void epoll_add(int epfd, int fd)
{
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN | EPOLLET;//开启ET模式
setnonblock(fd);//设置非阻塞
if( epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev) == -1)
{
printf("epoll add err\n");
}
}
void epoll_del(int epfd, int fd)
{
if( epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL) == -1)
{
printf("epoll del err\n");
}
}
void accept_client(int sockfd,int epfd)
{
int c = accept(sockfd,NULL,NULL);
if( c < 0 )
{
return;
}
printf("accept c=%d\n",c);
epoll_add(epfd,c);//把新接受的连接添加到内核事件表(红黑树)
}
void recv_data(int c,int epfd)
{
while( 1 )
{
char buff[128] = {0};
int n = recv(c,buff,1,0);
if( n == -1 )
{
if( errno != EAGAIN && errno != EWOULDBLOCK )
{
printf("recv err");
}
else
{
send(c,"ok",2,0);
}
break;
}
else if( n == 0 )
{
epoll_del(epfd,c);
close(c);
printf("client close\n");
break;
}
else
{
printf("buff=%s\n",buff);
}
}
}
int main()
{
int sockfd = socket_init();
if( sockfd == -1)
{
exit(1);
}
int epfd = epoll_create(MAXFD);//创建内核事件表(收集描述符) 红黑树
if( epfd == -1)
{
exit(1);
}
epoll_add(epfd,sockfd);//将监听套接字sockfd添加到内核事件表
struct epoll_event evs[MAXFD];//存放就绪描述符
while(1)
{
int n = epoll_wait(epfd,evs,MAXFD,5000);//获取就绪描述符 可能阻塞
printf("------wait----\n");
if( n == -1 )
{
printf("epoll err\n");
}
else if ( n == 0 )
{
printf("time out\n");
}
else
{
for(int i = 0; i < n; i++)
{
int fd = evs[i].data.fd;
if( evs[i].events & EPOLLIN)
{
if( fd == sockfd )
{
accept_client(sockfd,epfd);
}
else
{
recv_data(fd,epfd);
}
}
//if( evs[i].events & EPOLLOUT)
}
}
}
}
int socket_init()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1)
{
return -1;
}
struct sockaddr_in saddr;
memset(&saddr, 0, sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr));
if (res == -1)
{
printf("bind err\n");
return -1;
}
res = listen(sockfd, 5);
if (res == -1)
{
return -1;
}
return sockfd;
}
六、epoll内部
红黑树:管理所有被监听的 fd(epoll_ctl 的 O(log n) → 实际几乎 O(1))
rdlist(就绪链表):双向链表,存放已经就绪的 fd,epoll_wait 直接返回这个链表(O(1))
回调机制:当 fd 状态变化时,内核调用 ep_insert 把 fd 插入 rdlist(这就是为什么 ET 只需要通知一次)
七、总结
时间复杂度优秀
select/poll:每次调用都需要把整个关注的文件描述符集合(fd set)从用户态拷贝到内核态,并且内核需要线性扫描整个集合来找出哪些 fd 就绪,时间复杂度是 O(n)。
epoll:只需要在初次注册兴趣事件时(epoll_ctl)拷贝一次,之后每次 epoll_wait 调用时,内核直接返回已经就绪的事件列表,时间复杂度接近 O(1)(实际上是 O(就绪事件数量)),不需要再扫描全部 fd。
文件描述符数量限制极高
select:受限于 FD_SETSIZE(通常是 1024),并且 fd 必须是低位编号。
poll:理论上没有 fd 个数限制,但内部用数组线性存储,fd 越多内存和扫描开销越大。
epoll:使用红黑树 + 就绪链表(或回调)结构管理关注的 fd,单个 epoll 实例可以轻松支持数十万甚至上百万个连接(只受系统内存和 max user watches 限制)。
无需每次都重新传入感兴趣的 fd 集合
select/poll:每次调用都要把整个 fd 集合和感兴趣的事件重新传给内核(即使大部分都没变化)。
epoll:通过 epoll_ctl 一次性注册/修改/删除感兴趣的事件,后续 epoll_wait 只负责取结果,极大减少系统调用和数据拷贝开销。
支持两种高效触发模式
水平触发(LT):默认模式,只要 fd 可读/可写就会一直通知,和 poll 行为类似,适合大多数场景。
边缘触发(ET):只有状态发生变化时才通知一次,能极大减少事件通知次数(高并发服务器常用),配合非阻塞 I/O 可以达到最高性能。
事件就绪通知机制更高效(内核回调 + 链表)
当某个 fd 就绪时,内核会直接把它挂到 epoll 实例的就绪链表(rdlist)上,epoll_wait 只需要把这条链表 splice 到用户空间即可,几乎没有额外开销。
支持 EPOLLONESHOT 和线程安全
EPOLLONESHOT:可以让某个 fd 在被某个线程处理后自动禁用,避免多个线程同时处理同一个 socket 的“惊群”问题。
多个线程/进程可以安全地对同一个 epoll 实例调用 epoll_wait(底层用了 mutex + wait queue)。
内存拷贝开销极低
select/poll 每次都要把几千甚至几万个 fd 的 bitmap/数组拷贝来拷贝去,而 epoll 只需要在注册时拷贝一次 struct epoll_event,之后只拷贝真正就绪的那几个。
更多推荐

所有评论(0)