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_ctlBad file descriptor,因为 epoll_create1 直接失败返回了 -1

  • epoll_create(size):历史接口,size 只是提示(>0 即可)
  • epoll_create1(flags):现代接口,flags 可以是 0EPOLL_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 去 OR O_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 三句话

  1. epoll_create1 参数是 flags,不是 size
  2. ET 模式:non-block + accept/read 到 EAGAIN
  3. O_NONBLOCKF_SETFLFD_CLOEXECF_SETFD
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