Linux `epoll` 学习笔记:从原理到正确写法(含 ET 经典坑总结)

文章目录

  • [Linux `epoll` 学习笔记:从原理到正确写法(含 ET 经典坑总结)](#Linux epoll 学习笔记:从原理到正确写法(含 ET 经典坑总结))
    • [1. `epoll` 是什么?为什么比 `select/poll` 更适合高并发?](#1. epoll 是什么?为什么比 select/poll 更适合高并发?)
    • [2. `epoll` 的三大 API:create / ctl / wait](#2. epoll 的三大 API:create / ctl / wait)
      • [2.1 创建:`epoll_create1`](#2.1 创建:epoll_create1)
      • [2.2 控制:`epoll_ctl`](#2.2 控制:epoll_ctl)
      • [2.3 等待:`epoll_wait`](#2.3 等待:epoll_wait)
    • [3. LT vs ET:默认 LT,ET 更高效但更容易写错](#3. LT vs ET:默认 LT,ET 更高效但更容易写错)
      • [3.1 LT(Level Trigger,水平触发,默认)](#3.1 LT(Level Trigger,水平触发,默认))
      • [3.2 ET(Edge Trigger,边缘触发)](#3.2 ET(Edge Trigger,边缘触发))
    • [4. ET 模式的"正确姿势":非阻塞 + drain 到 EAGAIN](#4. ET 模式的“正确姿势”:非阻塞 + drain 到 EAGAIN)
      • [4.1 正确设置 non-block(重要!)](#4.1 正确设置 non-block(重要!))
      • [4.2 accept 必须循环到 EAGAIN(ET 下)](#4.2 accept 必须循环到 EAGAIN(ET 下))
      • [4.3 read 必须循环到 EAGAIN(ET 下)](#4.3 read 必须循环到 EAGAIN(ET 下))
    • [5. 一个最小可运行的 epoll 服务端(ET 版骨架)](#5. 一个最小可运行的 epoll 服务端(ET 版骨架))
    • [6. 写聊天室/echo 时最常见的坑(我踩过的都在这里)](#6. 写聊天室/echo 时最常见的坑(我踩过的都在这里))
      • [6.1 `epoll_create1` 参数写错导致 `Bad file descriptor`](#6.1 epoll_create1 参数写错导致 Bad file descriptor)
      • [6.2 `if (eventsi.data.fd = sock_fd)`:把 `==` 写成 `=`](#6.2 if (events[i].data.fd = sock_fd):把 == 写成 =)
      • [6.3 非阻塞设置用错 `F_SETFD`](#6.3 非阻塞设置用错 F_SETFD)
      • [6.4 ET 模式只读一次:后面再也不触发](#6.4 ET 模式只读一次:后面再也不触发)
      • [6.5 `read()` 后直接当字符串用](#6.5 read() 后直接当字符串用)
      • [6.6 `EPOLLOUT` 乱用会导致"疯狂触发"](#6.6 EPOLLOUT 乱用会导致“疯狂触发”)
      • [6.7 断开连接要处理 RDHUP/HUP/ERR](#6.7 断开连接要处理 RDHUP/HUP/ERR)
    • [7. 关于 `strlen + 1`、发送 `\0` 的小提醒](#7. 关于 strlen + 1、发送 \0 的小提醒)
    • [8. 总结:我最终记住的 epoll 三句话](#8. 总结:我最终记住的 epoll 三句话)

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

推荐使用:

c 复制代码
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 加入/修改/删除监听集合:

c 复制代码
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

阻塞等待事件发生:

c 复制代码
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:

c 复制代码
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

我踩过的坑:用 F_SETFD 去 OR O_NONBLOCK 是错的。
F_SETFD 管的是 FD_CLOEXEC 这类 描述符标志,不是 non-block。

4.2 accept 必须循环到 EAGAIN(ET 下)

c 复制代码
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 下)

c 复制代码
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 版骨架)

下面是一个"骨架级"的正确思路(省略业务):

c 复制代码
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

错误:

c 复制代码
int epfd = epoll_create1(10000); // ❌

正确:

c 复制代码
int epfd = epoll_create1(0);              // ✅
int epfd = epoll_create1(EPOLL_CLOEXEC);  // ✅ 推荐

6.2 if (events[i].data.fd = sock_fd):把 == 写成 =

这是非常隐蔽但致命的 bug:

c 复制代码
if (events[i].data.fd = sock_fd) { ... } // ❌ 永远成立

应改为:

c 复制代码
if (events[i].data.fd == sock_fd) { ... } // ✅

6.3 非阻塞设置用错 F_SETFD

错误:

c 复制代码
fcntl(fd, F_SETFD, fcntl(fd, F_GETFD, 0) | O_NONBLOCK); // ❌

正确:

c 复制代码
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); // ✅

F_SETFD 主要用于:

c 复制代码
fcntl(fd, F_SETFD, fcntl(fd, F_GETFD, 0) | FD_CLOEXEC); // ✅

6.4 ET 模式只读一次:后面再也不触发

错误(ET 下):

c 复制代码
read(fd, buf, sizeof(buf)); // ❌ 只读一次

正确:

  • 读到 EAGAIN
  • accept 到 EAGAIN

6.5 read() 后直接当字符串用

TCP 是字节流,read() 得到的是一段字节,不保证末尾有 \0。如果你要 printf("%s"),必须自己补:

c 复制代码
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

更稳的事件集合:

c 复制代码
ev.events = EPOLLIN | EPOLLET | EPOLLRDHUP;

处理时:

c 复制代码
if (events[i].events & (EPOLLERR | EPOLLHUP | EPOLLRDHUP)) {
    close(fd);
    continue;
}

7. 关于 strlen + 1、发送 \0 的小提醒

在 demo 聊天室里我经常写:

c 复制代码
write(fd, msg, strlen(msg) + 1);

这样做的好处是:对端读到的数据可以直接当 C 字符串用(因为连 \0 都发过去了)。

但要知道:TCP 是字节流,不保消息边界 。一次 read 可能读到半条、也可能读到多条拼在一起。生产级做法一般是:

  • 自己定义协议(长度字段/分隔符)
  • 或者每条消息以 \n 结尾并按行解析
  • 或者使用固定包头 + body 长度

学习阶段这么写没问题,但要知道"为什么严格来说不可靠"。


8. 总结:我最终记住的 epoll 三句话

  1. epoll_create1 参数是 flags,不是 size
  2. ET 模式:non-block + accept/read 到 EAGAIN
  3. O_NONBLOCK 用 F_SETFL,FD_CLOEXEC 用 F_SETFD
相关推荐
ajassi200015 分钟前
AI语音智能体开发日记(十八)智能体服务器xiaozhi-esp32-server源码部署指南
运维·服务器·人工智能·ai·ai编程
‎ദ്ദിᵔ.˛.ᵔ₎19 分钟前
linux 进程信号(下)
linux·运维·服务器
曼岛_21 分钟前
[图解密码学]第1章 密码学是什么
网络·密码学·图解密码学
程序喵大人21 分钟前
【C++入门】编译链接模型 - 05 ODR 为什么会让重复定义变成工程炸点
开发语言·c++·编译链接·odr
AOI小白新手上路26 分钟前
DRSformer·论文蒸馏笔记:可学习 Top-k 稀疏注意力去雨网络
网络·笔记·学习
你的强来了99831 分钟前
WCHToolKit场景实战
网络·串口·调试·usb·ble·分析
skywalk816336 分钟前
在FreeBSD系统的linux仿真环境下安装和使用kiro 这个亚马逊的AI agent
linux·人工智能·freebsd·kiro
Mikko738 分钟前
Linux 磁盘满了但 du 找不到?LVM 扩容 df 不变?fstab 写错开不了机?三台真机实测磁盘排查
linux·运维·centos
yumgpkpm1 小时前
Acceldata ODP 3.3.6.4 vs CDP Private Cloud Base 7.3.2 对比
大数据·运维·服务器·hadoop·华为·zookeeper·hbase
汉克老师1 小时前
CSP-S 2026 初赛试题解析(第一部分:选择题(8-15))精讲
c++·csp-s·小学生·学c++编程