文章目录


深入理解 Linux I/O 多路复用:select、poll、epoll 全解析

适合人群:有一定 C/C++ 和网络编程基础,想系统掌握 Linux 高并发 I/O 底层机制的开发者。

阅读目标:一文搞懂 select → poll → epoll 的演进脉络、API 用法、触发模式、底层原理与实战代码。


目录

  1. 为什么需要 I/O 多路复用?
  2. 第一代方案:select
  3. 过渡方案:poll
  4. 终极方案:epoll
  5. epoll 核心 API 详解
  6. epoll 底层数据结构
  7. 触发模式:LT vs ET
  8. ET 模式铁律:为何必须搭配非阻塞 I/O?
  9. 三者核心区别速查表
  10. epoll 实战代码:高并发 Echo Server
  11. epoll 的惊群问题
  12. 常见面试题 & 避坑指南
  13. 总结

一、为什么需要 I/O 多路复用?

传统的网络服务器模型有两种:

模型 做法 问题
每连接一个进程 fork() 一个子进程处理一个客户端 连接数上千后,进程切换开销巨大,内存爆炸
每连接一个线程 一个线程处理一个客户端 线程数过多同样有上下文切换和栈内存问题

核心矛盾:大部分连接在大部分时间是空闲的(没有数据收发),但我们却为每个连接分配了独立的进程/线程去"傻等"。

I/O 多路复用的思路:让一个线程同时监视所有连接的文件描述符(fd),当某个 fd 就绪(有数据可读/可写)时,内核通知应用程序去处理。

一句话:用 1 个线程盯 N 个连接,谁有事就处理谁。


二、第一代方案:select

2.1 函数原型

#include <sys/select.h>

int select(int nfds,
           fd_set *readfds,
           fd_set *writefds,
           fd_set *exceptfds,
           struct timeval *timeout);

2.2 工作流程

用户态:创建 fd_set 位图(最大1024位)
    ↓
每次调用 select():
    1. 将整个 fd_set 从用户态拷贝到内核态
    2. 内核线性遍历所有 fd,检查是否就绪
    3. 将就绪的 fd 标记在位图中,拷贝回用户态
    ↓
用户态:再次遍历所有 fd,找出被标记的就绪项

2.3 致命缺陷

缺陷 说明
最大连接数限制 FD_SETSIZE 宏限制,通常为 1024
O(n) 线性扫描 每次调用都要遍历所有 fd,即使只有 1 个活跃
重复内存拷贝 每次调用都要把整个 fd_set 在用户态/内核态之间拷贝
位图操作繁琐 需要维护 3 个独立的位图(读/写/异常),编程复杂

2.4 辅助宏

FD_ZERO(&set);      // 清空集合
FD_SET(fd, &set);   // 添加 fd 到集合
FD_CLR(fd, &set);   // 从集合中移除 fd
FD_ISSET(fd, &set); // 检查 fd 是否在集合中(就绪后使用)

三、过渡方案:poll

3.1 函数原型

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

3.2 核心数据结构

struct pollfd {
    int   fd;       // 要监视的文件描述符
    short events;   // 用户关心的事件(输入)
    short revents;  // 内核填充的实际发生事件(输出)
};

3.3 常用事件标志

事件宏 含义
POLLIN 有数据可读
POLLOUT 可以写入数据
POLLERR 发生错误
POLLHUP 对端关闭连接
POLLNVAL fd 无效(未打开)
POLLPRI 有紧急数据可读

3.4 相比 select 的改进

维度 select poll
数据结构 3 个位图(fd_set) pollfd 结构体数组
最大连接数 1024(FD_SETSIZE 无硬限制(受系统内存限制)
事件类型 仅读/写/异常 3 类 更丰富(POLLINPOLLPRI 等)
fd 传参 位图拷贝 数组传递,不需要 3 个集合

3.5 仍然存在的问题

⚠️ poll 本质上仍然是 O(n) 线性扫描! 每次调用仍需将整个 pollfd 数组拷贝进内核,内核仍需遍历所有 fd。它只是"修了 select 的 Bug",并没有"改变算法"。


四、终极方案:epoll

4.1 epoll 是什么?

epoll 是 Linux 2.6 内核引入的 I/O 事件通知机制,是 select/poll增强版本。它的设计目标就是解决 C10K(单机万级并发)问题。

Nginx、Redis、Node.js(Linux下)、Netty 等知名高并发框架底层都使用 epoll。

4.2 为什么 epoll 比 select/poll 快?

优化点 select/poll epoll
扫描方式 O(n) 线性遍历所有 fd O(1) 事件驱动回调,只返回就绪 fd
内存拷贝 每次调用都拷贝整个 fd 集合 epoll_ctl 注册时拷贝一次,之后不再重复
内核数据结构 位图 / 数组 红黑树(管理所有监视fd)+ 双向链表(就绪队列)

核心机制

  1. epoll_ctl 把 fd 注册到内核的红黑树中。
  2. 当某个 fd 上有事件发生(如网卡收到数据),内核通过回调函数ep_poll_callback)将该 fd 挂入就绪链表
  3. epoll_wait 只需检查就绪链表是否有数据,无需遍历全部 fd
                 epoll 内核结构示意
                 ┌──────────────────────┐
                 │   epoll 实例 (epfd)   │
                 │                      │
                 │  ┌────────────────┐  │
                 │  │   红黑树        │  │  ← 存储所有被监视的 fd
                 │  │  (监视集合)     │  │
                 │  └───────┬────────┘  │
                 │          │ 事件发生时  │
                 │          │ 回调挂入    │
                 │          ▼            │
                 │  ┌────────────────┐  │
                 │  │  就绪链表       │  │  ← 只存放有事件的 fd
                 │  │  (Ready List)  │  │
                 │  └────────────────┘  │
                 └──────────────────────┘

五、epoll 核心 API 详解

epoll 由 3 个系统调用组成:

5.1 epoll_create / epoll_create1 —— 创建 epoll 实例

#include <sys/epoll.h>

// 旧版(Linux 2.6+)
int epoll_create(int size);

// 新版(Linux 2.6.27+,推荐)
int epoll_create1(int flags);
参数 说明
size 旧版中提示内核预期监听 fd 数量(现代内核已忽略此值,但必须 > 0)
flags 新版标志位,可设为 0EPOLL_CLOEXEC

