← 返回文章

网络 IO 模型

发布于 2026/10/10

1. 完整回答

网络 I/O 模型主要描述的是,应用程序和操作系统内核之间如何协作完成网络数据的读写。

以读取 Socket 数据为例,一次网络 I/O 通常可以分为两个阶段:等待数据就绪,以及将数据从内核缓冲区复制到用户缓冲区。

根据这两个阶段的处理方式,常见的网络 I/O 模型有五种。

第一种是阻塞 I/O,线程调用 recv 后,如果数据没有到达,就会一直等待,直到数据读取完成才返回,实现简单,但大量连接可能需要大量线程。

第二种是非阻塞 I/O,如果数据没有就绪,调用会立即返回,应用程序可以继续执行其他任务,但如果不断轮询,会浪费 CPU 资源。

第三种是I/O 多路复用,通过 select、poll、epoll 等机制,让一个线程同时监听多个 Socket,哪个连接就绪,就处理哪个连接,从而减少线程数量和无效等待。

第四种是信号驱动 I/O,数据就绪时内核通过信号通知应用程序,再由应用程序读取数据,但信号处理比较复杂,实际使用较少。

第五种是异步 I/O,应用程序提交读写请求后可以继续执行其他任务,I/O 操作完成后再收到完成通知,例如 Linux 的 io_uring。

这里最重要的区别是:I/O 多路复用通知的是数据已经就绪,应用程序仍需主动读取;异步 I/O 通知的是提交的 I/O 操作已经完成。

实际项目中,Linux 高并发网络服务通常采用 epoll + 非阻塞 Socket + 事件循环 的方式,例如 Nginx、Redis 等系统都使用了 I/O 多路复用思想。对于需要批量提交异步操作的场景,也可以考虑 io_uring。

2. 核心原理与关键概念

2.1 一次网络 I/O 的两个阶段

以 TCP Socket 的 recv 操作为例:

阶段一:等待数据就绪

网卡接收网络数据 → 内核网络协议栈处理 → 数据进入 Socket 接收缓冲区 → Socket 变为可读状态。

阶段二:数据复制

应用程序调用 recv → 内核将数据从 Socket 接收缓冲区复制到用户缓冲区 → 系统调用返回读取结果 → 应用程序处理数据。

不同 I/O 模型的核心区别就在于:等待阶段是否阻塞,以及应用程序获得的是就绪通知还是完成通知。

2.2 五种网络 I/O 模型

I/O 模型 等待数据阶段 数据读取阶段 主要特点
阻塞 I/O(BIO) 线程阻塞等待 调用 recv 完成读取 简单直观,但线程等待成本高
非阻塞 I/O(NIO) 未就绪立即返回 应用程序主动读取 不必一直等待,但单纯轮询开销大
I/O 多路复用 统一等待多个 fd 的就绪事件 就绪后主动读取 一个线程可管理多个连接
信号驱动 I/O 内核发送就绪信号 收到信号后主动读取 减少主动查询,但信号管理复杂
异步 I/O(AIO) 提交请求后继续执行 I/O 执行机制完成操作 基于完成通知,适合异步并发处理

关键区别:前四种通常归类为同步 I/O,第五种属于异步 I/O。 这里的同步和异步,是针对整个 I/O 操作是否由应用程序主动执行读写,以及通知发生在就绪阶段还是完成阶段,而不只是看调用是否会阻塞。

2.3 I/O 多路复用的核心流程

以 Linux epoll 为例:

创建 epoll 实例 → 注册 Socket 和关注的事件 → 等待就绪事件 → 获取就绪连接 → 执行非阻塞读写 → 继续监听

其中有三个关键环节:

  1. 事件注册:通过 epoll_ctl 告诉内核需要监听哪些 Socket,以及关注可读还是可写事件。

  2. 就绪通知:内核维护监听关系和就绪状态,epoll_wait 返回已就绪的事件,不需要应用程序每次扫描全部连接。

  3. 事件处理:应用程序调用 recv 或 send 完成实际读写,再继续处理其他连接。

这种方式的优势是:线程数量不必与连接数量一一对应,尤其适合连接数多、单个连接大部分时间都在等待数据的网络服务。

依据:Linux recv(2)、poll(2)、epoll(7)、aio(7)。

2.4 关键概念

