深入理解 IO 模型:从本质到 epoll


一、什么是 IO?IO 的本质是什么?

IO(Input / Output),即输入与输出,是计算机系统中数据流动的核心抽象。

在操作系统的视角下,IO 描述的是外部设备与内存之间的数据传输。这里的外部设备可以是磁盘、网卡、键盘、显示器等一切内存之外的东西。

IO 的本质:等 + 拷贝

IO 操作可以拆解为两个关键阶段:

  1. 等待(Wait):等待数据从外部设备到达内核缓冲区。比如等网卡收到数据、等磁盘磁头定位到目标扇区。这个阶段进程通常无事可做,只是等待硬件准备就绪。

  2. 拷贝(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()

  • 如果内核数据还没准备好,系统调用立即返回一个错误EWOULDBLOCKEAGAIN),而不是阻塞进程。
  • 进程收到错误后可以继续执行其他任务,然后**轮询(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_uringio_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,并设置 errnoEAGAINEWOULDBLOCK,而不是阻塞进程。

这个机制是非阻塞 IO 和多路复用(配合 epoll ET 模式)的基础。


五、IO 多路复用深度解析

IO 多路复用是后端开发最核心的 IO 技术,也是面试的高频考点。Linux 提供了三代接口:selectpollepoll。每一代都在解决上一代的痛点。

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 个 fdFD_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 上的事件主要有三类:

  1. 读事件就绪:有数据可读,或对端关闭连接(read() 返回 0)。
  2. 写事件就绪:发送缓冲区有空间,可以写入数据。
  3. 异常事件就绪:带外数据(OOB)到达等。
select 的工作原理

select 底层的核心逻辑是遍历检测

  1. 内核将用户传入的三个 fd_set 从用户态拷贝到内核态。
  2. 0 ~ nfds-1 范围内的每个 fd,调用其 struct file 中挂载的 poll 函数指针(file->f_op->poll),进行非阻塞检查,判断该 fd 是否有事件就绪。
  3. 关键行为:只有当所有传入的 fd 都没有就绪事件时,进程才会调用 schedule() 进入休眠(阻塞)。哪怕只有一个 fd 就绪,select 都会立刻返回,不会阻塞。
  4. 当某个 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 的缺点
  1. fd 数量上限:受 FD_SETSIZE(默认 1024)限制,无法承载高并发。
  2. 参数是值-结果类型:每次调用都需要将 fd_set 从用户态拷贝到内核态,返回时又被内核修改(覆盖写),因此每次循环都必须重新设置整个 fd_set,接口使用非常不便。
  3. O(n) 遍历:用户层拿到返回后需要遍历所有 fd 用 FD_ISSET 找出就绪者;内核层也需要遍历全部 fd 做 poll 检测。当 fd 数量很大而实际就绪的很少时,大量 CPU 被浪费在遍历上。
  4. 每次调用都重新拷贝 fd_set:对于一个需要反复调用的 select 循环,每次调用都要把 fd_set 从用户态拷贝到内核态,这个开销随 fd 数量增加而增大。
  5. 参数耦合:输入(关心的 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 带外数据可读
参数说明
  • fdspollfd 结构体数组的指针,每个元素描述一个被监听的 fd。
  • nfds:数组长度。
  • timeout(毫秒):
    • -1:阻塞等待,直到有事件发生。
    • 0:立即返回(非阻塞检查)。
    • > 0:最多等待该毫秒数。
poll 相比 select 的改进
  1. 移除 fd 数量上限:用动态数组代替 fd_set 位图,理论上可以监听任意数量的 fd(上限取决于系统资源)。
  2. 输入输出分离(代码解耦)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() 返回 -1errno == 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)或异步拷贝技术——它们试图优化甚至消除"拷贝"阶段的额外开销。


核心要点回顾

  1. IO 的本质 = + 拷贝。高效的 IO = 降低"等"的比重。
  2. 同步 IO vs 异步 IO 的根本区别在于进程是否参与拷贝阶段
  3. 阻塞/非阻塞是文件描述符级别的属性,通过 fcntl() 设置。
  4. select → poll → epoll 是一条清晰的演进线:解除数量限制 → 分离输入输出 → 事件驱动 O(1) 就绪。
  5. epoll 的核心武器:红黑树存注册 fd,就绪链表只存就绪 fd,回调机制自动触发入链。
  6. ET 模式是"门铃",LT 模式是"水位警报"。
  7. ET 必须配合非阻塞 fd,原因是"读完为止"的循环在缓冲区空时,阻塞 fd 会导致进程卡死,而非阻塞 fd 会返回 EAGAIN。
  8. 真正工业级的高并发服务器,无论 LT 还是 ET,都建议使用非阻塞 fd。
  9. 多路复用优化了"等",没有优化"拷贝"。拷贝仍然是不可避免的开销,需要零拷贝技术来进一步解决。

感谢阅读!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