返回值:成功返回 epoll 文件描述符(epfd),失败返回 -1

关键点

  • epfd 本身也是一个文件描述符,用完后需要 close(epfd) 释放。
  • EPOLL_CLOEXEC:设置后,如果当前进程调用 exec() 启动新程序时,会自动关闭此 epfd,防止 fd 泄露给子进程。
// 推荐写法
int epfd = epoll_create1(EPOLL_CLOEXEC);
if (epfd == -1) {
    perror("epoll_create1");
    exit(EXIT_FAILURE);
}

5.2 epoll_ctl —— 注册/修改/删除监听事件

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数 说明
epfd epoll_create 返回的 epoll 实例描述符
op 操作类型(见下表)
fd 要操作的目标文件描述符
event 指向 epoll_event 结构体的指针

操作类型 op

含义
EPOLL_CTL_ADD 向 epoll 实例中添加 fd 及其监听事件
EPOLL_CTL_MOD 修改已注册 fd 的监听事件
EPOLL_CTL_DEL 从 epoll 实例中删除 fd(event 参数可传 NULL)

核心结构体 epoll_event

struct epoll_event {
    uint32_t     events;   // 事件掩码(位掩码,可 OR 组合)
    epoll_data_t data;     // 用户自定义数据(联合体)
};

typedef union epoll_data {
    void    *ptr;    // 指向用户自定义数据(最常用)
    int      fd;     // 文件描述符
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

常用事件标志 events

事件宏 含义
EPOLLIN 0x001 对应的 fd 可读
EPOLLOUT 0x004 对应的 fd 可写
EPOLLPRI 0x002 紧急数据可读(TCP out-of-band data)
EPOLLERR 0x008 发生错误
EPOLLHUP 0x010 对端关闭连接(挂断)
EPOLLET 1u << 31 设置为边缘触发(ET)模式
EPOLLONESHOT 1u << 30 一次性监听:事件触发后自动禁用该 fd,需重新 EPOLL_CTL_MOD 启用
EPOLLRDHUP 0x2000 对端关闭了写端(半关闭,Linux 2.6.17+)

使用示例

struct epoll_event ev;

// 添加一个 fd,监听可读事件,使用 ET 模式
ev.events = EPOLLIN | EPOLLET;   // 可读 + 边缘触发
ev.data.fd = client_fd;          // 也可以用 ev.data.ptr 传自定义对象
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

// 修改为同时监听可读和可写
ev.events = EPOLLIN | EPOLLOUT | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_MOD, client_fd, &ev);

// 删除监听
epoll_ctl(epfd, EPOLL_CTL_DEL, client_fd, NULL);

💡 EPOLLONESHOT 的作用:在多线程环境中,防止同一个 fd 被多个线程同时处理。事件触发后自动取消监听,处理完后需要重新 MOD 才能再次接收事件。


5.3 epoll_wait —— 等待事件就绪

int epoll_wait(int epfd,
               struct epoll_event *events,
               int maxevents,
               int timeout);
参数 说明
epfd epoll 实例描述符
events 输出参数:内核将就绪的事件填入此数组
maxevents 本次最多返回多少个就绪事件(必须 > 0)
timeout 超时时间(毫秒)。-1 = 永久阻塞,0 = 立即返回(非阻塞轮询)

返回值

  • > 0:就绪事件的数量(已填入 events 数组)
  • 0:超时,没有事件
  • -1:出错(被信号中断时 errno == EINTR

使用示例

#define MAX_EVENTS 64
struct epoll_event events[MAX_EVENTS];

while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);  // 永久阻塞等待
    if (n == -1) {
        if (errno == EINTR) continue;  // 被信号中断,重新等待
        perror("epoll_wait");
        break;
    }

    for (int i = 0; i < n; i++) {
        if (events[i].events & EPOLLIN) {
            // 可读,处理读事件
            handle_read(events[i].data.fd);
        }
        if (events[i].events & EPOLLOUT) {
            // 可写,处理写事件
            handle_write(events[i].data.fd);
        }
        if (events[i].events & (EPOLLERR | EPOLLHUP)) {
            // 错误或断开,清理
            close(events[i].data.fd);
        }
    }
}

💡 maxevents 怎么选? 一般设为 64~256。太大浪费栈空间,太小可能需要多次调用。Nginx 默认使用 512。


5.4 三个 API 的协作关系

         ┌─────────────────┐
         │  epoll_create1  │  ← 第一步:创建 epoll 实例,得到 epfd
         └────────┬────────┘
                  │
         ┌────────▼────────┐
         │   epoll_ctl     │  ← 第二步:注册/修改/删除 fd 和事件
         │  (ADD/MOD/DEL)  │     将 fd 插入红黑树,注册回调
         └────────┬────────┘
                  │
         ┌────────▼────────┐
         │  epoll_wait     │  ← 第三步(主循环):阻塞等待就绪事件
         │  (返回就绪数组)   │     只检查就绪链表,O(1) 返回
         └────────┬────────┘
                  │
                  ▼
           处理就绪事件...
           然后回到 epoll_wait

六、epoll 底层数据结构

用户进程
   │
   │ epoll_ctl(ADD, fd=5, EPOLLIN)
   ▼
┌───────────────────────────────────────────────────┐
│                   内核空间                          │
│                                                   │
│   epfd (epoll 实例)                                │
│   ├── 红黑树 (rbr)                                 │
│   │   ├── [fd=5, events=EPOLLIN, callback]        │
│   │   ├── [fd=8, events=EPOLLIN|EPOLLET, cb]      │
│   │   ├── [fd=12, events=EPOLLIN|EPOLLOUT, cb]    │
│   │   └── ... (O(log n) 增删查)                    │
│   │                                               │
│   └── 就绪链表 (rdllist)                            │
│       ├── [fd=8,  revents=EPOLLIN]  ← 有数据到达   │
│       └── [fd=12, revents=EPOLLOUT] ← 可写         │
│                                                   │
│   当 fd=5 收到数据 → 内核回调 ep_poll_callback       │
│   → 将 fd=5 挂入就绪链表                            │
│   → epoll_wait 发现有数据 → 返回给用户               │
└───────────────────────────────────────────────────┘

时间复杂度分析

操作 时间复杂度 说明
epoll_ctl ADD/MOD/DEL O(log n) 红黑树操作
epoll_wait 返回就绪事件 O(k) k = 活跃连接数(就绪链表长度)
内核回调挂入就绪链表 O(1) 链表尾部插入

