【Linux第二十五章】高级IO

前言 🚀

学到 Linux 网络编程后,很多人会发现一个现象:真正把服务器性能拉开差距的,往往不是 socketTCPHTTP 这些"会不会用"的问题,而是 IO 到底怎么等、怎么收、怎么通知、怎么派发 。同样是一台服务器,同样是在收发数据,为什么有的实现一阻塞就把线程卡死,有的实现却能同时处理大量连接?为什么 selectpollepoll 都叫多路复用,但大家最后几乎都在谈 epoll?为什么 ET 模式总强调"非阻塞 + 循环读到尽头"?再比如,Reactor 又为什么总和高并发服务器放在一起讲?

这些问题表面上看分散,实际上都围绕同一个核心:IO 的本质是"等待 + 拷贝",高级 IO 的优化重点,就是尽量降低等待造成的浪费,让一个进程或线程在同样时间内处理更多有效事件。

这篇文章就按这条主线,把这份《高级IO》笔记里的内容重新串起来:先看五种 IO 模型是怎么区分的,再看 selectpollepoll 各自解决了什么问题,最后再落到 LT/ET 触发策略和 Reactor 模式,建立一条更完整的高并发 IO 理解框架。


一. 高级 IO 的核心问题:到底在优化什么 🧠

理解高级 IO,第一步不是背接口,而是先看清 IO 到底由什么组成。

这份笔记给出的概括非常直接:

IO = 等待 + 拷贝

这里的"等待",通常是等待数据就绪、缓冲区可读、缓冲区可写、连接建立完成等条件;"拷贝"则是把数据从内核态搬运到用户态,或者从用户态写入内核缓冲区。

所以,所谓高效 IO,不是让"数据凭空更快到达",而是:

  • 减少进程把时间浪费在无意义等待上
  • 减少不必要的遍历和重复设置
  • 让同一个执行流能管理更多 IO 对象
  • 只在真正有价值的时刻通知应用层

二. 五种 IO 模型:它们到底差在哪 🧱

笔记一开始把 Linux 中常见的五种 IO 模型做了一个总览,这部分非常适合作为高级 IO 的总框架。

2.1 阻塞式 IO

阻塞式 IO 最容易理解:进程发起 IO 后,就一直等在那里,直到数据就绪并完成相应操作。

它的优点是模型简单,代码容易写;问题是等待期间通常什么也做不了。如果并发连接很多,大量执行流会被白白挂起,资源利用率很差。

2.2 非阻塞轮询式 IO

非阻塞 IO 则是:发起操作后如果资源暂时不可用,不会卡死当前进程,而是立刻返回。应用层随后可以继续做别的事,或者过一会儿再来问一次。

问题也很明显:如果一直主动轮询,会浪费大量 CPU。

2.3 信号驱动式 IO

信号驱动式 IO 会在数据就绪时由系统通过信号通知进程,例如 SIGIO。这样应用层不必一直轮询,但收到信号后,后续数据处理仍要自己完成。

2.4 多路复用 IO

多路复用 IO 的核心思路是:一个进程同时等多个文件描述符,把"等"这件事集中管理起来。

这就是 selectpollepoll 这些机制存在的意义。它们并不负责真正收发数据,而是专门告诉你:哪些 fd 现在已经就绪,可以读了、可以写了、或者出现异常了。

2.5 异步 IO

异步 IO 则更进一步:进程发起 IO 请求后,不仅不参与等待,连后续的数据拷贝都尽量由系统完成。等整件事彻底做完,再通知应用层。

也正因为如此,前四种通常都属于同步 IO 语义中的不同等待方式,而真正意义上的"完全异步",通常指的是最后这一类。

💡 避坑指南:

不要把"非阻塞"直接等同于"异步"。
非阻塞只是等待方式变了;只要进程后续还要自己参与收发与拷贝过程,本质上仍属于同步 IO 范畴。


三. 非阻塞 IO 的两个高频错误码怎么理解 🔍

笔记在最开始还提到了两个很常见的错误码:EAGAINEINTR

3.1 EAGAIN / EWOULDBLOCK

在非阻塞模式下,如果当前资源还没有准备好,例如可读数据尚未到达、可写空间暂时不足,那么相关系统调用可能返回 EAGAINEWOULDBLOCK

它们通常表达的核心意思都是:

现在不能继续,不是永久失败,而是"暂时还不行"。

3.2 EINTR

EINTR 则表示调用在真正完成数据读取之前,被信号中断了。它强调的不是"资源未就绪",而是"这次系统调用被外部信号打断了"。