概念 含义
Socket 应用程序进行网络通信的接口
文件描述符(fd) Linux 中标识已打开文件、Socket 等资源的整数
阻塞 操作暂时无法完成时,调用线程进入等待
非阻塞 操作暂时无法完成时,调用立即返回
就绪事件 当前具备执行某类 I/O 操作的条件
完成事件 已提交的 I/O 操作执行结束,可以获取结果
事件循环(Event Loop) 持续等待事件、分发事件并执行对应处理逻辑

3. 高频面试追问

3.1 为什么 I/O 多路复用属于同步 I/O?

I/O 多路复用属于同步 I/O,因为它只负责通知数据就绪,真正的数据读取仍然需要应用程序主动执行。

以 epoll 为例,应用程序先通过 epoll_wait 等待可读事件,等 Socket 可读后,再调用 recv 将数据读取到用户缓冲区。

虽然 epoll_wait 可能阻塞,但这不影响它属于同步 I/O。

判断同步还是异步,关键不是线程有没有等待,而是应用程序收到通知时,I/O 操作究竟是可以开始执行,还是已经执行完成。

依据:Linux epoll(7)、recv(2)。

继续追问:非阻塞 I/O 为什么也是同步 I/O?

因为非阻塞只改变等待方式。数据没准备好时,recv 立即返回;数据准备好后,应用程序仍然要再次调用 recv 完成读取,所以它仍然属于同步 I/O。

3.2 select、poll、epoll 有什么区别?

三者都能实现 I/O 多路复用,主要区别在于文件描述符的管理方式,以及获取就绪事件的效率。

select 使用固定大小的位图管理 fd,每次调用需要重新传递监听集合,并通过遍历找到就绪连接。在 Linux glibc 中,常规 fd_set 受 FD_SETSIZE 限制,通常只能监听编号小于 1024 的 fd。

poll 使用动态长度的 pollfd 数组,解决了 select 固定大小位图的问题,但仍需要每次传递并遍历监听集合。

epoll 则将监听关系维护在内核中,通过事件回调维护就绪列表,epoll_wait 可以直接获取就绪事件,不必每次遍历全部监听连接。

所以,连接数量多、活跃连接占比较低时,epoll 通常更有优势。

依据:Linux select(2)、poll(2)、epoll(7)。

3.3 epoll 为什么效率高?它的时间复杂度一定是 O(1) 吗?

epoll 的优势主要来自事件驱动和就绪列表,但不能简单认为所有操作都是 O(1)。

它在内核中维护两个关键结构:

  • 监听集合:管理已经注册的 fd,Linux 实现中使用红黑树等结构。

  • 就绪列表:保存已经产生就绪事件的监听项。

当 Socket 的就绪状态发生变化时,内核通过相关回调更新就绪列表。应用程序调用 epoll_wait 时,主要获取就绪事件,而不是扫描全部注册连接。

假设监听了一万个连接,只有十个连接有数据,select、poll 仍然需要检查大量 fd,而 epoll 可以主要处理这十个就绪连接。

不过,注册和修改监听项需要维护内核数据结构,返回大量就绪事件也有相应开销。

因此,epoll 的等待和事件返回成本通常更接近就绪事件数量,而不是全部监听连接数量,但整体开销并不是恒定的 O(1)。

依据:Linux epoll(7)、Linux 内核 fs/eventpoll.c。

3.4 epoll 的 LT 和 ET 模式有什么区别?

LT 是水平触发,ET 是边缘触发。核心区别是未处理完的数据是否会持续触发就绪通知。

LT 是默认模式。只要 Socket 仍然满足可读条件,后续调用 epoll_wait 时就可以继续收到通知。

ET 主要在就绪状态发生变化时通知。如果一次没有读取完已有数据,不能依赖下次等待再次提醒,因此通常配合非阻塞 Socket,持续读取直到返回 EAGAIN。

举个例子,Socket 收到了 100 字节数据,应用程序只读取了 20 字节:

  • LT 模式下,剩余数据仍然可读,之后还会收到可读事件。

  • ET 模式下,不能保证剩余的 80 字节会单独再次触发通知。

LT 更容易实现,容错性更好;ET 可以减少重复通知,但对事件处理逻辑要求更高。

依据:Linux epoll(7)。

继续追问:ET 模式是不是只会通知一次?

不是。ET 并不是每个连接只通知一次,后续发生新的状态变化仍然可能触发通知。它的关键特征是不会因为之前的数据一直没读完,就持续重复通知。