这就是 epoll 在高并发下碾压 select 的根本原因:当 10000 个连接中只有 10 个活跃时,select 扫描 10000 次,epoll 只处理 10 个。


七、触发模式:LT vs ET

这是 epoll 的核心考点,也是最容易出错的地方。

7.1 水平触发 (Level-Triggered, LT)

内核缓冲区状态:
时间 ─────────────────────────────────►
      [空]  [1字节]  [5字节]  [5字节]  [空]
              ↑        ↑        ↑
              │        │        │
       epoll_wait   epoll_wait  epoll_wait
       返回就绪 ✓    返回就绪 ✓   返回就绪 ✓
  • 默认模式(不加 EPOLLET 标志)
  • 只要 fd 的缓冲区中有数据,每次 epoll_wait 都会通知你
  • 这次没读完?没关系,下次还会通知
  • 安全、简单,适合大多数场景

7.2 边缘触发 (Edge-Triggered, ET)

内核缓冲区状态:
时间 ─────────────────────────────────►
      [空]  [1字节]  [5字节]  [5字节]  [空]
              ↑        ↑        ↑
              │        │        │
       epoll_wait   epoll_wait  epoll_wait
       返回就绪 ✓    未通知 ✗    未通知 ✗
              │
     状态从"空→有数据"发生了"边沿跳变"
     只在此刻通知一次!
  • 需要设置 EPOLLET 标志
  • 只在状态变化的瞬间通知一次(从无数据→有数据)
  • 后续缓冲区数据增加但状态没"跳变"→不再通知
  • 高效:减少了不必要的 epoll_wait 返回次数

7.3 对比总结

维度 LT(水平触发) ET(边缘触发)
通知条件 缓冲区有数据就通知 缓冲区状态变化才通知
通知频率 高(可能重复通知) 低(只通知一次)
编程难度 低,不容易漏数据 高,必须一次读完
性能 略低(可能有冗余通知) 更高
是否要求非阻塞I/O 不强制 强制要求
select 是否支持 ✅ 是(唯一模式) ❌ 否

八、ET 模式铁律:为何必须搭配非阻塞 I/O?

这是面试高频题,也是实际开发中最容易踩坑的地方。

8.1 逻辑链条

ET模式要求:通知一次 → 必须循环读,直到掏空缓冲区
                    ↓
循环读的最后一次:read() 时缓冲区已空
                    ↓
         ┌──────────┴──────────┐
         │                     │
    阻塞 I/O               非阻塞 I/O
         │                     │
    read() 永久挂起        read() 立即返回 -1
    线程卡死 💀            errno = EAGAIN ✅
                           安全 break 退出循环

8.2 标准 ET 模式读数据模板

// 前提:fd 已设置为非阻塞模式
// fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);

void handle_read_et(int fd) {
    char buf[4096];
    while (1) {
        ssize_t n = read(fd, buf, sizeof(buf));
        if (n > 0) {
            // 读到数据,处理
            process_data(buf, n);
        } else if (n == 0) {
            // 对端关闭连接
            close(fd);
            break;
        } else {
            // n == -1
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 缓冲区已掏空,安全退出
                break;
            } else if (errno == EINTR) {
                // 被信号中断,继续读
                continue;
            } else {
                // 真正的错误
                perror("read");
                close(fd);
                break;
            }
        }
    }
}

8.3 LT 模式的读法(更简单)

void handle_read_lt(int fd) {
    char buf[4096];
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        process_data(buf, n);
        // 没读完?没关系,下次 epoll_wait 还会通知我
    } else if (n == 0) {
        close(fd);
    } else {
        perror("read");
        close(fd);
    }
}

九、三者核心区别速查表

对比维度 select poll epoll
数据结构 位图 fd_set pollfd 结构体数组 红黑树 + 就绪链表
最大连接数 1024 (FD_SETSIZE) 无硬限制 无限制(系统打开文件数)
工作模式 主动轮询 主动轮询 事件驱动回调
时间复杂度 O(n) O(n) O(1) (与活跃数相关)
内核拷贝 每次拷贝 每次拷贝 注册时拷贝一次
触发模式 仅 LT 仅 LT LT + ET
fd 通知机制 遍历所有fd 遍历所有fd 回调函数挂入就绪链表
跨平台性 几乎全平台 大多数 Unix Linux 特有
适用场景 连接少、跨平台 中等连接数、跨平台 高并发、Linux
代表框架 - - Nginx, Redis, Netty

一图看懂演进关系

select (1983, BSD)
  │
  │  问题:1024限制 + 每次拷贝 + O(n)
  ▼
poll (1986, System V)
  │
  │  改进:突破1024 + pollfd数组
  │  仍有:每次拷贝 + O(n)
  ▼
epoll (2002, Linux 2.5.44)
  │
  │  革命:红黑树 + 回调 + 就绪链表
  │  彻底解决:O(1) + 只拷贝一次
  ▼
高并发时代 (C10K/C100K)

十、epoll 实战代码:高并发 Echo Server

以下是一个完整的、带详细注释的 epoll ET 模式 Echo Server:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define MAX_EVENTS 64
#define PORT       8080
#define BUF_SIZE   4096