这两个错误码看起来都意味着"没成功",但语义完全不同:

  • EAGAIN:资源暂时不可用
  • EINTR:系统调用过程被信号打断

四. IO 多路复用到底在做什么 🗺️

高级 IO 里最核心的一块,就是 IO 多路复用。

它的本质不是"帮你把数据读完",而是:

把等待多个 fd 的过程统一交给内核管理,再把就绪结果告诉用户进程。

4.1 多路复用只负责等待,不负责数据拷贝

这一点非常重要。无论是 selectpoll 还是 epoll,它们都只是"等待机制"。

当某个 fd 可读、可写或异常时,它们会通知你"可以处理了";但真正的数据收发,仍然要靠:

  • read
  • recv
  • write
  • send

这些实际 IO 系统调用来完成。

4.2 为什么它适合高并发

因为一个线程不再傻傻地只守着一个连接,而是能够统一监视很多连接。只要谁有事件发生,就处理谁;没有事件时,就集中等待,不必为每个连接额外维持一个阻塞线程。


五. select:最经典的多路复用起点 🧩

5.1 select 的定位

select 是最经典的一种 IO 多路复用接口,用于同时监测多个文件描述符的就绪状态。

它的函数原型大致是:

cpp 复制代码
int select(int nfds,
           fd_set *readfds,
           fd_set *writefds,
           fd_set *exceptfds,
           struct timeval *timeout);

5.2 关键参数怎么理解

  • nfds:待监测的最大文件描述符值加 1
  • readfds:关心哪些 fd 可读
  • writefds:关心哪些 fd 可写
  • exceptfds:关心哪些 fd 出现异常
  • timeout:超时设置

5.3 timeout 控制三种等待方式

根据笔记内容,timeout 可以形成三种典型行为:

  • nullptr:一直阻塞,直到有事件就绪
  • {0, 0}:非阻塞,立刻返回
  • {5, 0}:阻塞一段时间,例如 5 秒

5.4 fd_set 的本质

select 通过 fd_set 来表示关注的 fd 集合,本质上它就是位图。常见配套宏包括:

  • FD_ZERO
  • FD_SET
  • FD_ISSET
  • FD_CLR

5.5 select 的输入输出参数语义

这也是 select 很容易写错的地方:它的那些 fd_set 参数是输入输出型参数

  • 调用前:你通过位图告诉内核"我关注哪些 fd"
  • 返回后:内核会改写这些位图,只留下真正就绪的 fd

所以如果你下一轮还想继续用,通常必须重新设置。


六. select 的问题到底出在哪 ⚠️

select 不是不能用,而是在高并发场景下,问题会越来越明显。

6.1 每次调用都要重置参数

因为它会修改传入的 fd_set,所以每轮都要重新准备关注集合,这带来了重复劳动。

6.2 用户态和内核态之间有位图拷贝开销

调用前要把关注集合交给内核,返回后还要把结果再带回来,这会产生用户态和内核态之间的数据拷贝开销。

6.3 需要遍历

无论用户层检查就绪结果,还是内核层扫描关注集合,本质上都离不开遍历。fd 一多,这种扫描成本就会越来越高。

6.4 fd_set 有数量上限

笔记特别强调了这一点:fd_set 是固定大小的数据结构,因此 select 能监测的 fd 数量存在上限。

💡 避坑指南:
select 的核心问题不在于"不能同时等多个 fd",而在于:
参数要反复重置、位图要来回拷贝、内核和用户都要遍历,而且关注数量还有上限。


七. poll:比 select 更顺手,但还不够彻底 🧱

7.1 pollselect 的共同点

poll 的定位和 select 本质相同:都只负责 IO 等待,不负责真正的数据收发。

函数原型大致是:

cpp 复制代码
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

其中 pollfd 结构里最关键的是:

  • fd:文件描述符
  • events:输入参数,表示关心什么事件
  • revents:输出参数,表示实际发生了什么事件

7.2 poll 的改进点

select 相比,poll 至少解决了两个明显问题:

  1. 输入和输出参数分离
    eventsrevents 不再混在一个位图里,不需要像 select 那样反复重置整套集合。

  2. 不再受 fd_set 固定大小限制

    它通过数组描述关注对象,不再受固定大小位图直接限制。

7.3 poll 仍然没解决的核心问题

虽然 poll 用起来比 select 更顺手,但它仍然保留了两个关键瓶颈:

  • 用户态和内核态之间仍然有数据拷贝
  • 用户层和内核层依旧要对整个关注集合做遍历

也就是说,poll 只是把 select 的参数设计改得更合理了,但并没有从根本上摆脱"全量扫描"的模式。


