从阻塞 I/O 到 io_uring:Linux 高性能 I/O 模型的演进

举报
yd_232225224 发表于 2026/10/05 23:05:40 2026/10/05
【摘要】 网络服务器的核心任务,是同时处理大量客户端连接上的读写操作。一个 Web 服务可能同时维持数万个连接,其中大部分连接在绝大多数时间里处于空闲状态,只偶尔有数据到达。如何用有限的线程和 CPU 资源,高效地处理这些连接上的 I/O 事件,是服务器程序设计的基本问题。本文梳理 Linux 下 I/O 模型的演进过程:从最简单的阻塞 I/O,到非阻塞 I/O 与 I/O 多路复用,再到 epoll...

网络服务器的核心任务,是同时处理大量客户端连接上的读写操作。一个 Web 服务可能同时维持数万个连接,其中大部分连接在绝大多数时间里处于空闲状态,只偶尔有数据到达。如何用有限的线程和 CPU 资源,高效地处理这些连接上的 I/O 事件,是服务器程序设计的基本问题。

本文梳理 Linux 下 I/O 模型的演进过程:从最简单的阻塞 I/O,到非阻塞 I/O 与 I/O 多路复用,再到 epoll 及其背后的设计,以及近年出现的 io_uring 异步接口,并介绍建立在这些机制之上的常见服务器架构模式。

一、一次 I/O 操作的两个阶段

以从网络套接字读取数据为例,一次读操作可以分为两个阶段:

  1. 等待数据就绪:等待数据从网络到达,被内核接收并放入套接字的接收缓冲区;
  2. 复制数据:将数据从内核缓冲区复制到用户进程提供的缓冲区。

第一个阶段的耗时取决于网络和对端,可能是微秒,也可能是数分钟甚至更久;第二个阶段是内存复制,耗时很短且可预测。

不同 I/O 模型的区别,主要就在于进程在这两个阶段中是否被阻塞,以及如何得知数据已经就绪。

二、阻塞 I/O

阻塞 I/O 是最直观的模型。进程调用读操作后,如果数据尚未到达,进程就会被挂起,直到数据到达并复制完成,读调用才返回。

在阻塞 I/O 模型下,一个线程在同一时刻只能等待一个连接。要同时服务多个连接,最简单的方式是每个连接分配一个线程(或进程):

  • 主线程负责接受新连接;
  • 每接受一个连接,就创建一个新线程,在其中循环执行"读取请求、处理、写回响应"。

这种模型的优点是编程简单,代码按顺序书写,逻辑清晰,易于理解和调试。

但当连接数增长到数千甚至数万时,问题就显现出来:

  • 内存开销:每个线程都需要独立的栈空间,通常为数百 KB 到数 MB。一万个线程仅栈空间就可能占用数 GB 内存;
  • 调度开销:大量线程之间的上下文切换消耗可观的 CPU 时间,并破坏 CPU 缓存的局部性;
  • 资源浪费:绝大多数线程在大部分时间里只是在等待数据,什么也不做。

使用线程池可以限制线程数量,但如果线程池中的所有线程都阻塞在空闲连接上,新的请求就无法得到处理。

这个困境曾被总结为著名的 C10K 问题:如何让一台服务器同时处理一万个并发连接。解决这个问题,需要让一个线程能够同时照管多个连接。

三、非阻塞 I/O

将套接字设置为非阻塞模式后,读操作的行为会发生变化:如果数据尚未就绪,读调用不会挂起进程,而是立即返回一个错误码,表示"暂时没有数据,稍后再试"。

这样,一个线程就可以依次对多个连接发起读操作,哪个有数据就处理哪个,没有数据就跳过。

然而,单纯的非阻塞 I/O 存在一个明显的问题:线程不知道哪些连接上有数据,只能不断轮询所有连接。如果一万个连接中只有少数几个有数据,绝大多数读调用都是无效的,白白消耗 CPU。而如果为了节省 CPU 在轮询之间休眠,又会增加响应延迟。

因此,非阻塞 I/O 通常不单独使用,而是与 I/O 多路复用配合使用。

四、I/O 多路复用

I/O 多路复用的思路是:由内核来监视多个文件描述符的状态,当其中任何一个变为就绪(可读、可写或出错)时,通知进程。进程只需在一个调用上等待,就能同时照管大量连接。