// 设置 fd 为非阻塞模式
int set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int listen_fd, epfd, conn_fd;
    struct sockaddr_in serv_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    struct epoll_event ev, events[MAX_EVENTS];

    // ===== 1. 创建监听 socket =====
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) { perror("socket"); exit(1); }

    // 设置端口复用
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = INADDR_ANY;
    serv_addr.sin_port = htons(PORT);

    if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
        perror("bind"); exit(1);
    }
    if (listen(listen_fd, 128) == -1) {
        perror("listen"); exit(1);
    }

    // 设置监听 socket 为非阻塞(ET 模式需要)
    set_nonblocking(listen_fd);

    // ===== 2. 创建 epoll 实例 =====
    epfd = epoll_create1(EPOLL_CLOEXEC);
    if (epfd == -1) { perror("epoll_create1"); exit(1); }

    // ===== 3. 注册监听 socket 的读事件 =====
    ev.events = EPOLLIN | EPOLLET;  // 可读 + ET 模式
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    printf("Echo Server started on port %d (ET mode)\n", PORT);

    // ===== 4. 事件循环 =====
    while (1) {
        int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (n == -1) {
            if (errno == EINTR) continue;
            perror("epoll_wait"); break;
        }

        for (int i = 0; i < n; i++) {
            // ---------- 新连接到达 ----------
            if (events[i].data.fd == listen_fd) {
                // ET 模式下,accept 也要循环调用,直到返回 EAGAIN
                while (1) {
                    conn_fd = accept(listen_fd,
                                     (struct sockaddr*)&client_addr,
                                     &client_len);
                    if (conn_fd == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK)
                            break;  // 所有新连接都已接受完
                        perror("accept");
                        break;
                    }

                    set_nonblocking(conn_fd);  // 新连接也必须设为非阻塞

                    // 将新连接注册到 epoll
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = conn_fd;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);

                    printf("New connection: %s:%d (fd=%d)\n",
                           inet_ntoa(client_addr.sin_addr),
                           ntohs(client_addr.sin_port), conn_fd);
                }
            }
            // ---------- 客户端数据可读 ----------
            else if (events[i].events & EPOLLIN) {
                int fd = events[i].data.fd;
                char buf[BUF_SIZE];

                // ET 模式:必须循环读,直到 EAGAIN
                while (1) {
                    ssize_t nread = read(fd, buf, sizeof(buf));
                    if (nread > 0) {
                        // Echo:原样回写
                        ssize_t nwrite = write(fd, buf, nread);
                        if (nwrite == -1) {
                            perror("write");
                            break;
                        }
                    } else if (nread == 0) {
                        // 对端关闭
                        printf("Client fd=%d disconnected\n", fd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                        break;
                    } else {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break;  // 缓冲区已空
                        }
                        perror("read");
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                        break;
                    }
                }
            }
            // ---------- 错误或断开 ----------
            else if (events[i].events & (EPOLLERR | EPOLLHUP)) {
                int fd = events[i].data.fd;
                printf("Error/Hangup on fd=%d\n", fd);
                epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                close(fd);
            }
        }
    }

    close(epfd);
    close(listen_fd);
    return 0;
}

编译运行

gcc -o echo_server echo_server.c
./echo_server

# 另开终端测试
echo "Hello" | nc localhost 8080
# 输出: Hello

十一、epoll 的惊群问题

11.1 什么是惊群?

当多个进程/线程同时调用 epoll_wait 监听同一个 listen_fd 时,新连接到来时所有进程/线程都被唤醒,但只有一个能 accept 成功,其余的白跑一趟又继续睡眠——这就是"惊群效应"(Thundering Herd)。

11.2 解决方案

方案 说明
EPOLLEXCLUSIVE (Linux 4.5+) listen_fd 上设置此标志,内核只唤醒一个等待者
SO_REUSEPORT 多个进程各自创建 socket 并 bind 同一端口,内核在进程间负载均衡分配连接
加锁 accept 前加互斥锁,保证只有一个进程去 accept(Nginx 早期做法)
// 方案1:EPOLLEXCLUSIVE (Linux 4.5+)
ev.events = EPOLLIN | EPOLLEXCLUSIVE;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

十二、常见面试题 & 避坑指南

Q1: epoll 的红黑树和就绪链表分别在什么时候被操作?

  • 红黑树epoll_ctl ADD/MOD/DEL 时操作,负责管理所有被监视的 fd
  • 就绪链表:当 fd 上有事件发生时,内核通过回调函数将 fd 挂入就绪链表epoll_wait 从就绪链表中取出就绪事件返回给用户

Q2: ET 模式下,如果 read 没读完会怎样?

不会丢失数据(数据还在内核缓冲区),但不会再收到通知!直到有新的数据到达(新的状态跳变)才会再次触发。所以 ET 模式下必须一次性读完

Q3: EPOLLONESHOTEPOLLET 有什么区别?

  • EPOLLET:状态变化时通知一次,之后如果缓冲区有新数据到达,会再次通知。
  • EPOLLONESHOT:通知一次后彻底禁用该 fd,必须手动 EPOLL_CTL_MOD 重新启用。主要用于多线程防止同一 fd 被并发处理。

Q4: epoll 实例(epfd)用完了要不要 close?

必须 close! epfd 是一个文件描述符,不关闭会导致 fd 泄露。在进程退出时内核会自动回收,但良好的编程习惯应该显式关闭。

Q5: select 在什么场景下仍然有用?

  • 需要跨平台(Windows、macOS 等不支持 epoll)
  • 连接数很少(< 100),性能差异可以忽略
  • 简单的脚本或工具程序

十三、总结

一句话记住三者

方案 一句话
select 简单粗暴,人少够用,人多累死
poll select 的修补版,没了1024限制,但本质没变
epoll 彻底革命,事件驱动 + 回调 + 红黑树,高并发之王

选型决策树

你的程序需要跨平台吗?
├── 是 → 连接数 < 1024?
│        ├── 是 → select
│        └── 否 → poll(或用 libevent/libuv 封装)
└── 否(仅 Linux)→ epoll
         ├── 简单场景、不想踩坑 → LT 模式
         └── 极致性能 → ET + 非阻塞 I/O

黄金法则

epoll ET 模式 + 非阻塞 I/O = 强制绑定,缺一不可。

收到通知后 → 循环 read/accept → 遇到 EAGAIN 才退出 → 回到 epoll_wait


参考资料

  • man 7 epoll — Linux 官方手册
  • 《UNIX 网络编程 卷1》— W. Richard Stevens
  • Linux 内核源码 fs/eventpoll.c

好的!你提的这两个问题非常精准,也是很多初学者(甚至工作几年的开发者)最容易含糊的地方。

下面我把 “内存拷贝到底是怎么发生的” 掰碎了讲透,然后把实战代码加上 “保姆级 + 原理级” 的超详细注释。


深度补充一:彻底讲透“内存拷贝”问题

很多文章只说一句“epoll 只拷贝一次”,但不解释拷贝了什么、从哪拷到哪、为什么 select 每次都要拷。我们用图解把数据在用户态内核态之间的流转画清楚。

1.1 先搞懂前提:为什么会有“拷贝”?

Linux 系统分为用户态(你的程序运行的地方)和内核态(操作系统内核运行的地方)。

  • 你的程序(用户态)不能直接访问内核的数据结构。
  • 内核(内核态)不能直接访问你程序的变量。
  • 当你的程序调用系统函数(如 selectepoll_wait)时,必须把数据从用户态内存“拷贝”到内核态内存,内核处理完后,再把结果从内核态“拷贝”回用户态