八. epoll:为什么它成了 Linux 高并发服务器的主力方案 💻

真正把多路复用带入高并发主战场的,是 epoll

8.1 epoll 的三个核心接口

根据笔记整理,epoll 最核心的接口有三个:

cpp 复制代码
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

它们分别负责:

  • epoll_create:创建 epoll 实例
  • epoll_ctl:增删改监听的 fd 与事件
  • epoll_wait:等待并获取已经就绪的事件

8.2 epoll_event 在表达什么

epoll_event 里有两个最重要的成员:

  • events:事件类型,例如 EPOLLINEPOLLOUT
  • data:用户自定义附加数据,常见就是存 fd

8.3 常见事件类型

笔记列出了几个高频事件:

  • EPOLLIN:可读
  • EPOLLOUT:可写
  • EPOLLPRI:紧急数据可读
  • EPOLLERR:错误
  • EPOLLHUP:挂断
  • EPOLLET:边缘触发模式
  • EPOLLONESHOT:只监听一次

九. epoll 真正优化了什么 🔍

理解 epoll,最关键的不是背接口,而是看它在机制上到底比 select/poll 多做了什么。

9.1 核心数据结构:红黑树 + 就绪队列

笔记中专门强调了 eventpoll 这个核心结构,它大致维护两类东西:

  • 红黑树 :记录用户关心哪些 fd、关心哪些事件
  • 就绪队列:记录已经发生、而且确实是用户关心的就绪事件

9.2 为什么它比 select/poll 高效

因为 select/poll 每一轮更像是"把所有关注对象再全扫一遍,看谁好了";而 epoll 更偏向"平时把关注关系维护好,等真有事件时直接把就绪项挂进队列"。

这样一来,epoll_wait 获取的重点就不再是"检查全部关注者",而是直接处理已经进入就绪队列的事件集合。

9.3 epoll_wait 返回结果的语义

笔记里提到一个很容易被误解的点:

用户传给 epoll_waitevents 数组,是用户空间自己准备的内存;内核会把就绪事件拷贝到这里,不是直接把内核里的就绪队列映射给用户。

所以,"就绪队列是拷贝出来的缓冲区,不是内存映射"这个提醒,本质上是在纠正一个常见误区:epoll 的高效来自更好的事件组织方式,不等于"完全没有数据搬运"。


十. LTET:不是两个接口,而是两种通知策略 🧩

10.1 LT(水平触发)

LT 的逻辑是:

只要底层条件一直满足,就持续通知你。

例如,只要读缓冲区里还有数据没读完,那么下次 epoll_wait 还可能继续告诉你这个 fd 可读。

10.2 ET(边缘触发)

ET 的逻辑则是:

只在状态发生变化的那个瞬间通知一次。

也就是说,从"没有数据"变成"有数据"的那一刻会通知你,但后面如果你没把数据处理干净,它不会像 LT 那样反复提醒你。

10.3 为什么 ET 更高效

因为它减少了重复通知。对高并发场景来说,这种"只在变化点提醒"的策略更克制,也更节省通知成本。

10.4 为什么 ET 一定要配合非阻塞

笔记在这部分强调得很明确:ET 模式下,通常要求配合非阻塞 IO,并在收到通知后循环读取,直到读到 EAGAIN 为止

原因就在于:

  • 如果你不一次性把本轮可读数据尽量取完
  • 后续又没有新的"边缘变化"发生
  • 那你可能就再也收不到提醒了

💡 避坑指南:
ET 不是"通知更少所以自动更快",而是"通知更少,但你必须自己把本轮状态处理干净"。

所以它通常必须和非阻塞 + 循环读写一起使用。


十一. 发送数据时为什么通常不常驻关注 EPOLLOUT ⚠️

笔记在后面专门提到了"数据发送与 epoll 的协作",这部分很容易被忽略,但在工程里很重要。

11.1 发送通常一开始就能发

大多数连接刚开始时,发送缓冲区并不会满,所以很多时候你直接 send 就行,不需要事先长期监听可写事件。

11.2 什么时候才需要关注写事件

只有当发送缓冲区被写满,当前写不进去时,才真正需要把这个 fd 交给 epoll 去关注 EPOLLOUT,等待"又能继续写了"的时刻。

11.3 这背后的工程原则

也就是说,在多路转接里:

  • 读事件通常是常态关注项
  • 写事件通常按需关注

否则如果你总是长期监听 EPOLLOUT,很多连接会因为"本来就可写"而不断返回写就绪,造成不必要的唤醒和处理开销。


十二. Reactor 模式:高级 IO 最后的组织形态 🗺️