Linux 提供了三种多路复用机制:select、poll 和 epoll。

1. select

select 是最早出现的多路复用接口。进程将需要监视的文件描述符集合传给内核,内核检查这些描述符的状态,如果都未就绪则阻塞等待,直到有描述符就绪或超时,然后返回,并在集合中标记出哪些描述符已就绪。

select 存在几个明显的局限:

  • 数量限制:文件描述符集合使用固定大小的位图表示,通常最多只能监视 1024 个描述符;
  • 重复复制:每次调用都需要将整个描述符集合从用户空间复制到内核空间;
  • 线性扫描:内核需要遍历所有被监视的描述符来检查状态;返回后,进程也需要遍历整个集合,才能找出哪些描述符就绪;
  • 集合被修改:select 返回时会修改传入的集合,每次调用前都需要重新设置。

当被监视的描述符数量为 n 时,每次调用的开销是 O(n),与实际就绪的描述符数量无关。

2. poll

poll 使用一个结构体数组代替位图来描述需要监视的描述符,每个元素分别记录关注的事件和返回的事件。这解决了 select 的数量上限问题,也不再需要每次重新设置集合。

但 poll 的核心问题与 select 相同:每次调用仍然需要复制整个数组到内核,内核和进程仍然需要线性扫描所有描述符。在连接数很大而活跃连接很少时,效率依然低下。

3. epoll

epoll 是 Linux 特有的多路复用机制,专门为解决大规模连接场景而设计。它将"注册关注的描述符"和"等待就绪事件"这两个操作分离开来:

  • 创建 epoll 实例:内核创建一个 epoll 对象,进程获得一个代表它的文件描述符;
  • 注册与修改:通过控制接口向 epoll 实例中添加、修改或删除需要监视的描述符及其关注的事件。这一操作只在描述符的关注状态发生变化时执行,而不是每次等待都重复;
  • 等待事件:调用等待接口,内核只返回已经就绪的描述符。

epoll 的内部机制

epoll 能够高效工作,得益于其内部的两个数据结构和一个回调机制:

  • 红黑树:存储所有被监视的描述符,支持高效的插入、删除和查找;
  • 就绪链表:存储当前已就绪的描述符;
  • 回调机制:当一个描述符被注册到 epoll 时,内核会在该描述符对应的设备上注册一个回调函数。当数据到达、描述符状态变为就绪时,回调函数被触发,将该描述符加入就绪链表。

等待接口只需检查就绪链表是否为空,不为空就将其中的描述符返回给进程。整个过程无需遍历所有被监视的描述符。

因此,epoll 等待操作的开销只与就绪的描述符数量相关,而与被监视的描述符总数无关。即使监视数十万个连接,只要每次只有少量连接活跃,epoll 的效率依然很高。

水平触发与边缘触发

epoll 支持两种通知模式:

水平触发(Level-Triggered,LT):只要描述符处于就绪状态,每次调用等待接口都会返回它。例如,如果接收缓冲区中还有未读完的数据,下一次等待时仍会收到通知。这是默认模式,行为与 select 和 poll 一致,编程上容错性较好。

边缘触发(Edge-Triggered,ET):只在描述符的状态发生变化时通知一次。例如,数据从无到有时通知一次,如果进程没有把数据全部读完,在新数据到达之前不会再次收到通知。

使用边缘触发模式时,必须遵循以下规则:

  • 描述符必须设置为非阻塞模式;
  • 收到通知后,必须循环读取,直到读调用返回"暂时没有数据"的错误为止;否则剩余数据可能永远得不到处理。

边缘触发减少了重复通知的次数,在高负载下效率更高,但编程更容易出错。实践中,两种模式各有使用者,水平触发配合合理的读取策略,在大多数场景下也能获得很好的性能。

惊群问题

当多个线程或进程同时等待同一个 epoll 实例或同一个监听套接字时,一个新连接到达,可能会唤醒所有等待者,但最终只有一个能够成功接受连接,其余被唤醒的线程白白消耗了调度开销,这称为惊群问题。

Linux 内核为此提供了若干改进,例如允许只唤醒一个等待者的标志,以及允许多个套接字绑定同一端口、由内核在它们之间分发新连接的选项。后者能够将连接均衡地分配给多个工作线程,各自拥有独立的监听套接字和 epoll 实例,是常见的多核扩展方式。

