Linux `epoll` 学习笔记:从原理到正确写法(含 ET 经典坑总结)
本文总结了Linux epoll的核心知识点与常见误区。首先对比了epoll与select/poll的性能差异,指出epoll通过内核维护就绪队列实现高效事件通知。接着详细解析epoll三大API的正确用法,包括epoll_create1参数设置、epoll_ctl操作和epoll_wait使用。重点分析了LT与ET模式的区别,强调ET模式必须配合非阻塞IO,必须循环读取直到EAGAIN。文章还提
文章目录
- Linux `epoll` 学习笔记:从原理到正确写法(含 ET 经典坑总结)
Linux epoll 学习笔记:从原理到正确写法(含 ET 经典坑总结)
这篇文章记录我学习 Linux I/O 多路复用 epoll 的过程:它到底解决了什么问题、和 select/poll 的差别在哪里、epoll 的 API 怎么用、LT/ET 模式怎么选,以及写聊天室/echo server 时最常踩的坑(非阻塞设置、ET 读到 EAGAIN、accept 循环、epoll_create1 参数等)。
读完的目标:你能写出一个 epoll + 非阻塞 + ET 的服务端/客户端,并理解为什么要这么写。
1. epoll 是什么?为什么比 select/poll 更适合高并发?
在网络编程里,我们经常要同时处理多个 socket(多个客户端连接)。如果每个连接一个线程/进程,成本很高。于是出现了 I/O 多路复用:一个线程同时管理多个 fd。
select:用位图保存监听集合,每次调用要从 0 扫到 maxfd,开销随 maxfd 增加poll:用数组保存监听集合,每次调用要遍历整个数组,开销随监听 fd 数量增加epoll:监听集合常驻内核,并维护一个就绪队列,epoll_wait返回的是“已经就绪”的 fd 列表,开销更接近 O(就绪事件数)
一句话理解 epoll 的优势:
select/poll是“你每次来问内核:谁好了?”,内核就每次扫一遍;epoll是“谁好了谁自己报到”,你来epoll_wait时直接取就绪队列。
2. epoll 的三大 API:create / ctl / wait
2.1 创建:epoll_create1
推荐使用:
int epfd = epoll_create1(EPOLL_CLOEXEC); // 或 epoll_create1(0)
注意:epoll_create1 的参数是 flags,不是 size!
这是我踩过的坑:我一开始写 epoll_create1(10000),结果后面 epoll_ctl 报 Bad file descriptor,因为 epoll_create1 直接失败返回了 -1。
epoll_create(size):历史接口,size 只是提示(>0 即可)epoll_create1(flags):现代接口,flags 可以是0或EPOLL_CLOEXEC
2.2 控制:epoll_ctl
用于把 fd 加入/修改/删除监听集合:
struct epoll_event ev;
ev.events = EPOLLIN; // 关注可读
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
2.3 等待:epoll_wait
阻塞等待事件发生:
struct epoll_event events[MAX_EVENTS];
int n = epoll_wait(epfd, events, MAX_EVENTS, timeout_ms);
- 返回值
n:这次有多少个 fd 发生了事件 events[i]:每个事件都带回data.fd(或者你存的指针)和events标志位
3. LT vs ET:默认 LT,ET 更高效但更容易写错
3.1 LT(Level Trigger,水平触发,默认)
只要 fd “仍然可读/可写”,就会不断通知。
优点:
- 简单,不容易丢事件
- 不要求一定读到 EAGAIN
缺点:
- 活跃 fd 多时,重复通知可能带来更多 epoll 事件
3.2 ET(Edge Trigger,边缘触发)
只有从“不可读 → 可读”这类状态变化时通知一次。
优点:
- 更少的重复通知(更高效)
缺点(重点):
- 必须使用非阻塞
- 每次收到 EPOLLIN 必须 读到
EAGAIN/EWOULDBLOCK - 每次收到 listen fd 的 EPOLLIN 必须 accept 到
EAGAIN
否则就会出现经典现象:
“我明明有数据,但 epoll_wait 不再返回事件了”
根因:你没把缓冲区读空,fd 仍然处于“可读”状态,状态没有变化,ET 就不会再通知。
4. ET 模式的“正确姿势”:非阻塞 + drain 到 EAGAIN
4.1 正确设置 non-block(重要!)
设置非阻塞必须用 F_GETFL/F_SETFL,因为 O_NONBLOCK 属于 file status flags:
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
我踩过的坑:用
F_SETFD去 ORO_NONBLOCK是错的。F_SETFD管的是FD_CLOEXEC这类 描述符标志,不是 non-block。
4.2 accept 必须循环到 EAGAIN(ET 下)
while (1) {
int cfd = accept(listenfd, ...);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
perror("accept"); break;
}
set_nonblock(cfd);
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
}
4.3 read 必须循环到 EAGAIN(ET 下)
while (1) {
int n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 处理数据
} else if (n == 0) {
// 对端关闭
close(fd);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
if (errno == EINTR) continue;
perror("read");
close(fd);
break;
}
}
5. 一个最小可运行的 epoll 服务端(ET 版骨架)
下面是一个“骨架级”的正确思路(省略业务):
int epfd = epoll_create1(EPOLL_CLOEXEC);
int lfd = socket(...);
set_nonblock(lfd);
bind(lfd,...);
listen(lfd, SOMAXCONN);
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = lfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
while (1) {
struct epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == lfd) {
// accept 到 EAGAIN
} else {
// read 到 EAGAIN
}
}
}
6. 写聊天室/echo 时最常见的坑(我踩过的都在这里)
6.1 epoll_create1 参数写错导致 Bad file descriptor
错误:
int epfd = epoll_create1(10000); // ❌
正确:
int epfd = epoll_create1(0); // ✅
int epfd = epoll_create1(EPOLL_CLOEXEC); // ✅ 推荐
6.2 if (events[i].data.fd = sock_fd):把 == 写成 =
这是非常隐蔽但致命的 bug:
if (events[i].data.fd = sock_fd) { ... } // ❌ 永远成立
应改为:
if (events[i].data.fd == sock_fd) { ... } // ✅
6.3 非阻塞设置用错 F_SETFD
错误:
fcntl(fd, F_SETFD, fcntl(fd, F_GETFD, 0) | O_NONBLOCK); // ❌
正确:
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); // ✅
F_SETFD 主要用于:
fcntl(fd, F_SETFD, fcntl(fd, F_GETFD, 0) | FD_CLOEXEC); // ✅
6.4 ET 模式只读一次:后面再也不触发
错误(ET 下):
read(fd, buf, sizeof(buf)); // ❌ 只读一次
正确:
- 读到 EAGAIN
- accept 到 EAGAIN
6.5 read() 后直接当字符串用
TCP 是字节流,read() 得到的是一段字节,不保证末尾有 \0。如果你要 printf("%s"),必须自己补:
int n = read(fd, buf, BUFSIZ - 1);
buf[n] = '\0';
6.6 EPOLLOUT 乱用会导致“疯狂触发”
很多 socket 平时几乎一直可写,如果你长期监听 EPOLLOUT,epoll 会不断告诉你“可写”。正确做法是:
- 只有当你真的有待发送数据且 write 返回 EAGAIN 时,才临时关注 EPOLLOUT
- 写完了再把 EPOLLOUT 去掉(MOD)
6.7 断开连接要处理 RDHUP/HUP/ERR
更稳的事件集合:
ev.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
处理时:
if (events[i].events & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
close(fd);
continue;
}
7. 关于 strlen + 1、发送 \0 的小提醒
在 demo 聊天室里我经常写:
write(fd, msg, strlen(msg) + 1);
这样做的好处是:对端读到的数据可以直接当 C 字符串用(因为连 \0 都发过去了)。
但要知道:TCP 是字节流,不保消息边界。一次 read 可能读到半条、也可能读到多条拼在一起。生产级做法一般是:
- 自己定义协议(长度字段/分隔符)
- 或者每条消息以
\n结尾并按行解析 - 或者使用固定包头 + body 长度
学习阶段这么写没问题,但要知道“为什么严格来说不可靠”。
8. 总结:我最终记住的 epoll 三句话
epoll_create1参数是 flags,不是 size- ET 模式:non-block + accept/read 到 EAGAIN
O_NONBLOCK用F_SETFL,FD_CLOEXEC用F_SETFD
更多推荐



所有评论(0)