如果说 select/poll/epoll 解决的是"怎么等",那么 Reactor 模式解决的就是:

等到了以后,整个程序该怎么组织。

12.1 Reactor 的核心职责

笔记里对 Reactor 的定位很清楚:

  • 它负责事件的异步派发
  • 回调方法负责同步 IO 处理
  • 业务层在 IO 处理完成后继续做业务逻辑

所以 Reactor 本身不是"替你把业务做完",而更像一个总调度器:谁有事件,就把谁分发到对应处理逻辑。

12.2 为什么它适合高并发服务器

因为网络服务器最怕的一件事,就是一个连接上的耗时业务把整个事件循环拖死。

Reactor 模式下,可以把结构拆开:

  • Reactor 负责盯事件、派任务
  • IO 回调负责快速完成读写
  • 真正耗时的业务逻辑再交给专门处理模块

这样事件分发层就不会因为业务慢而整体阻塞。

12.3 主从 Reactor 的思路

笔记后面还提到了多线程版本的典型结构:

  • Reactor 负责接收新连接
  • Reactor 负责已连接 socket 的事件处理

这是一种很常见的主从分工方式,目的是把"接新连接"和"处理大量已建立连接"拆开,减轻单个 Reactor 的压力。

12.4 可以把它理解成"半同步半异步"

事件到达时,由底层事件通知机制触发,这部分带有异步分发意味;而真正执行读写、执行业务回调时,逻辑仍是同步推进的。

也正因为如此,Reactor 往往会和"半同步半异步"这样的描述放在一起看。


十三. 把整章高级 IO 串成一条主线 📌

如果把这章内容压缩成一条主线,可以这样理解:

  1. IO 的本质是 等待 + 拷贝
  2. 阻塞、非阻塞、信号驱动、多路复用、异步 IO,区别主要在于"怎么等"和"谁参与"
  3. select/poll/epoll 都属于多路复用机制,只负责等待,不负责真正读写
  4. select 的主要问题是位图重置、数量上限、遍历和拷贝开销
  5. poll 改善了参数设计,也放宽了数量限制,但仍保留遍历问题
  6. epoll 通过更合理的内核数据结构与就绪事件组织方式,成为高并发主力方案
  7. LTET 是不同的通知策略,其中 ET 更高效,但对"非阻塞 + 一次性读尽"的要求更高
  8. 最终在工程实践中,还要进一步用 Reactor 把"等待、通知、IO 处理、业务处理"组织成完整服务器架构

总结 📝

高级 IO 这一章最重要的收获,不是记住几个函数原型,而是建立这样一条完整认识:高并发网络编程的核心,不只是"会收发数据",而是要让等待这件事尽量高效、让通知尽量精准、让事件组织尽量低成本。

从这个角度回看整章内容:

  • 五种 IO 模型是在回答"进程如何参与等待与处理"
  • select/poll/epoll 是在回答"一个执行流如何同时管理很多 IO 对象"
  • LT/ET 是在回答"内核该用什么策略通知你"
  • Reactor 是在回答"当事件真的来了,程序整体该怎么协作"

所以,真正把高级 IO 学明白之后,再看高并发服务器、事件驱动框架、TcpServer、网络库设计时,就不会把这些内容看成几个孤立专题,而会自然落到同一条主线上:

先高效等待,再精准通知,再快速处理 IO,最后把重业务从事件派发链路中拆开。

这也是 Linux 高性能网络编程最核心的一层底座。

相关推荐
zzzsde2 小时前
【Linux】库的制作与使用(2)ELF&&静态链接
linux·运维·服务器
北冥有羽Victoria2 小时前
Django 实战:SQLite 转 MySQL 与 Bootstrap 集成
大数据·服务器·python·django·编辑器
艾莉丝努力练剑2 小时前
【Linux线程】Linux系统多线程(二):线程的优缺点
linux·运维·服务器·c++·学习
HalvmånEver2 小时前
Linux:基于TCP Socket的客户端-服务器实现的远程命令行项目
linux·运维·服务器·网络·tcp/ip
Three~stone2 小时前
Cisco Packet Tracer保姆级安装教程【附汉化教程插件】
linux·运维·服务器·网络安全
艾莉丝努力练剑2 小时前
【Linux线程】Linux系统多线程(一):线程概念
java·linux·运维·服务器·开发语言·学习·线程
C语言小火车2 小时前
Linux 操作系统八股文(2026最新完整版)
java·linux·运维
Deitymoon2 小时前
linux——消息队列进程间通信
linux
嵌入式学习菌2 小时前
内网穿透全闭环实操指南
linux·开发语言·php