12.总结:深入理解 Linux I/O 多路复用:select、poll、epoll 全解析
文章目录
深入理解 Linux I/O 多路复用:select、poll、epoll 全解析
适合人群:有一定 C/C++ 和网络编程基础,想系统掌握 Linux 高并发 I/O 底层机制的开发者。
阅读目标:一文搞懂 select → poll → epoll 的演进脉络、API 用法、触发模式、底层原理与实战代码。
目录
- 为什么需要 I/O 多路复用?
- 第一代方案:select
- 过渡方案:poll
- 终极方案:epoll
- epoll 核心 API 详解
- epoll 底层数据结构
- 触发模式:LT vs ET
- ET 模式铁律:为何必须搭配非阻塞 I/O?
- 三者核心区别速查表
- epoll 实战代码:高并发 Echo Server
- epoll 的惊群问题
- 常见面试题 & 避坑指南
- 总结
一、为什么需要 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 类 | 更丰富(POLLIN、POLLPRI 等) |
| 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)+ 双向链表(就绪队列) |
核心机制:
- 用
epoll_ctl把 fd 注册到内核的红黑树中。 - 当某个 fd 上有事件发生(如网卡收到数据),内核通过回调函数(
ep_poll_callback)将该 fd 挂入就绪链表。 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 |
新版标志位,可设为 0 或 EPOLL_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_ctlADD/MOD/DEL 时操作,负责管理所有被监视的 fd。 - 就绪链表:当 fd 上有事件发生时,内核通过回调函数将 fd 挂入就绪链表;
epoll_wait从就绪链表中取出就绪事件返回给用户。
Q2: ET 模式下,如果 read 没读完会怎样?
不会丢失数据(数据还在内核缓冲区),但不会再收到通知!直到有新的数据到达(新的状态跳变)才会再次触发。所以 ET 模式下必须一次性读完。
Q3: EPOLLONESHOT 和 EPOLLET 有什么区别?
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 系统分为用户态(你的程序运行的地方)和内核态(操作系统内核运行的地方)。
- 你的程序(用户态)不能直接访问内核的数据结构。
- 内核(内核态)不能直接访问你程序的变量。
- 当你的程序调用系统函数(如
select、epoll_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,告诉程序"掏空了,可以走了",而不是卡死。 |
更多推荐

所有评论(0)