五、基于事件驱动的服务器架构

I/O 多路复用为服务器架构带来了新的设计模式。

Reactor 模式

Reactor 模式是基于 I/O 多路复用的经典架构。它的核心是一个事件循环:

  1. 调用 epoll 等待就绪事件;
  2. 对每个就绪的描述符,根据事件类型分发给对应的处理器;
  3. 处理器执行非阻塞的读写操作和业务逻辑;
  4. 回到第 1 步。

在 Reactor 模式中,多路复用器通知的是"可以进行 I/O 了",实际的读写操作仍由应用程序执行。

根据线程的组织方式,Reactor 模式有几种常见变体:

  • 单线程 Reactor:一个线程负责所有连接的事件监听、I/O 和业务处理。结构简单,没有线程同步问题,但无法利用多核,任何一个耗时的处理都会阻塞所有连接。一些内存数据库和代理服务采用这种模型,依靠极短的处理时间获得高吞吐;
  • 单 Reactor 加工作线程池:事件循环线程负责 I/O,将耗时的业务处理交给工作线程池,处理完成后再将结果交回事件循环发送;
  • 多 Reactor:一个主 Reactor 负责接受新连接,并将连接分配给多个从 Reactor;每个从 Reactor 运行在独立的线程上,负责一部分连接的全部 I/O 事件。这种模式能够充分利用多核,是许多高性能网络框架和服务器的标准架构。

事件驱动编程的挑战

事件驱动架构显著提高了并发能力,但也带来了编程上的复杂性:

  • 控制流被打散:原本顺序书写的"读取、处理、写回"逻辑,被拆分成多个回调函数,状态需要在回调之间显式传递,代码可读性下降;
  • 不能阻塞事件循环:任何阻塞操作,例如同步的磁盘读写、同步的数据库查询、耗时的计算,都会让整个事件循环停滞,影响该线程负责的所有连接;
  • 错误处理复杂:异常需要在多个回调之间传播,资源释放的时机也更难把握。

为了缓解这些问题,许多语言引入了异步编程语法和协程。开发者可以用接近同步代码的方式书写逻辑,在遇到 I/O 时由运行时自动挂起当前协程、切换到其他协程,I/O 就绪后再恢复执行。底层仍然是基于 epoll 等机制的事件循环,但对开发者隐藏了回调的复杂性。一些语言的运行时更进一步,将大量轻量级的用户态线程调度到少量操作系统线程上,让开发者完全以阻塞风格书写代码,由运行时在底层自动转换为非阻塞 I/O 和多路复用。

六、epoll 的局限

epoll 极大地提升了网络 I/O 的处理能力,但它仍然存在一些固有的局限。

本质上是就绪通知。 epoll 只告诉进程"哪个描述符可以读写了",实际的数据复制仍然需要进程发起读写系统调用来完成。每处理一个就绪的连接,至少需要一次等待调用和一次读写调用。在高吞吐场景下,系统调用本身的开销变得不可忽视。

对普通文件无效。 对于磁盘上的普通文件,Linux 认为它们总是"就绪"的,epoll 对其不起作用。但磁盘读取在缓存未命中时实际上可能阻塞较长时间。因此,基于 epoll 的事件循环无法以非阻塞方式处理文件 I/O,通常只能将文件操作交给单独的线程池执行。

旧的异步 I/O 接口难以使用。 Linux 早期提供过一套原生异步 I/O 接口,但它只在使用直接 I/O(绕过页缓存)的情况下才能真正异步执行,对缓冲 I/O 仍然可能阻塞,支持的操作类型也很有限,因此并未被广泛采用。

系统调用开销加剧。 近年来,为了缓解处理器侧信道漏洞而引入的内核防护措施,使得每次系统调用的开销显著增加。对于每秒需要执行数百万次系统调用的高性能应用而言,这一影响十分明显。

这些问题促使 Linux 引入了一套全新的异步 I/O 接口。

七、io_uring

io_uring 是 Linux 5.1 版本引入的异步 I/O 框架。它的设计目标是:提供一个统一的、真正异步的 I/O 接口,同时尽可能减少系统调用和数据复制的开销。

共享环形队列