拷贝 = 内存数据的复制(memcpy),是需要消耗 CPU 和时间的。数据量越大,拷贝越慢。


1.2 select 的悲剧:每次都“全量搬运”

假设你监视了 10000 个 fd,但只有 5 个有数据。看看 select 每次被调用时发生了什么:

┌─────────────────────────────────────────────────────────────────┐
│                        用户态(你的程序)                         │
│                                                                 │
│  fd_set readfds;  // 一个巨大的位图,10000个bit                  │
│  FD_SET(fd1, &readfds);                                        │
│  FD_SET(fd2, &readfds);                                        │
│  ...                                                           │
│  FD_SET(fd10000, &readfds);                                    │
│                                                                 │
│  select(10001, &readfds, ...);  ──────── 调用 ──────────┐       │
└─────────────────────────────────────────────────────────┼───────┘
                                                          │
            【第1次拷贝:用户态 → 内核态】                   │
            把 10000 个 fd 的位图(约1.2KB)整个复制进去     │
                                                          ▼
┌─────────────────────────────────────────────────────────────────┐
│                        内核态(操作系统)                         │
│                                                                 │
│  收到 10000 个 fd 的位图                                        │
│  内核开始线性遍历:                                              │
│    fd1 有数据吗?没有。                                          │
│    fd2 有数据吗?没有。                                          │
│    ...                                                         │
│    fd5000 有数据吗?有!标记一下。                                │
│    ...                                                         │
│    fd10000 有数据吗?没有。                                      │
│                                                                 │
│  遍历完毕,修改位图(只保留有数据的5个fd的标记)                   │
│                                                                 │
└───────────────────────────────────┬─────────────────────────────┘
                                    │
            【第2次拷贝:内核态 → 用户态】
            把修改后的 10000 个 fd 的位图整个复制回来
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────┐
│                        用户态(你的程序)                         │
│                                                                 │
│  拿到返回的位图,你还得自己再遍历一遍 10000 个 fd:               │
│  for (int i = 0; i < 10000; i++) {                             │
│      if (FD_ISSET(i, &readfds)) {  // 是你吗?是你吗?          │
│          handle(i);                                            │
│      }                                                         │
│  }                                                             │
│                                                                 │
│  处理完后,位图被内核改过了,下次调用 select 前必须重新初始化!    │
│  FD_ZERO(&readfds);  // 清空                                   │
│  FD_SET(fd1, &readfds); // 重新加...                           │
│  ...                                                           │
│  select(...) ──────── 又是一轮全量拷贝 ────────────►            │
└─────────────────────────────────────────────────────────────────┘

select 的拷贝开销总结

步骤 拷贝内容 数据量 发生频率
进内核 全量 fd 位图 O(N),N=总连接数 每次 select 调用
出内核 全量 fd 位图 O(N) 每次 select 调用
用户态遍历 全量 fd 位图 O(N) 每次 select 返回后

致命问题:哪怕 10000 个连接里只有 1 个活跃,你每次也要把 10000 个 fd 的信息搬进搬出,还要遍历 10000 次。连接越多,CPU 越 wasted。


1.3 epoll 的革命:“持久化内核 + 按需搬运”

epoll 把架构拆成了两步epoll_ctl(注册)和 epoll_wait(等待)。

第一步:epoll_ctl —— 注册时拷贝(只发生一次)

┌─────────────────────────────────────────────────────────────────┐
│                        用户态(你的程序)                         │
│                                                                 │
│  int epfd = epoll_create1(0);  // 在内核创建一个 epoll 实例      │
│                                                                 │
│  struct epoll_event ev;                                        │
│  ev.events = EPOLLIN;                                          │
│  ev.data.fd = fd_5;                                            │
│  epoll_ctl(epfd, EPOLL_CTL_ADD, fd_5, &ev); ──── 调用 ────┐    │
└────────────────────────────────────────────────────────────┼────┘
                                                             │
            【拷贝:用户态 → 内核态】                          │
            只拷贝 1 个 fd 的信息(12字节)                    │
                                                             ▼
┌─────────────────────────────────────────────────────────────────┐
│                        内核态(操作系统)                         │
│                                                                 │
│  epoll 实例 (epfd)                                             │
│  ┌──────────────────────────────────────────────┐              │
│  │  红黑树(持久化存储在内核中!)                │              │
│  │    ├── fd_5  (EPOLLIN)                       │  ← 插入红黑树 │
│  │    ├── fd_8  (EPOLLIN|EPOLLET)               │              │
│  │    ├── fd_12 (EPOLLIN|EPOLLOUT)              │              │
│  │    └── ... (最多可存数十万个)                  │              │
│  └──────────────────────────────────────────────┘              │
│                                                                 │
│  这棵树一直存在于内核中,不会因为 epoll_wait 返回而消失!        │
└─────────────────────────────────────────────────────────────────┘

关键点

  • epoll_ctl 每次只传一个 fd 的信息,拷贝量极小(O(1))。
  • 注册进去的 fd 信息持久化保存在内核的红黑树中,不需要每次都重新传。
  • 你要加 10000 个 fd,就调 10000 次 epoll_ctl,每次拷 1 个。但后续不再重复拷贝

第二步:epoll_wait —— 只搬运“就绪的”

┌─────────────────────────────────────────────────────────────────┐
│                        内核态(操作系统)                         │
│                                                                 │
│  网卡收到 fd_5 的数据 → 内核回调函数触发 →                       │
│  将 fd_5 挂入就绪链表                                            │
│                                                                 │
│  ┌────────────────────┐    ┌─────────────────────┐             │
│  │  红黑树(全量监视)  │    │  就绪链表(仅活跃)   │             │
│  │  10000 个 fd       │    │  fd_5 (EPOLLIN)     │  ← 只有1个! │
│  │  ...               │    │  fd_99 (EPOLLIN)    │  ← 只有2个! │
│  └────────────────────┘    └─────────────────────┘             │
│                                                                 │
└───────────────────────────────────┬─────────────────────────────┘
                                    │
                                    │ epoll_wait 被调用
                                    │
            【拷贝:内核态 → 用户态】
            只拷贝就绪链表里的 fd(2个,而非10000个!)
            拷贝量 = O(K),K=活跃连接数
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────┐
│                        用户态(你的程序)                         │
│                                                                 │
│  struct epoll_event events[64];  // 用户态准备的空数组           │
│                                                                 │
│  // 调用 epoll_wait,把指针和最大数量传给内核                     │
│  int n = epoll_wait(epfd, events, 64, -1);                     │
│                                                                 │
│  // 内核把 2 个就绪事件填入 events 数组                          │
│  // n = 2                                                       │
│                                                                 │
│  for (int i = 0; i < n; i++) {   // 只遍历 2 次!               │
│      handle(events[i].data.fd);                                │
│  }                                                             │
│                                                                 │
│  // 红黑树还在内核中完好无损,下次 epoll_wait 直接复用!          │
│  // 不需要重新注册!                                             │
└─────────────────────────────────────────────────────────────────┘