3.5 为什么 epoll 通常要配合非阻塞 Socket?

因为事件就绪不代表后续所有读写操作都一定能够立即完成,使用阻塞 Socket 可能卡住整个事件循环。

比如某个连接通知可读,应用程序第一次读取了一部分数据,第二次继续读取时,如果缓冲区已经没有数据,阻塞模式可能导致线程一直等待。

这样同一个线程管理的其他连接就无法及时处理。

使用非阻塞 Socket 后,数据暂时不可读时会返回 EAGAIN 或 EWOULDBLOCK,线程可以继续处理其他连接。

因此,epoll 负责等待和分发就绪事件,非阻塞 Socket 负责避免实际读写操作长时间占用事件循环线程。

依据:Linux recv(2)、epoll(7)。

3.6 Reactor 和 Proactor 模型有什么区别?

Reactor 基于就绪通知,Proactor 基于完成通知。

Reactor 的流程是:

注册事件 → 等待 Socket 就绪 → 分发事件 → 应用程序执行读写 → 处理业务。

典型实现是 epoll + 非阻塞 Socket + 事件循环。

Proactor 的流程是:

提交异步读写请求 → I/O 执行机制完成操作 → 返回完成事件 → 应用程序处理结果。

两者最大的区别是:Reactor 告诉应用程序可以读写了,Proactor 告诉应用程序提交的读写已经完成了。

需要注意,Reactor 和 Proactor 是架构模型,不完全等同于某个操作系统接口。有些异步框架在对外提供 Proactor 风格接口时,底层仍然可能通过 Reactor 实现。

依据:Boost.Asio 官方文档《The Proactor Design Pattern》。

3.7 I/O 多路复用相比一个连接一个线程,有什么优势和局限?

最大的优势是降低线程资源消耗,提高大量并发连接的管理效率。

传统的一个连接一个线程模型中,如果客户端长期不发送数据,线程可能一直阻塞在读取操作上。

如果同时存在几万个连接,就需要维护大量线程,会增加内存占用和线程调度成本。

I/O 多路复用允许一个线程管理大量连接,只有连接就绪时才执行对应处理逻辑。

不过它也有局限:I/O 多路复用减少的是等待开销,不会自动提高业务计算速度。

如果事件循环执行了耗时 SQL、复杂计算或者阻塞式 RPC,其他连接仍然会受到影响。

因此,实际项目通常会让事件循环负责网络 I/O,将耗时业务交给工作线程池或其他执行机制。

依据:Redis 官方延迟优化文档、Nginx 官方事件处理文档。

3.8 如果使用 epoll 开发高并发服务器,需要处理哪些问题?

除了使用 epoll,还需要正确处理非阻塞读写、连接生命周期和耗时任务,否则很容易出现 CPU 空转、请求卡住或者连接异常。

实际设计中,我会让事件循环负责连接接入和网络读写,再将耗时业务交给工作线程处理。

对于网络事件,重点处理以下情况:

第一,数据没有一次读完。 TCP 是字节流,单次 recv 不保证返回完整业务消息,需要结合消息长度或者分隔符进行拆包。

第二,非阻塞读写返回特殊结果。 EAGAIN 表示当前暂时无法继续读写,不代表连接出错;对于 TCP 流,recv 返回 0 通常表示对端已经正常关闭发送方向。

第三,数据没有一次发送完成。 send 可能只发送部分数据,剩余部分需要缓存,等 Socket 再次可写时继续发送。

第四,避免事件循环空转。 比如 Socket 长期可写,如果一直监听可写事件但没有实际数据需要发送,就可能导致大量无效唤醒。通常只在发送缓冲中有待发送数据时关注可写事件。

另外,还需要管理超时连接、异常关闭和工作线程之间的数据同步。

依据:Linux recv(2)、send(2)、epoll(7)。

继续追问:如果连接数很多,epoll 就一定能支撑高并发吗?

不一定。连接数只是一个指标,还要考虑活跃连接比例、请求处理耗时、网络带宽、文件描述符限制和系统内存。epoll 可以高效管理连接,但不能消除这些资源限制。

3.9 epoll 和 io_uring 有什么区别?什么场景会选择 io_uring?

epoll 是基于就绪通知的 I/O 多路复用接口,io_uring 是基于请求提交和完成结果的异步 I/O 接口。

