【Linux】五种 IO 模型与非阻塞 IO

🌈 个人主页:Zfox_
🔥 系列专栏:Linux

目录
- 一:🔥 重新理解 IO
- 🦋 为什么说网络问题的本质是 I/O 问题?
- 🎀 从数据流动看网络通信
- 🎀 网络 I/O 的瓶颈
- 🦋 如何理解 I/O 的本质?
- 🦋 什么是高效的 I/O?
- 🎀 高效 I/O 的目标
- 🎀 实现高效 I/O 的策略
- 二:🔥 五种 IO 模型
- 🦋 生动例子:餐厅点餐
- 🦋 专业术语介绍
- 🦋 总结表
- 三:🔥 思考
- 🦋 阻塞 vs 非阻塞,非阻塞效率效率一定高吗?
- 🦋 五种模型中,谁的 I/O 效率最高?
- 🦋 同步通信 vs 异步通信
- 四:🔥 非阻塞 IO
- 🦋 fcntl
- 🦋 实现函数 SetNonBlock
- 🦋 非阻塞方式读取标准输入
- 五:🔥 共勉
一:🔥 重新理解 IO
🦋 为什么说网络问题的本质是 I/O 问题?
🎀 从数据流动看网络通信
- 网络通信的核心:数据在客户端与服务器之间**双向流动**。
- 客户端发送请求 → 输出(Write)
- 服务器接收请求 → 输入(Read)
- 服务器返回响应 → 输出(Write)
- 客户端接收响应 → 输入(Read)
- 每个步骤均涉及 I/O 操作:数据通过网卡、内核缓冲区、用户程序传递,本质是 跨层数据搬运。
🎀 网络 I/O 的瓶颈
- 延迟(Latency):数据从一端到另一端的传输时间(如物理距离、路由跳数)。
- 带宽(Bandwidth):单位时间内可传输的数据量上限。
- 并发(Concurrency):同时处理的连接数影响资源分配效率。
总结:
网络性能优化的核心是 减少 I/O 等待时间
和 提升 I/O 吞吐量
。
🦋 如何理解 I/O 的本质?
🔬 IO 就是 input,output,参照物是计算机本身,是计算机系统内部和外部设备进行交互的过程。
IO = 等+拷贝(等是主要矛盾) \colorbox{#FF7F00}{IO = 等+拷贝(等是主要矛盾)} IO = 等+拷贝(等是主要矛盾)
🐳 等待外部设备就绪,当外部设备准备好了以后,通过 CPU 的针脚发送中断信号告知操作系统。操作系统转入内核态,进行拷贝工作。
- 等待(Waiting):等待数据就绪(如网络数据到达内核缓冲区、磁盘数据加载到内存)。
- 拷贝(Copying):将数据从内核缓冲区复制到用户空间(或反向)。
高效IO
\colorbox{turquoise}{高效IO}
高效IO
上面说的IO=等待+拷贝
在大多数情况下,时间都浪费在等待上面,因为和等待相比,拷贝要花的时间比等待的时间少的多。
- 高效 IO 的核心:减少等待时间的浪费,而非单纯优化拷贝速度。
等待的分类
- 主动等待:进程阻塞直到数据就绪(如阻塞式
read()
)。 - 被动等待:进程通过轮询或事件通知检查状态(如非阻塞 IO +
epoll
)。
示例:
- 网络请求:客户端等待服务器响应的 RTT(Round-Trip Time)属于被动等待。
- 数据库查询:从磁盘读取数据时,CPU 因 IO 阻塞而空闲属于主动等待。
🦋 什么是高效的 I/O?
⚡🧙 任何通信场景,IO 通信效率一定是有上限的,毕竟 花盆里长不出参天大树(受硬件限制)
IO效率低 的原因主要有以下几点:
- 等待时间:IO操作通常涉及与外部设备的交互,这些设备的速度远低于CPU和内存。例如,硬盘的读写速度比内存慢几个数量级,网络传输的速度也受带宽和延迟的限制。因此,程序在等待IO操作完成时会浪费大量时间。
- 上下文切换:在阻塞IO中,操作系统需要将等待IO的进程挂起,并切换到其他进程执行。这种上下文切换会消耗额外的CPU资源,降低整体效率,
- 资源竞争:在高并发环境下,多个进程或线程可能同时请求IO操作,导致资源竞争和排队,进一步增加等待时间。
🎀 高效 I/O 的目标
- 最大化 CPU 利用率:减少进程因等待 I/O 而阻塞的时间。
- 最小化延迟:快速响应每个 I/O 请求。
- 最大化吞吐量:单位时间内处理更多 I/O 操作。
🎀 实现高效 I/O 的策略
策略 1:减少阻塞等待
- 非阻塞 I/O:轮询检查数据是否就绪,避免进程挂起。
- 代价:频繁轮询可能导致 CPU 空转。
- 多路复用(如
epoll
):单线程监控多个 I/O 事件,仅处理就绪的描述符。- 优势:适合高并发网络服务(如 Web 服务器)。
策略 2:批量处理 I/O
- 缓冲(Buffering):累积多个小数据包后一次性处理,减少系统调用次数。
- 示例:TCP 协议的 Nagle 算法合并小数据包。
策略 3:异步化与并行化
- 异步 I/O(如
io_uring
):内核全程处理 I/O,完成后通知进程。 - 多线程/进程:为每个连接分配独立执行单元(但需权衡上下文切换开销)
由于 IO 大部分时间花在了 等待上,因此高效的 IO 本质:单位时间内,等待的比重越低, IO 效率越高 \colorbox{cyan}{由于 IO 大部分时间花在了 等待上,因此高效的 IO 本质:单位时间内,等待的比重越低, IO 效率越高} 由于 IO 大部分时间花在了 等待上,因此高效的 IO 本质:单位时间内,等待的比重越低, IO 效率越高
二:🔥 五种 IO 模型
在了解相关知识之前,我们先来看个例子,方便我们对其的理解
🦋 生动例子:餐厅点餐
角色定义
- 进程:顾客(发起 I/O 请求的主体)。
- 文件描述符:订单号(标识一个 I/O 请求)。
- 数据:顾客点的餐(需要处理的内容)。
1.1 阻塞 I/O
- 场景:
顾客下单后,一直坐在餐桌前等待,直到服务员端上菜才能做其他事(如玩手机)。 - 关键点:
- 顾客(进程)在等待期间完全被阻塞。
- 订单号(文件描述符)对应唯一的请求。
1.2 非阻塞 I/O
- 场景:
顾客下单后,每隔 5 秒去厨房问一次“我的菜好了吗?”,期间可以喝水、聊天。 - 关键点:
- 顾客(进程)需要主动轮询状态。
- 若厨房(内核)回答“没好”,顾客继续做其他事。
1.3 信号驱动 I/O
- 场景:
顾客下单后,留下手机号给服务员,继续聊天。厨房准备好菜时,服务员打电话通知顾客取餐。 - 关键点:
- 数据就绪时内核(服务员)通过信号(电话)通知进程(顾客)。
- 顾客仍需自己从厨房端走菜(同步拷贝数据)。
1.4 多路转接 I/O
- 场景:
顾客同时点了咖啡和蛋糕,告诉大堂经理“两样都好了叫我”。经理一直监听多个订单,任一就绪时通知顾客。 - 关键点:
- 一个进程(顾客)通过多路复用接口(经理)监控多个文件描述符(订单)。
- 仍需顾客自己取餐(同步拷贝数据)。
1.5 异步 I/O
- 场景:
顾客下单后,继续办公。厨房准备好菜后,服务员直接将菜端到顾客桌上,并说“您的菜齐了”。 - 关键点:
- 数据准备和端菜(拷贝)全程由内核(服务员)完成。
- 顾客(进程)无需参与任何等待或操作。
🦋 专业术语介绍
-
阻塞 I/O (Blocking I/O)
- 进程发起 I/O 操作后,立即进入阻塞状态,(在内核将数据准备好之前,系统调用一直等待)直到内核将数据准备好并拷贝到用户空间后,进程才恢复执行。
- 所有的套接字默认是阻塞方式
- 同步 I/O:进程全程需要等待数据就绪和拷贝完成。
-
非阻塞 I/O (Non-blocking I/O)
- 进程发起 I/O 操作后,内核立即返回一个状态值(未就绪),进程通过轮询(Polling) 反复检查数据是否就绪,期间可以执行其他任务。
- 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回
EWOULDBLOCK
错误码. - 轮询:意指程序员循环的方式反复尝试读写文件描述符。这对 CPU 来说是较大的浪费, 一般只有特定场景下才使用.
- 同步 I/O:进程需要主动检查数据状态并完成拷贝。
-
信号驱动 I/O (Signal-driven I/O)
- 进程发起 I/O 操作后,内核在数据就绪时发送信号(如
SIGIO
) 通知进程,进程随后执行数据拷贝。 - 同步 I/O:数据拷贝阶段仍需进程主动完成。
- 进程发起 I/O 操作后,内核在数据就绪时发送信号(如
-
多路转接 I/O (Multiplexing I/O)
- 进程通过
select
、poll
或epoll
同时监控多个文件描述符,当任一描述符数据就绪时,内核通知进程进行处理。 - 同步 I/O:数据就绪后仍需进程主动拷贝数据。
- 进程通过
虽然从流程图上看起来和阻塞 IO 类似实际上最核心在于 IO 多路转接 能够同时等待多个文件描述符的就绪状态.
- 异步 I/O (Asynchronous I/O)
- 进程发起 I/O 操作后,内核 全程负责数据准备和拷贝,完成后通过回调(如信号或回调函数)通知进程。
- 异步 I/O:进程无需参与数据准备或拷贝。
🦋 总结表
- 同步 I/O:
- 阻塞 I/O、非阻塞 I/O、信号驱动 I/O、多路转接 I/O。
- 共同点:数据拷贝阶段需进程主动完成(即使通过信号或轮询触发)。
- 异步 I/O:
- 数据准备和拷贝全程由内核处理,进程无需参与。
I/O 模型 | 同步/异步 | 例子类比 | 进程角色 |
---|---|---|---|
阻塞 I/O | 同步 | 干等上菜 | 全程阻塞 |
非阻塞 I/O | 同步 | 轮询询问厨房 | 主动轮询 |
信号驱动 I/O | 同步 | 电话通知取餐 | 被动响应信号 |
多路转接 I/O | 同步 | 经理监听多个订单 | 批量监听 |
异步 I/O | 异步 | 服务员直接端菜到桌 | 完全无需参与 |
三:🔥 思考
🦋 阻塞 vs 非阻塞,非阻塞效率效率一定高吗?
答案:
不一定,非阻塞 I/O 的效率取决于具体场景。
- 非阻塞 I/O 的优势:
进程在等待数据就绪期间可以执行其他任务(避免完全阻塞),适合需要同时处理多任务的场景。
例子:餐厅顾客边等餐边聊天(非阻塞)比干等的顾客(阻塞)更高效。 - 非阻塞 I/O 的劣势:
如果频繁轮询(如每秒检查 1000 次),会导致 CPU 资源浪费,甚至比阻塞 I/O 效率更低。
例子:顾客每隔 1 秒就去厨房问一次,导致自己无法专心聊天,服务员也被频繁打扰。
结论:
- 低并发场景:阻塞 I/O 更简单高效(避免轮询开销)。
- 高并发场景:非阻塞 I/O + 多路复用(如
epoll
)效率更高(避免大量线程阻塞)
🦋 五种模型中,谁的 I/O 效率最高?
答案:
异步 I/O(如 Linux 的 io_uring
)理论效率最高,但实际中 多路复用 I/O(如 epoll
) 在同步模型中更常用。
- 异步 I/O:
- 优势:内核全程处理数据准备和拷贝,进程完全无需等待(服务员直接端菜到桌)。
- 限制:依赖操作系统和硬件的支持(如 Linux 的异步 I/O 实现复杂)。
- 多路复用 I/O(
epoll
):- 优势:单线程监控大量文件描述符,避免进程/线程频繁切换(大堂经理统一管理订单)。
- 场景:高并发网络服务器(如 Nginx、Redis)的核心模型。
总结:
- 异步 I/O 理论最优,但实际中多路复用 I/O 因兼容性和成熟度更常用
- 异步 I/O 的高效主要和其特点无关,还是得依靠程序员自己,而多路复用 I/O 可以用于处理大批网络数据,降低了等的比重
因此总的来说,我们更认为 多路复用的 I/O 效率更高
🦋 同步通信 vs 异步通信
同步 和 异步 关注的是消息通信机制.
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得到返回值了;
- 换句话说,就是由调用者主动等待这个调用的结果;
- 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果;
- 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用.
另外:之前我们在讲 多进程多线程 的时候,也提到同步和互斥。
注意:这里的同步通信和进程之间的同步是完全不相干的概念.
- 进程/线程同步 也是进程/线程之间直接的制约关系
- 是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调
他们的工作次序而等待、传递信息所产生的制约关系。尤其是在访问临界资源的时候.
因此以后在看到 “同步” 这个词,一定要先搞清楚大背景是什么。这个同步是同步通信异步通信的同步, 还是同步与互斥的同步
四:🔥 非阻塞 IO
🦋 fcntl
一个文件描述符, 默认都是阻塞 IO.
函数原型如下.
NAME
fcntl - manipulate file descriptor
SYNOPSIS
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
cmd 是命令,是要操作的类型。主要的操作类型有:
- 获取,设置文件状态信息:cmd=F_GETFL,F_SETFL。
- 复制现有的描述符,cmd=F_DUPFD。
- 获取,设置文件描述符标识, ,cmd=F_GETFD,F_SETFD \colorbox{pink}{ 获取,设置文件描述符标识, ,cmd=F\_GETFD,F\_SETFD } 获取,设置文件描述符标识, ,cmd=F_GETFD,F_SETFD
- 获取,设置异步IO所有权,cmd=F_GETOWN,F_SETOWN。
- 获取、设置记录锁,cmd=F_GETLK,F_SETLK,F_SETLKW。
🧊 我们此处只是用第三种功能, 获取/设置文件状态标记
, 就可以将一个文件描述符设置为非阻塞. 文件状态标志包括 O_APPEND
、O_NONBLOCK
。
🦋 实现函数 SetNonBlock
⚙️ 基于 fcntl
, 我们实现一个 SetNoBlock
函数, 将文件描述符设置为非阻塞.
#include <unistd.h>
#include <fcntl.h>
// 让文件描述符非阻塞
void SetNonBlock(int fd)
{
int f1 = fcntl(fd, F_GETFL);
if(f1 < 0)
{
perror("fcntl");
return ;
}
fcntl(fd, F_SETFL, f1 | O_NONBLOCK); // O_NONBLOCK 让fd 以非阻塞的方式进行工作
}
- 使用 F_GETFL 将当前的文件描述符的属性取出来 (这是一个位图).
- 然后再使用 F_SETFL 将文件描述符设置回去. 设置回去的同时, 加上一个 O_NONBLOCK 参数.
🦋 非阻塞方式读取标准输入
#include <iostream>
#include <cstdio>
#include <cerrno>
#include <string>
#include <unistd.h>
#include <fcntl.h>
// 让文件描述符非阻塞
void SetNonBlock(int fd)
{
int f1 = fcntl(fd, F_GETFL);
if(f1 < 0)
{
perror("fcntl");
return ;
}
fcntl(fd, F_SETFL, f1 | O_NONBLOCK); // O_NONBLOCK 让fd 以非阻塞的方式进行工作
}
int main()
{
std::string tips = "Please Enter# ";
char buffer[1024];
SetNonBlock(0);
while(true)
{
write(0, tips.c_str(), tips.size());
// 非阻塞,如果我们不做输入,数据不就绪,以出错形式返回!!
// read 不是有读取失败(-1)吗?失败vs底层数据没就绪 -> 底层数据没就绪,不算失败
// 如果是 -1, 失败vs底层数据没就绪我们后续的做法是不同的!
// read -> -1, 失败vs底层数据没就绪 -> 需要区分的必要性的!
// errno 表示:更详细的出错原因, 最近一次调用,出错的时候的出错码
int n = read(0, buffer, sizeof(buffer));
if(n > 0)
{
buffer[n] = 0;
std::cout << "echo# " << buffer << std::endl;
}
else if(n == 0)
{
std::cout << "read file end" << std::endl;
break;
}
else
{
// EAGAIN 11 /* Try again */
// EWOULDBLOCK EAGAIN /* Operation would block */
if(errno == EAGAIN || errno == EWOULDBLOCK)
{
// 做其他事情呢?
std::cout << "底层数据,没有就绪" << std::endl;
sleep(1);
continue;
}
else if (errno == EINTR)
{
std::cout << "被中断, 重新来" << std::endl;
sleep(1);
continue;
}
else
{
std::cout << "read error: " << n << ", errno: " << errno << std::endl;
}
}
}
return 0;
}
五:🔥 共勉
😋 以上就是我对 【Linux】五种 IO 模型与阻塞 IO
的理解, 觉得这篇博客对你有帮助的,可以点赞收藏关注支持一波~ 😉