epoll 的拷贝开销总结

步骤 拷贝内容 数据量 发生频率
epoll_ctl 注册 单个 fd 信息 O(1),约12字节 每个 fd 只注册一次
epoll_wait 返回 仅就绪的 fd O(K),K=活跃数 每次 wait 调用
用户态遍历 仅就绪的 fd O(K) 每次 wait 返回后

1.4 终极对比:一图看懂拷贝差异

                    select (10000连接,5个活跃)
                    ──────────────────────────
调用 select()  ──→  拷贝 10000 个 fd 进内核    【O(N) 全量进】
内核处理       ──→  遍历 10000 个 fd
select() 返回  ──→  拷贝 10000 个 fd 出内核    【O(N) 全量出】
用户遍历       ──→  遍历 10000 个 fd 找就绪    【O(N) 全量遍历】
                    总开销:30000+ 次操作,每次调用都重复!

                    epoll (10000连接,5个活跃)
                    ──────────────────────────
epoll_ctl(ADD) ──→  拷贝 1 个 fd 进内核红黑树  【O(1),只一次】
                 × 10000 次注册

epoll_wait()   ──→  传空数组指针进内核          【几乎无拷贝】
内核处理       ──→  直接查看就绪链表(5个)     【O(1) 查找】
epoll_wait返回 ──→  拷贝 5 个就绪 fd 出内核     【O(K) 仅活跃出】
用户遍历       ──→  遍历 5 个 fd               【O(K) 仅活跃遍历】
                    总开销:每次 wait 仅 10 次操作!红黑树永久复用!

关于 mmap 的澄清

⚠️ 常见误解:很多文章说“epoll 用 mmap 共享内存实现零拷贝”。

事实:早期 Linux 2.6 内核的 epoll 实现确实用过 mmap 来避免 epoll_wait 返回时的拷贝,但现代 Linux 内核已经不再使用 mmap(因为 mmap 本身的映射管理开销在 fd 少时反而更大)。现在的 epoll_wait 仍然有拷贝,但拷贝的仅仅是就绪的 fd(O(K)),而不是全量 fd(O(N)),所以开销已经微乎其微。


深度补充二:保姆级超详细注释实战代码

下面是完整的 epoll ET 模式 Echo Server,每一行都加了原理级注释,告诉你为什么这么写

/*
 * ====================================================================
 *  epoll ET(边缘触发)模式高并发 Echo Server
 *  编译:gcc -o echo_server echo_server.c
 *  运行:./echo_server
 *  测试:echo "Hello" | nc localhost 8080
 * ====================================================================
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define MAX_EVENTS 64    // epoll_wait 每次最多返回的就绪事件数
#define PORT       8080  // 监听端口
#define BUF_SIZE   4096  // 读写缓冲区大小

/*
 * ====================================================================
 *  工具函数:将文件描述符设置为非阻塞模式
 * ====================================================================
 *
 *  【为什么需要非阻塞?】
 *  在 ET(边缘触发)模式下,epoll 只在 fd 状态变化时通知一次。
 *  收到通知后,我们必须用 while 循环反复 read,直到把内核缓冲区掏空。
 *  掏空后,下一次 read 必然遇到"没数据"的情况:
 *    - 如果是阻塞模式:read 会永久挂起等待 → 线程卡死!
 *    - 如果是非阻塞模式:read 立即返回 -1,errno=EAGAIN → 我们知道掏空了,安全退出循环。
 *
 *  【fcntl 是什么?】
 *  fcntl 是 Linux 提供的文件描述符控制函数,可以修改 fd 的属性(如阻塞/非阻塞)。
 */
int set_nonblocking(int fd) {
    // F_GETFL:获取 fd 当前的标志位(如 O_RDONLY, O_NONBLOCK 等)
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl F_GETFL");
        return -1;
    }
    // F_SETFL:设置新的标志位,用 | O_NONBLOCK 在原有标志上"追加"非阻塞标志
    if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
        perror("fcntl F_SETFL");
        return -1;
    }
    return 0;
}