使用 epoll 时,应用程序先等待 Socket 就绪,再调用 recv 或 send。

使用 io_uring 时,应用程序可以把读写请求放入提交队列,内核处理后,再通过完成队列返回结果。

io_uring 使用用户态和内核态共享的环形队列,支持批量提交和批量获取完成结果,可以降低部分系统调用和交互成本。

不过,io_uring 并不意味着所有请求都会在后台独立执行,部分请求可能立即完成,部分可能借助内核工作线程。

所以在实际选择上,常规高并发 Socket 服务使用成熟的 epoll 方案就能满足需求;如果有大量异步操作、批量提交需求,且性能测试证明有收益,可以考虑 io_uring。

依据:Linux io_uring(7)、Red Hat 官方技术文章《Why you should use io_uring for network I/O》。

4. 易混淆概念对比

4.1 阻塞 I/O vs 非阻塞 I/O

对比维度 阻塞 I/O 非阻塞 I/O
数据未就绪 调用线程等待 立即返回
CPU 使用 等待期间线程可被调度出去 忙轮询可能消耗 CPU
实现复杂度 较低 需要管理重试和事件
常见场景 简单网络服务、阻塞线程池 事件驱动服务器

核心区别:调用暂时无法完成时,线程是否等待。

4.2 同步 I/O vs 异步 I/O

对比维度 同步 I/O 异步 I/O
操作方式 应用程序主动调用读写接口 应用程序提交异步请求
返回或通知含义 读写调用返回时获得本次操作结果 完成通知表示请求执行结束
调用是否可能阻塞 可能阻塞,也可能非阻塞 提交通常无需等待操作完成
典型方案 BIO、非阻塞 I/O、epoll POSIX AIO、io_uring

核心区别:同步 I/O 中,应用程序主动执行读写调用;异步 I/O 中,应用程序提交操作并在完成后获取结果。

4.3 select vs poll vs epoll

对比维度 select poll epoll
监听集合 fd_set 位图 pollfd 数组 内核维护的监听集合
fd 数量限制 Linux glibc 常规接口受 1024 限制 无固定 1024 限制 无固定 1024 限制
每次传递完整监听集合 是 是 否
获取就绪事件 遍历 fd 集合 遍历 fd 数组 从内核获取就绪事件
性能特点 随监听集合增大,扫描成本增加 同样存在全量扫描成本 更适合大量连接、少量活跃事件
平台支持 广泛 广泛 Linux 专有

三者实际支持的 fd 数量仍受进程资源限制、系统资源和可用内存影响。

4.4 LT vs ET

对比维度 LT(水平触发) ET(边缘触发)
通知条件 就绪条件持续满足即可通知 主要在就绪状态变化时通知
未读完数据 后续仍可通知 不能依赖再次通知
非阻塞要求 推荐配合非阻塞 实际应配合非阻塞
实现复杂度 较低 较高
适用场景 通用网络服务 能正确维护读写状态的事件驱动服务

核心区别:LT 更关注当前是否就绪,ET 更关注就绪状态的变化。

4.5 Reactor vs Proactor

对比维度 Reactor Proactor
事件含义 I/O 就绪 I/O 完成
读写执行 应用程序主动发起 异步执行机制负责完成请求
典型接口 epoll、kqueue IOCP、io_uring 异步操作
适用场景 基于事件循环的网络服务 基于异步完成事件的服务

核心区别:Reactor 是准备好了再操作,Proactor 是操作完成后再处理结果。

5. 总结

网络 I/O 模型主要描述应用程序和内核如何完成网络数据读写,核心是解决大量网络连接下的等待和资源消耗问题。

一次网络读取通常分为两个阶段:等待数据就绪,以及将数据从内核缓冲区复制到用户缓冲区。

常见的模型包括阻塞 I/O、非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。

其中,I/O 多路复用是 Linux 高并发网络编程中非常常见的方案,通过 select、poll、epoll 等机制,一个线程就能监听多个连接。

epoll 的优势是内核维护监听集合和就绪列表,不需要每次扫描全部连接,适合大量连接但活跃连接较少的场景。

实际开发中,通常会把 epoll 和非阻塞 Socket、Reactor 事件循环结合使用。

最后一个关键区别是:epoll 通知的是 I/O 就绪,应用程序还需要主动读写;真正的异步 I/O 则是在请求完成之后通知应用程序。