io_uring 的核心是两个在用户空间和内核空间之间共享内存的环形队列:

  • 提交队列(Submission Queue,SQ):应用程序将 I/O 请求写入这个队列,每个请求称为一个提交队列条目,描述了操作类型、文件描述符、缓冲区地址、偏移量等信息;
  • 完成队列(Completion Queue,CQ):内核完成 I/O 操作后,将结果写入这个队列,每个结果称为一个完成队列条目,包含操作的返回值和应用程序附带的用户数据。

由于两个队列都位于共享内存中,应用程序提交请求和读取结果都只是普通的内存读写操作,不需要通过系统调用复制数据。两个队列分别采用单生产者、单消费者的模式,通过头尾指针和内存屏障实现无锁同步。

基本工作流程

  1. 应用程序在提交队列中填写一个或多个 I/O 请求;
  2. 调用一次系统调用,通知内核有新的请求;
  3. 内核执行这些 I/O 操作;
  4. 操作完成后,内核将结果写入完成队列;
  5. 应用程序从完成队列中读取结果。

与 epoll 的"就绪通知"不同,io_uring 是一种完成通知模型:应用程序提交的是"请帮我读取这些数据",得到通知时数据已经被复制到了指定的缓冲区中,无需再发起读调用。这对应的是 Proactor 模式。

批量提交与系统调用合并

由于请求在共享队列中积累,应用程序可以一次系统调用提交多个请求,并且可以在同一个系统调用中同时提交新请求和等待完成事件。在高负载下,大量 I/O 操作可以分摊到极少的系统调用上。

轮询模式

io_uring 还提供了进一步减少系统调用的选项:

  • 提交队列轮询:内核启动一个专门的线程,持续轮询提交队列。应用程序只需将请求写入共享队列,内核线程会自动发现并处理,完全不需要系统调用。这种模式以占用一个 CPU 核心为代价,换取极低的延迟和极高的吞吐;
  • I/O 轮询:对于支持的高速存储设备,内核主动轮询设备的完成状态,而不是等待中断,进一步降低延迟。

统一的异步接口

io_uring 支持的操作类型远远超出了传统的读写:

  • 文件的读写,包括缓冲 I/O 和直接 I/O;
  • 网络套接字的接收、发送、接受连接、建立连接;
  • 文件的打开、关闭、同步、获取属性;
  • 超时、取消请求等控制操作。

更重要的是,对于普通文件的缓冲 I/O,io_uring 也能真正以异步方式处理:如果数据在页缓存中,直接完成;如果需要从磁盘读取,则交由内核内部的工作线程执行,不会阻塞应用程序。这使得网络 I/O 和文件 I/O 第一次可以在同一个事件循环中统一处理。

其他高级特性

请求链接。 多个请求可以被链接在一起,按顺序执行,前一个成功后再执行下一个。例如"打开文件、读取内容、关闭文件"可以作为一个链一次性提交。

注册文件与缓冲区。 应用程序可以预先将常用的文件描述符和缓冲区注册到 io_uring 实例中。之后的请求直接引用注册后的索引,内核无需在每次操作时重复查找文件描述符和映射用户内存,进一步降低开销。

缓冲区池。 对于网络接收这类无法预知何时有数据到达的场景,如果为每个连接都预先分配一个缓冲区,在连接数很大时会浪费大量内存。io_uring 允许应用程序提供一个共享的缓冲区池,内核在数据真正到达时再从池中选取一个缓冲区使用。

多次触发请求。 一个请求可以被设置为多次产生完成事件。例如一个接受连接的请求可以持续接受新连接,每接受一个就产生一个完成事件,而不需要每次重新提交。

io_uring 的挑战

io_uring 带来了显著的性能提升,但也存在一些需要注意的问题:

  • 编程复杂度:直接使用底层接口较为复杂,通常需要借助封装库。完成通知模型要求缓冲区在请求提交后、完成之前保持有效,这对内存管理提出了更高要求;
  • 安全问题:io_uring 的功能丰富,代码复杂,在发展过程中暴露出了较多的内核安全漏洞。出于安全考虑,部分发行版、容器环境和云平台默认限制或禁用了 io_uring;
  • 内核版本依赖:许多重要特性在不同的内核版本中陆续加入,应用程序需要检测当前内核支持哪些功能,并准备好退化方案;
  • 适用场景:对于连接数不多、吞吐要求不高的应用,epoll 已经足够,迁移到 io_uring 的收益有限。io_uring 的优势主要体现在极高吞吐、大量小 I/O、需要统一处理网络和文件 I/O 的场景,例如高性能存储引擎、数据库、代理服务器等。