int main() {
    int listen_fd, epfd, conn_fd;
    struct sockaddr_in serv_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    struct epoll_event ev, events[MAX_EVENTS];

    /* ================================================================
     *  第一步:创建监听 socket
     * ================================================================
     *
     *  socket() 创建一个"通信端点",返回一个文件描述符(fd)。
     *  在 Linux 中,"一切皆文件",socket 也是一个 fd,可以被 epoll 监视。
     *
     *  AF_INET     = IPv4 协议族
     *  SOCK_STREAM = TCP(面向连接、可靠、字节流)
     *  0           = 自动选择协议(TCP)
     */
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    /*
     *  【为什么设置 SO_REUSEADDR?】
     *  当你 Ctrl+C 杀掉服务器后,端口会进入 TIME_WAIT 状态(持续约60秒)。
     *  这期间再次 bind 同一端口会报 "Address already in use" 错误。
     *  SO_REUSEADDR 允许在 TIME_WAIT 状态下重新绑定端口,方便开发调试。
     */
    int opt = 1;
    if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) == -1) {
        perror("setsockopt SO_REUSEADDR");
        exit(EXIT_FAILURE);
    }

    // 配置服务器地址结构
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;           // IPv4
    serv_addr.sin_addr.s_addr = INADDR_ANY;   // 监听所有网卡(0.0.0.0)
    serv_addr.sin_port = htons(PORT);         // 端口号,htons 转换为主机字节序→网络字节序

    // 将 socket 绑定到 IP:Port
    if (bind(listen_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

    /*
     *  listen() 让 socket 进入"监听"状态,准备接受客户端连接。
     *  参数 128 = backlog,即内核为此 socket 维护的"已完成三次握手但还没被 accept 的连接队列"最大长度。
     *  当队列满时,新的 SYN 包会被丢弃(客户端会重试)。
     */
    if (listen(listen_fd, 128) == -1) {
        perror("listen failed");
        exit(EXIT_FAILURE);
    }

    /*
     *  【关键!】将 listen_fd 也设置为非阻塞!
     *
     *  为什么 listen_fd 也要非阻塞?
     *  在 ET 模式下,epoll 通知"有新连接"时,可能有多个连接同时到达。
     *  我们需要用 while 循环反复 accept,直到没有更多新连接。
     *  当所有新连接都被 accept 后,下一次 accept 会遇到"没有新连接"的情况:
     *    - 阻塞模式:accept 会卡住等待 → 线程死锁!
     *    - 非阻塞模式:accept 返回 -1,errno=EAGAIN → 安全退出循环。
     */
    if (set_nonblocking(listen_fd) == -1) {
        exit(EXIT_FAILURE);
    }

    /* ================================================================
     *  第二步:创建 epoll 实例
     * ================================================================
     *
     *  epoll_create1() 在内核中创建一个 epoll 实例(包含红黑树 + 就绪链表)。
     *  返回一个 epfd(epoll 文件描述符),后续所有操作都通过这个 epfd 进行。
     *
     *  EPOLL_CLOEXEC 标志:
     *  如果当前进程调用 exec() 启动一个新程序,epfd 会被自动关闭。
     *  防止 fd 泄露给子进程(安全最佳实践)。
     */
    epfd = epoll_create1(EPOLL_CLOEXEC);
    if (epfd == -1) {
        perror("epoll_create1 failed");
        exit(EXIT_FAILURE);
    }

    /* ================================================================
     *  第三步:将 listen_fd 注册到 epoll 中
     * ================================================================
     *
     *  epoll_ctl() 用于向 epoll 实例的红黑树中 添加/修改/删除 fd。
     *
     *  参数说明:
     *    epfd           - epoll 实例
     *    EPOLL_CTL_ADD  - 操作类型:添加
     *    listen_fd      - 要监视的目标 fd
     *    &ev            - 要监视的事件
     *
     *  ev.events = EPOLLIN | EPOLLET:
     *    EPOLLIN  - 监视"可读"事件(对 listen_fd 来说,"可读"意味着有新连接到达)
     *    EPOLLET  - 使用边缘触发(ET)模式(只在状态变化时通知一次)
     *
     *  ev.data.fd = listen_fd:
     *    当这个 fd 就绪时,epoll_wait 会通过 events[i].data.fd 告诉我们是谁。
     *    也可以用 ev.data.ptr 传一个自定义的结构体指针(在复杂项目中更常用)。
     */
    ev.events = EPOLLIN | EPOLLET;
    ev.data.fd = listen_fd;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev) == -1) {
        perror("epoll_ctl add listen_fd failed");
        exit(EXIT_FAILURE);
    }

    printf("=== Echo Server started on port %d (ET + Non-blocking) ===\n", PORT);

    /* ================================================================
     *  第四步:事件主循环(核心!)
     * ================================================================
     *
     *  这是所有 epoll 服务器的心脏:
     *  1. epoll_wait 阻塞等待,直到有 fd 就绪
     *  2. 遍历返回的就绪事件,分发处理
     *  3. 处理完后回到步骤 1
     */
    while (1) {
        /*
         *  epoll_wait() 参数说明:
         *    epfd       - epoll 实例
         *    events     - 用户态提供的空数组,内核将就绪事件填入此数组
         *    MAX_EVENTS - 本次最多返回多少个事件(64个)
         *    -1         - 超时时间(毫秒),-1 表示永久阻塞,直到有事件才返回
         *
         *  【内核在这里做了什么?】
         *  1. 用户态传入 events 数组的指针(几乎无拷贝开销)
         *  2. 内核检查就绪链表:
         *     - 如果链表为空 → 进程进入睡眠(不消耗 CPU)
         *     - 如果链表有数据 → 将就绪事件拷贝到 events 数组中
         *  3. 返回就绪事件的数量 n
         *
         *  【为什么可能被信号中断?】
         *  如果进程在 epoll_wait 睡眠时收到信号(如 SIGINT),
         *  会提前返回 -1,errno 设为 EINTR。这不是错误,应该 continue 重新等待。
         */
        int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (n == -1) {
            if (errno == EINTR) {
                // 被信号中断,不是错误,继续等待
                continue;
            }
            perror("epoll_wait failed");
            break;
        }

        // 遍历所有就绪事件(只遍历 n 个,不是全部 fd!这就是 epoll 高效的原因)
        for (int i = 0; i < n; i++) {

            /* ============================================================
             *  场景 A:listen_fd 就绪 → 有新客户端连接到达
             * ============================================================
             */
            if (events[i].data.fd == listen_fd) {

                /*
                 *  【ET 模式下的 accept 必须循环调用!】
                 *
                 *  为什么?
                 *  ET 模式只通知一次。如果在 epoll_wait 返回前,
                 *  有多个客户端同时完成了三次握手(内核积压了多个连接),
                 *  epoll 只会通知一次"listen_fd 可读"。
                 *  如果我们只 accept 一次,剩余的连接就会"积压"在内核中,
                 *  且不会再次收到通知(直到有新的连接到来触发新的边沿)。
                 *
                 *  所以必须用 while 循环 accept,直到返回 EAGAIN(没有更多新连接)。
                 */
                while (1) {
                    conn_fd = accept(listen_fd,
                                     (struct sockaddr*)&client_addr,
                                     &client_len);

                    if (conn_fd == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            /*
                             *  EAGAIN/EWOULDBLOCK 的含义:
                             *  "当前没有更多待 accept 的连接了"
                             *  这不是错误!说明所有积压的连接都已经被 accept 完了。
                             *  安全退出循环。
                             */
                            break;
                        }
                        // EINTR:accept 被信号中断,重试即可
                        if (errno == EINTR) {
                            continue;
                        }
                        // 其他错误(如 EMFILE:fd 用完了)
                        perror("accept failed");
                        break;
                    }

                    // accept 成功!拿到了一个新的 conn_fd

                    /*
                     *  【关键!】新连接的 fd 也必须设置为非阻塞!
                     *
                     *  accept 返回的新 fd 默认是阻塞的。
                     *  如果不设为非阻塞,后续 ET 模式下的 read 循环会在掏空缓冲区后卡死。
                     *  这是最常见的 Bug 之一!
                     */
                    if (set_nonblocking(conn_fd) == -1) {
                        close(conn_fd);
                        continue;
                    }

                    /*
                     *  将新连接注册到 epoll 红黑树中
                     *
                     *  监视 EPOLLIN(可读) + EPOLLET(边缘触发)
                     *  当这个客户端发送数据时,epoll 会通知我们
                     */
                    ev.events = EPOLLIN | EPOLLET;
                    ev.data.fd = conn_fd;
                    if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev) == -1) {
                        perror("epoll_ctl add conn_fd failed");
                        close(conn_fd);
                        continue;
                    }

                    printf("[+] New connection: %s:%d (fd=%d)\n",
                           inet_ntoa(client_addr.sin_addr),
                           ntohs(client_addr.sin_port),
                           conn_fd);
                }
            }

            /* ============================================================
             *  场景 B:普通客户端 fd 就绪 → 有数据可读
             * ============================================================
             */
            else if (events[i].events & EPOLLIN) {
                int fd = events[i].data.fd;
                char buf[BUF_SIZE];

                /*
                 *  【ET 模式下的 read 必须循环调用!】(核心铁律)
                 *
                 *  为什么?
                 *  ET 模式只在"内核缓冲区从无数据→有数据"的瞬间通知一次。
                 *  假设内核缓冲区有 10KB 数据,但我们的 buf 只有 4KB:
                 *    - 第一次 read:读走 4KB,缓冲区还剩 6KB
                 *    - 如果我们不继续读,直接退出:
                 *      缓冲区仍有 6KB 数据,但 epoll 不会再次通知!
                 *      (因为状态没有发生"边沿跳变",数据一直在那里)
                 *      这 6KB 数据就"死"了,永远读不到。
                 *
                 *  所以必须循环 read,直到返回 EAGAIN(缓冲区彻底掏空)。
                 */
                while (1) {
                    ssize_t nread = read(fd, buf, sizeof(buf));

                    if (nread > 0) {
                        /*
                         *  读到数据了!
                         *  Echo Server:把收到的数据原样写回去。
                         *
                         *  【注意】生产环境中,write 也可能不能一次写完,
                         *  需要处理"部分写"的情况(注册 EPOLLOUT 事件继续写)。
                         *  这里为了简化,假设一次 write 能写完。
                         */
                        ssize_t nwrite = write(fd, buf, nread);
                        if (nwrite == -1) {
                            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                                /*
                                 *  发送缓冲区满了,写不下了。
                                 *  正确的做法是:注册 EPOLLOUT 事件,
                                 *  等内核缓冲区有空间时再继续写。
                                 *  这里简化处理,直接打印警告。
                                 */
                                fprintf(stderr, "[!] Write buffer full on fd=%d\n", fd);
                                break;
                            }
                            perror("write failed");
                            break;
                        }
                    }
                    else if (nread == 0) {
                        /*
                         *  read 返回 0 的特殊含义:
                         *  对端(客户端)正常关闭了连接(发了 FIN 包)。
                         *  我们需要:
                         *  1. 从 epoll 红黑树中移除这个 fd
                         *  2. close 关闭这个 fd(释放内核资源)
                         */
                        printf("[-] Client disconnected: fd=%d\n", fd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                        break;
                    }
                    else {
                        // nread == -1,出错了
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            /*
                             *  【ET 模式的安全退出点!】
                             *
                             *  EAGAIN 的含义:
                             *  "内核接收缓冲区已经被掏空了,暂时没有更多数据"
                             *
                             *  这就是非阻塞 I/O 的价值所在!
                             *  它让我们知道"数据读完了",可以安全地 break 退出循环,
                             *  回到 epoll_wait 等待下一次通知。
                             *
                             *  如果是阻塞模式,read 在这里不会返回 EAGAIN,
                             *  而是会永久挂起等待 → 线程卡死!
                             */
                            break;
                        }
                        else if (errno == EINTR) {
                            // 被信号中断,重新读
                            continue;
                        }
                        else {
                            // 真正的错误(如连接被重置 RST)
                            perror("read failed");
                            epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                            close(fd);
                            break;
                        }
                    }
                }
            }

            /* ============================================================
             *  场景 C:fd 发生错误或断开
             * ============================================================
             *
             *  EPOLLERR:fd 上发生了错误(如 TCP 连接异常)
             *  EPOLLHUP:对端挂断(如对方直接拔网线、进程崩溃)
             *
             *  注意:即使你没有在 ev.events 中注册这两个事件,
             *  epoll 也会在发生错误时自动返回它们。
             */
            else if (events[i].events & (EPOLLERR | EPOLLHUP)) {
                int fd = events[i].data.fd;
                printf("[!] Error/Hangup on fd=%d\n", fd);

                // 从 epoll 中移除
                epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                // 关闭 fd,释放资源
                close(fd);
            }
        }
    }

    /* ================================================================
     *  清理资源
     * ================================================================
     *
     *  epfd 也是一个文件描述符,必须 close 释放!
     *  不关闭会导致 fd 泄露(虽然进程退出时内核会自动回收)。
     */
    close(epfd);
    close(listen_fd);
    printf("Server shutdown.\n");
    return 0;
}

总结回顾

问题 核心答案
select 为什么每次都要拷贝? 因为 select 没有"持久化"机制,每次调用都是一个独立函数,必须把全量 fd 集合传给内核,内核改完再传回来。
epoll 怎么做到"只拷贝一次"? epoll_ctl 把 fd 注册进内核红黑树,红黑树持久化在内核中,后续 epoll_wait 不再需要传全量 fd。epoll_wait 只把就绪链表中的少量活跃 fd 拷贝回用户态。
ET 模式为什么 accept 也要循环? 和 read 同理:ET 只通知一次,如果同时来了 5 个连接,只 accept 一次会漏掉 4 个。
非阻塞 I/O 在 ET 模式中的角色? 它是循环的安全退出机制:当缓冲区/连接队列为空时,非阻塞的 read/accept 返回 EAGAIN,告诉程序"掏空了,可以走了",而不是卡死。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