17、epoll模型(Linux):epoll_create/epoll_ctl/epoll_wait详解、ET与LT模式
说到Linux高性能网络编程,绕不开的就是epoll。我刚开始接触网络编程时,用的还是select和poll,后来项目压力上来,才发现那俩家伙在高并发下有多吃力。epoll的出现,说白了就是Linux给咱们开的一扇高性能大门。
今天咱们就把epoll的三个核心函数——epoll_create、epoll_ctl、epoll_wait——掰开揉碎了讲清楚。顺便把ET和LT这两种模式的区别也聊透。嗯,这里要注意,理解ET和LT是区分新手和老手的分水岭。
epoll的整体架构
先看一张图,帮你建立整体认知。epoll的工作流程其实很清晰:
epoll_create:创建epoll实例
这个函数是起点。你调用它,内核就给你创建一个epoll实例,返回一个文件描述符。
#include <sys/epoll.h>
int epoll_create(int size);
// 返回:成功返回fd,失败返回-1并设置errno
参数size在Linux 2.6.8之后其实被忽略了,内核会动态调整。但我个人习惯还是传一个合理的值,比如你预计要管理1024个连接,就传1024。这算是个好习惯,万一哪天换到老内核呢?
epoll_ctl:控制事件
这个函数用来往epoll实例里添加、修改或删除你要监控的文件描述符。说白了,就是告诉内核:"你帮我盯着这个socket,有数据来了通知我。"
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 返回:成功返回0,失败返回-1
op参数有三个选项:
| 操作 | 含义 | 使用场景 |
|---|---|---|
| EPOLL_CTL_ADD | 添加一个新的fd到epoll | 新连接接入时 |
| EPOLL_CTL_MOD | 修改已注册fd的事件 | 改变监听的事件类型 |
| EPOLL_CTL_DEL | 从epoll中删除一个fd | 连接关闭时 |
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:可读事件。有数据来了。EPOLLOUT:可写事件。缓冲区可写。EPOLLERR:错误事件。EPOLLHUP:挂起事件。对端关闭连接。EPOLLET:设置为边缘触发模式。这个后面重点讲。EPOLLONESHOT:只触发一次,之后需要重新注册。
epoll_wait:等待事件
这是epoll的核心。调用它,线程就阻塞在这里,直到有事件发生或者超时。
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
// 返回:就绪的事件数量,0表示超时,-1表示出错
参数说明:
events:传出参数,内核把就绪的事件填到这里。maxevents:最多返回多少个事件。通常等于events数组的大小。timeout:超时时间,毫秒。-1表示一直等,0表示立即返回。
典型的用法是循环调用:
struct epoll_event events[1024];
int nfds = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < nfds; i++) {
// events[i].data.fd 就是有事件的socket
// events[i].events 就是事件类型
handle_event(events[i]);
}
LT模式 vs ET模式
终于到了重头戏。LT和ET是epoll的两种触发模式,理解它们,才算真正会用epoll。
LT模式(水平触发)
LT是默认模式。它的特点是:只要fd上有数据没读完,每次调用epoll_wait都会返回这个fd。
举个例子:socket缓冲区来了100字节,你只读了50字节。下次epoll_wait还会告诉你这个fd可读。说白了,就是"只要还有活没干完,我就一直提醒你"。
LT模式的优点:编程简单,不容易漏事件。缺点:可能会重复通知,效率略低。
ET模式(边缘触发)
ET模式需要你在epoll_ctl时加上EPOLLET标志。它的特点是:只在状态变化时通知一次。
还是那个例子:缓冲区来了100字节,你只读了50字节。下次epoll_wait不会再通知你,除非又有新的数据到达。
ET模式要求你:必须一次性把数据读完,或者写到缓冲区满为止。否则就会丢事件。
- 读操作:循环调用read/write,直到返回EAGAIN(表示暂时没数据了)。
- 写操作:同样循环,直到EAGAIN或写完。
- socket必须设置为非阻塞。否则在ET模式下,最后一次read会阻塞住。
LT vs ET 对比
| 对比项 | LT模式 | ET模式 |
|---|---|---|
| 触发条件 | 只要缓冲区有数据 | 状态变化时(无→有) |
| 通知次数 | 可能多次通知 | 只通知一次 |
| 编程难度 | 简单,不易出错 | 较复杂,需循环读写 |
| 性能 | 稍低(重复通知) | 更高(减少系统调用) |
| socket要求 | 阻塞/非阻塞均可 | 必须非阻塞 |
| 适用场景 | 连接数较少,追求简单 | 高并发,追求极致性能 |
完整示例代码
下面是一个ET模式的echo服务器骨架。你想想看,这段代码里包含了我们今天讲的所有知识点:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MAX_EVENTS 1024
#define BUFFER_SIZE 4096
// 设置非阻塞
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
set_nonblocking(listen_fd);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8888);
addr.sin_addr.s_addr = INADDR_ANY;
bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(listen_fd, 128);
// 1. 创建epoll实例
int epfd = epoll_create(1);
// 2. 添加监听fd,使用ET模式
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[MAX_EVENTS];
char buffer[BUFFER_SIZE];
while (1) {
// 3. 等待事件
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
// 新连接,ET模式要循环accept
while (1) {
int conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd == -1) {
if (errno == EAGAIN) break; // 没连接了
break;
}
set_nonblocking(conn_fd);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
}
} else {
// 普通连接,ET模式要循环读
int fd = events[i].data.fd;
while (1) {
int n = read(fd, buffer, BUFFER_SIZE);
if (n == -1) {
if (errno == EAGAIN) break; // 读完了
close(fd);
break;
} else if (n == 0) {
close(fd); // 对端关闭
break;
}
write(fd, buffer, n); // echo回去
}
}
}
}
close(epfd);
return 0;
}
避坑指南
最后分享几个我踩过的坑:
- ET模式用了阻塞socket:read最后一次会阻塞住,整个线程卡死。记住,ET必须配非阻塞。
- 忘记处理EAGAIN:ET模式下,不处理EAGAIN就退出循环,会导致数据没读完,后续事件丢失。
- epoll_wait的events数组太小:如果同时有大量事件就绪,数组装不下,剩下的就丢了。建议至少1024。
- 多线程共用epfd:epoll_wait不是线程安全的。多线程场景下,每个线程用独立的epoll实例,或者加锁保护。
嗯,epoll的内容就讲到这里。LT模式适合新手入门,ET模式适合追求极致性能的场景。我个人在线上服务中更倾向于ET模式,虽然编程麻烦点,但性能确实好。你根据自己项目的实际情况来选择就好。
公众号:蓝海资料掘金营,微信deep3321