【网络编程】IO模型
深入理解 IO 模型:从本质到 epoll
一、什么是 IO?IO 的本质是什么?
IO(Input / Output),即输入与输出,是计算机系统中数据流动的核心抽象。
在操作系统的视角下,IO 描述的是外部设备与内存之间的数据传输。这里的外部设备可以是磁盘、网卡、键盘、显示器等一切内存之外的东西。
IO 的本质:等 + 拷贝
IO 操作可以拆解为两个关键阶段:
-
等待(Wait):等待数据从外部设备到达内核缓冲区。比如等网卡收到数据、等磁盘磁头定位到目标扇区。这个阶段进程通常无事可做,只是等待硬件准备就绪。
-
拷贝(Copy):将数据从内核缓冲区复制到用户态内存(对于输入),或从用户态内存复制到内核缓冲区(对于输出)。
二、什么叫做高效的 IO?如何设计高效的 IO?
衡量 IO 效率的核心指标是:在单位时间内,进程实际处理了多少有效数据。
基于 IO 的本质(等 + 拷贝),我们可以推导出一个直观的结论:
等的比重越低,IO 效率越高。
因为"拷贝"是真实的数据搬运,是有价值的工作;而"等"是闲置和浪费。一个理想的 IO 模型,应当让进程在等待阶段尽可能地不阻塞、不被空耗,从而把 CPU 时间留给真正有意义的事情。
降低"等"的比重的核心思路有两个方向:
- 让一次等待能覆盖多个 IO 源(比如多路复用,一个线程同时监听成百上千个连接)。
- 让进程在等待时不被阻塞,可以去干别的活(比如非阻塞 IO + 轮询,或者异步 IO,内核在数据准备好后直接通知进程来取)。
不同的 IO 模型在这两个方向上做了不同的取舍,这也是接下来要讨论的五种常见 IO 模型。
三、五种常见的 IO 模型
在 Unix/Linux 系统中,常见的 IO 模型可以按发起者是否参与 IO 过程分为两大类:同步 IO 和 异步 IO。
同步 IO(Synchronous IO)
同步 IO 的特点是:发起 IO 的进程(线程)必须亲自参与 IO 的等和/或拷贝过程。 即在数据从内核到用户态的拷贝阶段,进程是参与其中的(通常表现为进程在某个系统调用中阻塞或主动轮询)。
同步 IO 包含以下四种模型:
1. 阻塞 IO(Blocking IO)
这是最常见、最传统的 IO 模型。
进程调用 read() 或 recv() 后,如果内核数据尚未准备好:
- 进程会挂起(休眠),让出 CPU,进入阻塞状态。
- 当数据到达内核缓冲区,内核将数据拷贝到用户空间后,系统调用返回,进程恢复运行。
整个过程进程始终在等待,无法做别的事情。这是"等"的比重最高的一种模型。
特点:编程模型最简单,但每个连接需要独占一个线程,线程切换开销大,不适合高并发场景。
应用进程 内核
| |
|——— recvfrom() ———————————> | 无数据就绪
| | 进程阻塞(休眠)
| ⏸ 等待数据... | 等待数据到达
| | 数据就绪
| | 拷贝数据到用户空间
|<—— 返回数据 ————————————————| 拷贝完成
| |
| 处理数据 |
2. 非阻塞 IO(Non-blocking IO)
进程将文件描述符设置为非阻塞模式后,调用 read():
- 如果内核数据还没准备好,系统调用立即返回一个错误(
EWOULDBLOCK或EAGAIN),而不是阻塞进程。 - 进程收到错误后可以继续执行其他任务,然后**轮询(polling)**再次尝试读取。
- 当某次轮询发现数据已就绪,内核执行数据拷贝,系统调用成功返回。
特点:进程在等待阶段不会被挂起,但需要反复轮询,浪费 CPU 资源。轮询间隔难以权衡:间隔太短则 CPU 空转严重,间隔太长则响应延迟变大。
应用进程 内核
| |
|——— recvfrom() ———————————> | 无数据就绪
|<—— EWOULDBLOCK ————————————| 立即返回错误
| |
| 干其他事情/等待一会 |
| |
|——— recvfrom() ———————————> | 无数据就绪
|<—— EWOULDBLOCK ————————————| 立即返回错误
| |
| ...多次轮询... |
| |
|——— recvfrom() ———————————> | 数据就绪
| | 拷贝数据到用户空间
|<—— 返回数据 ————————————————| 拷贝完成
3. 信号驱动 IO(Signal-driven IO)
信号驱动 IO 的核心思想是:用信号通知代替主动轮询。
进程通过 sigaction() 注册 SIGIO 信号处理函数(或用 fcntl() 设置 F_SETOWN),然后就可以继续执行自己的业务逻辑。当内核数据准备就绪时,内核向进程发送 SIGIO 信号,进程在信号处理函数中调用 recv() 完成数据拷贝。
特点:等待阶段进程完全不被阻塞,也不用轮询,CPU 利用率高。但信号的异步特性使得编程模型复杂(信号处理函数中可做的操作受限),且信号队列有上限,高并发下可能丢失通知。实际使用相对较少。
应用进程 内核
| |
|——— 注册 SIGIO 信号处理 ———> |
| |
| 继续执行其他任务 |
| (不被阻塞) |
| | 数据就绪
|<—— SIGIO 信号 ——————————————| 内核发送信号
| |
| 在信号处理函数中: |
|——— recvfrom() ———————————> |
| | 拷贝数据到用户空间
|<—— 返回数据 ————————————————| 拷贝完成
4. IO 多路复用(IO Multiplexing)
多路复用是目前后端高并发服务器最主流的 IO 模型。
核心思路:用一个系统调用(select / poll / epoll)同时监听多个文件描述符,当至少一个 fd 就绪时,系统调用返回,然后进程对就绪的 fd 逐个调用 read() 完成数据拷贝。
分工机制:
- 等:由
select/poll/epoll负责,一次性等待多个 fd,检测哪些 fd 的事件已就绪。 - 拷贝:由
read()/recv()/recvfrom()负责,将就绪 fd 的数据从内核拷贝到用户空间。
特点:一个线程可以同时管理成百上千个连接,极大地降低了"等"的比重。但本质上仍然是同步 IO——因为"拷贝"阶段线程仍然要亲自参与(调用 read 阻塞拷贝数据)。
应用进程 内核
| |
|——— select() —————————————> | 监听 fd1, fd2, ..., fdN
| | 无任何 fd 就绪
| ⏸ 阻塞等待... | 进程阻塞在 select 上
| | fd2 数据到达
|<—— select 返回 fd2 就绪 ———|
| |
|——— recv(fd2) ————————————> |
| | 拷贝数据到用户空间
|<—— 返回数据 ————————————————| 拷贝完成
| |
| 处理 fd2 的数据 |
异步 IO(Asynchronous IO)
异步 IO 的定义非常严格:发起者只负责发起 IO 操作,完全不参与等和拷贝的过程。
进程调用 aio_read() 后立即返回,可以继续执行其他任务。内核不仅负责等待数据就绪,还负责将数据从内核缓冲区拷贝到用户空间。当这一切完成后,内核通知进程(通过信号或回调函数),进程直接拿到已经拷贝好的数据即可使用。
关键区别:
| 维度 | 同步 IO(含多路复用) | 异步 IO |
|---|---|---|
| 等待阶段 | 进程参与(阻塞/轮询/被通知后还是要自己去拷贝) | 内核全权负责 |
| 拷贝阶段 | 进程亲自执行 read() 完成拷贝 |
内核自动完成,拷贝完后通知进程 |
| 进程角色 | 发起 + 参与 | 发起 + 接收结果 |
在 Linux 上,真正的异步 IO 实现是原生 AIO(io_submit / io_getevents)以及更现代的 io_uring。io_uring 通过共享内存环形队列实现极高性能的异步 IO,是近年 Linux 内核 IO 子系统中最重要的演进之一。
特点:理论效率最高——进程完全被解放。但 Linux 上 AIO 长期不够成熟(对 buffered IO 支持差),直到 io_uring 才真正让异步 IO 在生产环境大规模落地。
应用进程 内核
| |
|——— aio_read() ——————————> | 发起异步读请求
|<—— 立即返回 —————————————————|
| |
| 继续执行其他任务 | 等待数据就绪
| (完全不被阻塞) | 数据到达,自动拷贝到用户空间
| |
|<—— 信号/回调通知 ————————————| 一切就绪,通知进程
| |
| 直接使用已准备好的数据 |
五种模型小结
| IO 模型 | 等待阶段 | 拷贝阶段 | 进程阻塞点 | 适用场景 |
|---|---|---|---|---|
| 阻塞 IO | 进程阻塞等待 | 进程自己拷贝 | read() | 简单低并发 |
| 非阻塞 IO | 进程轮询 | 进程自己拷贝 | 无(但 CPU 空转) | 已基本被多路复用取代 |
| 信号驱动 IO | 内核通知 | 进程自己拷贝 | 无 | 较少使用 |
| IO 多路复用 | 进程阻塞在 select/poll/epoll | 进程自己拷贝 | select/poll/epoll + read() | 高并发服务器主流方案 |
| 异步 IO | 内核全权负责 | 内核自动完成 | 无 | 极致性能场景(io_uring) |
关键认知:前四种模型的核心差异在"等待阶段",而"拷贝阶段"进程都要亲自参与——所以它们都属于同步 IO。唯有异步 IO 将两个阶段都委托给内核,进程只关心最终结果。
四、阻塞与非阻塞:深入文件描述符
在讨论 IO 模型时,"阻塞"与"非阻塞"这一对概念,精确地说,是**针对于文件描述符(File Descriptor)**的属性。
每个打开的文件(含 socket)在内核中对应一个 struct file 对象,其中包含了该文件的操作方法和状态标志。通过 fcntl() 系统调用,我们可以修改 fd 的 O_NONBLOCK 标志:
// 获取当前标志
int flags = fcntl(fd, F_GETFL, 0);
// 设置为非阻塞
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
// 恢复为阻塞
fcntl(fd, F_SETFL, flags & ~O_NONBLOCK);
当一个 fd 被设为非阻塞后,对其执行 read() / write() / accept() 等操作时:
- 如果操作可以立即完成(数据就绪),则正常执行。
- 如果操作无法立即完成(没有数据可读 / 缓冲区满),则立即返回
-1,并设置errno为EAGAIN或EWOULDBLOCK,而不是阻塞进程。
这个机制是非阻塞 IO 和多路复用(配合 epoll ET 模式)的基础。
五、IO 多路复用深度解析
IO 多路复用是后端开发最核心的 IO 技术,也是面试的高频考点。Linux 提供了三代接口:select → poll → epoll。每一代都在解决上一代的痛点。
5.1 select
select 是多路复用的开山之作,其函数原型为:
#include <sys/select.h>
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
参数详解
- nfds:所有被监听的文件描述符中,最大值 + 1。内核需要知道遍历 fd 时的范围上限。
- readfds:关心的读事件 fd 集合。
- writefds:关心的写事件 fd 集合。
- exceptfds:关心的异常事件 fd 集合。
- timeout:
NULL:阻塞等待,直到至少有一个 fd 就绪。{0, 0}:不等待,立即返回(纯非阻塞检查)。- 特定时间值:最多等待指定时间,超时返回 0。
fd_set 本质是一个位图(bitmap),默认大小通常为 1024 位,因此 select 最多只能监听 1024 个 fd(FD_SETSIZE 宏定义)。操作 fd_set 需要使用一组宏:
FD_ZERO(&set); // 清空集合
FD_SET(fd, &set); // 将 fd 加入集合
FD_CLR(fd, &set); // 将 fd 移除集合
FD_ISSET(fd, &set); // 检查 fd 是否在集合中(返回时就绪判断)
返回值
> 0:有就绪 fd 的个数。= 0:超时,没有 fd 就绪。< 0:出错(如被信号中断,errno == EINTR)。
文件 fd 的事件类型
一个 fd 上的事件主要有三类:
- 读事件就绪:有数据可读,或对端关闭连接(
read()返回 0)。 - 写事件就绪:发送缓冲区有空间,可以写入数据。
- 异常事件就绪:带外数据(OOB)到达等。
select 的工作原理
select 底层的核心逻辑是遍历检测:
- 内核将用户传入的三个 fd_set 从用户态拷贝到内核态。
- 对
0 ~ nfds-1范围内的每个 fd,调用其struct file中挂载的poll函数指针(file->f_op->poll),进行非阻塞检查,判断该 fd 是否有事件就绪。 - 关键行为:只有当所有传入的 fd 都没有就绪事件时,进程才会调用
schedule()进入休眠(阻塞)。哪怕只有一个 fd 就绪,select 都会立刻返回,不会阻塞。 - 当某个 fd 上发生了 I/O 事件(如网卡收到数据),内核的中断处理程序会唤醒等待队列中的进程,进程被唤醒后重新遍历所有 fd,找出就绪的 fd,设置对应的 fd_set 位,然后返回用户态。
select 的核心循环伪代码:
for (;;) {
for (i = 0; i < nfds; i++) {
if (fd_is_in_set(i, readfds)) {
// 调用文件自身的 poll 方法进行非阻塞检测
if (file->f_op->poll(file, &wait)) {
ready_count++;
set_bit(i, res_readfds); // 标记为就绪
}
}
}
if (ready_count > 0)
return ready_count; // 有事,立刻返回
if (timeout_expired)
return 0;
// 都没就绪——进入休眠,等待被 I/O 事件唤醒
schedule(); // 进程调度,让出 CPU
}
select 的优点
- 聚合 fd,提高 IO 效率:一个线程可以同时监听多个 fd,比每个 fd 占用一个线程的阻塞 IO 模型高效得多。
- 跨平台:几乎所有 Unix-like 系统都支持 select,可移植性最好。
select 的缺点
- fd 数量上限:受
FD_SETSIZE(默认 1024)限制,无法承载高并发。 - 参数是值-结果类型:每次调用都需要将 fd_set 从用户态拷贝到内核态,返回时又被内核修改(覆盖写),因此每次循环都必须重新设置整个 fd_set,接口使用非常不便。
- O(n) 遍历:用户层拿到返回后需要遍历所有 fd 用
FD_ISSET找出就绪者;内核层也需要遍历全部 fd 做 poll 检测。当 fd 数量很大而实际就绪的很少时,大量 CPU 被浪费在遍历上。 - 每次调用都重新拷贝 fd_set:对于一个需要反复调用的 select 循环,每次调用都要把 fd_set 从用户态拷贝到内核态,这个开销随 fd 数量增加而增大。
- 参数耦合:输入(关心的 fd)和输出(就绪的 fd)共享同一参数,设计上不够正交。
5.2 poll
poll 是对 select 的直接改进,主要解决了两个痛点:fd 数量上限和输入输出参数耦合。
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
pollfd 结构体
struct pollfd {
int fd; // 要监听的文件描述符
short events; // 用户感兴趣的事件(输入参数,由用户设置)
short revents; // 实际发生的事件(输出参数,由内核填充)
};
事件标志(常用):
| 事件常量 | 含义 | 可用于 events | 可用于 revents |
|---|---|---|---|
POLLIN |
数据可读(包括对端关闭) | ✅ | ✅ |
POLLOUT |
数据可写 | ✅ | ✅ |
POLLERR |
发生错误 | ❌ | ✅ |
POLLHUP |
连接挂起(对端关闭) | ❌ | ✅ |
POLLNVAL |
fd 未打开 | ❌ | ✅ |
POLLPRI |
带外数据可读 | ✅ | ✅ |
参数说明
- fds:
pollfd结构体数组的指针,每个元素描述一个被监听的 fd。 - nfds:数组长度。
- timeout(毫秒):
-1:阻塞等待,直到有事件发生。0:立即返回(非阻塞检查)。> 0:最多等待该毫秒数。
poll 相比 select 的改进
- 移除 fd 数量上限:用动态数组代替
fd_set位图,理论上可以监听任意数量的 fd(上限取决于系统资源)。 - 输入输出分离(代码解耦):
events是输入(用户关心什么),revents是输出(内核告诉你实际发生了什么)。每次调用后无需重新构造监听集合,只需重置revents字段。
poll 仍需面对的问题
尽管 poll 解决了 select 最痛的两个问题,但它并没有改变底层的检测机制——内核依然通过遍历整个 fds 数组、对每个 pollfd 调用文件 poll 来检测就绪事件。所以当 fd 数量很大时,O(n) 的遍历开销依然是瓶颈。
更重要的是,poll 和 select 共享一个根本性的性能问题:
每次调用都需要将整个监听集合从用户态拷贝到内核态,返回时还需要遍历整个集合才知道哪些 fd 就绪了。
这个 O(n) 的复杂度在面对上万并发连接时,会变得不可接受。
5.3 epoll
epoll 是 Linux 2.6 内核引入的终极解决方案,是 select/poll 的彻底革新。
核心理念
epoll 的设计哲学是一个关键的思想转变:
把"用户主动轮询"的工作量,转嫁给"内核主动通知和事件管理"。
select/poll 的模式是"每次你问我一次,我把所有 fd 都检查一遍再告诉你"。而 epoll 的模式是"你先告诉我你要关注哪些 fd,有事件了我主动通知你哪些 fd 就绪了"。
三个核心函数
epoll 的接口由三个函数组成:
#include <sys/epoll.h>
// 1. 创建 epoll 实例,返回 epoll fd
int epoll_create(int size); // size 参数已废弃,填任意正数即可
int epoll_create1(int flags); // 推荐:flags 可设为 EPOLL_CLOEXEC
// 2. 向 epoll 实例中添加/修改/删除要监听的 fd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 3. 等待事件就绪
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_ctl 的操作类型:
| op | 含义 |
|---|---|
EPOLL_CTL_ADD |
注册新的 fd 到 epoll 实例 |
EPOLL_CTL_MOD |
修改已注册 fd 的监听事件 |
EPOLL_CTL_DEL |
从 epoll 实例中删除一个 fd |
epoll_event 结构体:
struct epoll_event {
uint32_t events; // 关心的事件(EPOLLIN, EPOLLOUT, etc.)
epoll_data_t data; // 用户数据(union,可存 fd 或指针)
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
epoll 的核心优势
1. 事件驱动,O(1) 就绪通知
这是 epoll 对 select/poll 最本质的超越。epoll 内部使用**红黑树(rbtree)存储所有注册的 fd,使用就绪链表(rdllist)**存储已经就绪的 fd。
epoll 实例内部结构:
┌──────────────────────────────┐
│ epoll 实例 │
│ │
│ 红黑树(rbtree) │
│ ├─ fd=5 ── wait_queue │ ← 注册的所有 fd
│ ├─ fd=8 ── wait_queue │
│ ├─ fd=12 ── wait_queue │
│ └─ ... │
│ │
│ 就绪链表(rdllist) │
│ ├─ fd=5 (EPOLLIN) │ ← 只有就绪的 fd
│ └─ fd=12 (EPOLLOUT) │
│ │
│ 等待队列(wq) │
│ └─ 阻塞在此 epoll 上的进程 │
└──────────────────────────────┘
工作流程如下:
epoll_create:创建 epoll 实例,初始化红黑树和就绪链表。epoll_ctl(ADD):将 fd 插入红黑树,并注册回调函数。当该 fd 有事件到达时(通过中断),内核会调用这个回调函数,把该 fd 加入就绪链表。epoll_wait:仅检查就绪链表是否为空。如果非空,直接返回就绪的 fd;如果为空,进程进入休眠,等待事件回调将其唤醒。
这意味着 epoll_wait 不需要遍历所有 fd,它的时间复杂度与就绪 fd 的数量成正比 O(ready),而不是与注册 fd 的总数成正比 O(total)。对于绝大多数时间只有少量 fd 就绪的高并发场景(这就是实际情况),这是巨大的性能提升。
2. 事件注册与等待解耦
select/poll 每次调用都需要传入完整的监听集合,每次都要拷贝。epoll 用 epoll_ctl 一次性注册到内核,之后 epoll_wait 调用不再需要传递 fd 集合,数据只拷贝一次(精确地说是增删改时各拷贝一次,而不是每次等待都拷贝)。
3. 无需重置监听集合
select 返回时会破坏输入参数,每次循环都要重建 fd_set。epoll 的 events 参数被 epoll_wait 纯粹用作输出缓冲区,而注册信息始终由内核的红黑树维护,完全不需要应用层操心。
重要概念区分
检测事件就绪 和 检测就绪事件 是两码事。
前者是查文件描述符是否有事件就绪。
后者是查文件描述符有哪件事情就绪。
5.4 epoll 的工作模式(通知模式)
epoll 有两种触发模式,对编写高性能网络程序来说,这是必须理解的关键概念。
LT 模式(Level Triggered,水平触发)
默认模式。 只要 fd 的读/写缓冲区满足条件,epoll_wait 就会一直通知。
- 读:只要接收缓冲区有数据,每次 epoll_wait 都会返回该 fd 的读事件。
- 写:只要发送缓冲区有空闲空间,每次 epoll_wait 都会返回该 fd 的写事件。
行为类比:像一个水满警报器——只要水位高于警戒线,它就持续报警,直到水位降下去。
// LT 模式下典型的读处理(可以用阻塞 read)
while (running) {
nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));
// LT 模式:即使没读完,下次 epoll_wait 还会通知
// 所以可以使用阻塞 read,甚至每次只读一个字节也没关系
}
}
}
ET 模式(Edge Triggered,边缘触发)
设置 event.events |= EPOLLET 启用。只在 fd 的状态发生变化(从无到有、从不可读到可读等"边沿")时,通知一次。
- 读:只有在新数据到达的那一刻通知一次。如果读完了一部分但没读完,剩余数据不会触发新的通知(因为状态没有改变——缓冲区一直有数据)。
- 写:只有在发送缓冲区从满变为非满的那一刻通知一次。
行为类比:像一个门铃——只在你按下的瞬间响一声,之后不管你站门口多久,它不会一直响。
// ET 模式下正确的读处理(必须用非阻塞 read + 循环读到 EAGAIN)
while (running) {
nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
// ET 模式:必须在一次通知中把所有数据读干净
while (1) {
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) {
// 内核缓冲区已空,全部读完
break;
}
if (n <= 0) {
// 出错或对端关闭
break;
}
// 处理数据
}
}
}
}
5.5 ET 模式深入问答
Q1:为什么 ET 模式下文件描述符必须被设置成非阻塞?
这是 ET 模式最常见的问题,答案非常核心。
ET 模式下,事件只通知一次。因此必须在一次通知中把缓冲区数据全部读完,否则剩余数据永远不会再触发通知,就会永远"丢失"在那个 fd 上——应用层以为没有数据了,实际上内核缓冲区里还有未读的数据。
为了"读完为止",代码需要在一个循环中反复调用 read(),直到返回 EAGAIN(表示内核缓冲区已空)。问题是:如果 fd 是阻塞模式,当缓冲区空了之后再调用 read(),进程会直接阻塞在 read() 上,而不是返回 EAGAIN。进程将永远卡住,无法回到 epoll_wait 处理其他 fd 的事件。
因此,ET 模式必须配合非阻塞 fd:当数据读完后,read() 返回 -1 且 errno == EAGAIN,循环安全退出。
// ET 模式读操作的正确写法
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 必须设为非阻塞
// 循环读取直到 EAGAIN
while (1) {
ssize_t n = read(fd, buf, BUF_SIZE);
if (n > 0) {
// 处理数据
} else if (n == 0) {
// 对端关闭连接
close(fd);
break;
} else if (n == -1 && errno == EAGAIN) {
// 缓冲区已空,全部读完
break;
} else {
// 真正的错误
break;
}
}
Q2:LT 模式需要设置非阻塞吗?为什么?
不是必须的,但强烈建议也设置非阻塞。
因为 LT 模式下,即使你每次只读一部分数据,下次 epoll_wait 还会继续通知你,所以理论上不会丢数据。
但在实际的高并发服务器开发中,使用非阻塞有实际的好处:
- 避免某一连接的慢处理阻塞整个线程。如果一个阻塞
read()等待数据(极少但可能发生),整个事件循环就会卡住,所有其他连接都受影响。 - 统一代码模式,减少心智负担。
所以在业界实践中,无论是 LT 还是 ET,几乎所有成熟的网络框架(Nginx、libevent 等)都会将 fd 设为非阻塞。
Q3:为什么 ET 模式效率更高?
原因一:无效通知少,通知效率更高
LT 模式下,只要缓冲区有数据(即使只是上次没读完剩下来的),每次 epoll_wait 都会返回该事件。这在很多情况下是无效通知——两个 epoll_wait 之间没有新数据到达,但 LT 还是反复通知"有数据"。
ET 模式只在"新数据到达"这一个边沿事件发生时才通知。没有冗余通知,应用层的 epoll_wait 返回次数降低。
原因二:提高网络吞吐量
ET 模式强制应用层在一次通知中把数据读干净(读到 EAGAIN)。这种"饥饿读取"策略配合 TCP 的滑动窗口和拥塞控制,可以让内核发送窗口的更新更快地被通告给对端,从而提升整体的网络吞吐量。
简单来说:你读得越快越干净,对端就能越快发送更多数据。
注意:"效率更高"不是绝对的。ET 模式编程复杂度更高,容易出错(漏读数据)。在大多数场景下,LT + 非阻塞 IO 已经足够好,且不容易出现隐蔽的 bug。ET 的优势主要体现在对性能有极致要求的高并发场景中。
5.6 内核到用户的数据传递:拷贝而非映射
一个容易被忽视的细节:在 Linux 的 IO 模型中,数据从内核空间到用户空间,始终是拷贝(copy),而不是映射(mmap)。
read() 和 recv() 等系统调用,本质上是调用 copy_to_user() 将内核缓冲区的数据复制到用户提供的缓冲区中。这意味着每次 IO 操作都有一份额外的内存带宽消耗。
select / poll / epoll 优化的是 “等” 这件事——让进程更高效地知道哪些 fd 有数据可读。它们并没有优化 “拷贝” 这件事——一旦数据就绪,进程仍然需要调用 read() 来完成一次数据拷贝。
这也是为什么后来出现了 sendfile()、splice()、io_uring 等零拷贝(Zero Copy)或异步拷贝技术——它们试图优化甚至消除"拷贝"阶段的额外开销。
核心要点回顾:
- IO 的本质 = 等 + 拷贝。高效的 IO = 降低"等"的比重。
- 同步 IO vs 异步 IO 的根本区别在于进程是否参与拷贝阶段。
- 阻塞/非阻塞是文件描述符级别的属性,通过
fcntl()设置。 - select → poll → epoll 是一条清晰的演进线:解除数量限制 → 分离输入输出 → 事件驱动 O(1) 就绪。
- epoll 的核心武器:红黑树存注册 fd,就绪链表只存就绪 fd,回调机制自动触发入链。
- ET 模式是"门铃",LT 模式是"水位警报"。
- ET 必须配合非阻塞 fd,原因是"读完为止"的循环在缓冲区空时,阻塞 fd 会导致进程卡死,而非阻塞 fd 会返回 EAGAIN。
- 真正工业级的高并发服务器,无论 LT 还是 ET,都建议使用非阻塞 fd。
- 多路复用优化了"等",没有优化"拷贝"。拷贝仍然是不可避免的开销,需要零拷贝技术来进一步解决。
感谢阅读!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)