八、各模型对比

模型 等待数据阶段 复制数据阶段 就绪感知方式 主要特点
阻塞 I/O 阻塞 阻塞 无需感知 编程最简单,每连接一线程,扩展性差
非阻塞 I/O 不阻塞,立即返回 阻塞 主动轮询 单独使用会浪费 CPU
select / poll 阻塞在多路复用调用上 阻塞 内核线性扫描 开销随监视数量线性增长
epoll 阻塞在等待调用上 阻塞 回调加入就绪链表 开销与活跃连接数相关,适合海量连接
io_uring 不阻塞 由内核完成 完成队列 真正异步,批量提交,统一网络与文件 I/O

九、零拷贝技术

在讨论高性能 I/O 时,另一个常被提及的话题是数据复制的开销。

以一个将磁盘文件通过网络发送出去的服务为例,传统方式需要先将文件读入用户缓冲区,再从用户缓冲区写入套接字。数据在这个过程中经历了多次复制:从磁盘到内核页缓存,从页缓存到用户缓冲区,从用户缓冲区到套接字缓冲区,再从套接字缓冲区到网卡。其中,进出用户空间的两次复制完全是不必要的,应用程序并没有对数据做任何修改。

零拷贝技术旨在减少或消除这些不必要的复制:

  • 内存映射:将文件直接映射到进程的地址空间,进程可以像访问内存一样访问文件内容,减少一次复制;
  • 文件发送调用:内核提供了专门的系统调用,可以在内核内部直接将文件内容从页缓存传送到套接字,数据完全不经过用户空间。在网卡支持的情况下,甚至可以直接从页缓存将数据交给网卡发送,避免向套接字缓冲区复制;
  • 管道拼接:借助内核中的管道缓冲区,在两个文件描述符之间移动数据,而无需复制到用户空间。

零拷贝技术被广泛应用于静态文件服务器、消息队列、代理服务器等需要大量转发数据的系统中,能够显著降低 CPU 占用并提高吞吐。io_uring 也在持续加入网络零拷贝发送和接收等相关特性。

十、实践建议

根据规模选择模型。 对于连接数较少的内部服务或工具程序,阻塞 I/O 配合线程池完全够用,代码简单可靠。面对大量并发连接时,应采用基于 epoll 的事件驱动架构,或使用基于协程的异步框架。只有在 epoll 确实成为瓶颈时,再考虑 io_uring。

优先使用成熟的框架。 直接使用 epoll 或 io_uring 编写服务器,需要处理大量的边界情况:部分读写、连接中断、缓冲区管理、超时处理等。大多数语言都有成熟的网络框架和异步运行时,它们已经妥善处理了这些问题。

绝不阻塞事件循环。 在事件驱动架构中,任何同步阻塞操作都会影响同一线程上的所有连接。耗时的计算、同步的文件或数据库调用,应当交给专门的线程池处理。

关注系统调用次数。 在高性能场景下,系统调用次数往往比单次调用的耗时更能影响整体性能。批量读写、合并小包发送、减少不必要的状态查询,都能带来可观的收益。

测量而非猜测。 I/O 性能受内核版本、硬件、网络环境、负载模式等多种因素影响。在选择和调优 I/O 模型时,应当基于实际负载进行测量,借助性能分析工具确定瓶颈所在,而非仅凭理论推断。

结语

Linux I/O 模型的演进,是一个不断减少无效等待、减少无效工作的过程。阻塞 I/O 让线程在等待中闲置;非阻塞 I/O 避免了阻塞,却需要徒劳地轮询;select 和 poll 让内核代为等待,却在每次调用中重复检查所有连接;epoll 只返回真正就绪的连接,解决了海量连接的扩展问题;io_uring 则更进一步,通过共享内存队列和完成通知模型,将系统调用和数据复制的开销降到最低,并第一次统一了网络和文件的异步 I/O。

每一次演进都针对上一代模型在特定场景下暴露的瓶颈。理解这些模型的工作原理和适用范围,有助于在设计系统时选择合适的方案,也有助于理解各类网络框架和异步运行时在底层究竟做了什么。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。