五种IO模型与非阻塞IO
一、核心问题:基本I/O的瓶颈
文档开篇点明了所有I/O操作的本质:
- 1.等待:等待数据准备好(例如,等待网络数据包到达内核缓冲区)。
- 2.拷贝:将数据从内核缓冲区拷贝到用户空间(应用程序的内存)。
关键洞察 :在实际网络环境中,"等待"所消耗的时间远远大于"拷贝"的时间 。因此,提高I/O效率的核心不在于加快拷贝速度,而在于如何更高效地"等待"。
五种I/O模型就是为了解决"如何等待"这个问题而提出的不同策略。
二、五种I/O模型详解(从低级到高级)
文档用"钓鱼"的比喻非常形象,我们来技术化地解释一下:
1. 阻塞I/O
- •工作方式 :应用程序发起I/O调用(如
read)后,线程被挂起,一直阻塞,直到内核数据准备好并完成拷贝,函数才返回。 - •优点:编程简单。
- •缺点:一个线程只能处理一个连接,性能极差。为每个连接创建一个线程会消耗大量资源。
- •类比:张三一直盯着鱼竿,鱼不上钩就不做任何别的事。
2. 非阻塞I/O
- •工作方式 :应用程序发起I/O调用,如果内核数据没准备好,立即返回一个错误码 ,而不是阻塞线程。程序需要不断地轮询尝试,直到数据准备好。
- •优点:线程不会阻塞,可以在等待一个连接时做别的事(比如处理其他连接)。
- •缺点:轮询会消耗大量CPU资源,因为线程在不停地空转检查。
- •类比:李四过几秒就拉一下鱼竿看看,没鱼就继续做别的事,但频繁检查很累人。
3. 信号驱动I/O
- •工作方式 :应用程序先向内核注册一个信号处理函数,然后可以去干别的事。当内核数据准备好时,它会向应用程序发送一个信号(如
SIGIO),应用程序收到信号后再来执行I/O操作。 - •优点:避免了轮询,CPU利用率高。
- •缺点:信号处理本身比较复杂,并且在大量I/O操作时,信号可能会变得不可靠。
- •类比:王五在鱼竿上装了个铃铛,鱼上钩铃会响,他听到铃声再来收竿。
4. I/O多路复用
- •工作方式 :这是最重要的模型 。应用程序通过调用
select,poll,epoll等函数,同时监控多个文件描述符。这个函数调用是阻塞的,但当任何一个被监控的描述符数据准备好时,函数就会返回,应用程序再遍历准备好的描述符进行I/O操作。 - •优点 :单线程就可以高效地管理成百上千个连接,这是高性能网络服务器(如Nginx, Redis)的基石。
- •与阻塞I/O的区别:阻塞IIO是一个线程堵在一个连接上;多路复用是一个线程堵在"监控器"上,但可以同时等待多个连接。
- •类比 :赵六一个人同时看管很多根鱼竿(
epoll),只要任何一根有鱼上钩,他就去处理那一根。
5. 异步I/O
- •工作方式 :应用程序发起一个I/O请求后,立即返回,内核会自动完成所有工作(包括等待数据和数据拷贝)。拷贝完成后,内核再通知应用程序。
- •与信号驱动I/O的区别 :信号驱动I/O是内核通知我们"可以开始拷贝数据了 ",拷贝操作要我们自己来做。而异步I/O是内核通知我们"数据拷贝已经完成了"。
- •类比:田七雇了一个帮手(内核),让帮手去钓鱼,钓好的鱼会直接送到家里。田七完全不用管钓鱼的过程。
三、关键概念辨析(非常重要!)
1. 同步 vs. 异步
- •关注点 :消息通信机制。即结果是由谁主动提供的。
- •同步 :调用者主动等待 结果。调用一个函数,在得到结果之前,调用不会返回。 •例子 :阻塞I/O、非阻塞I/O、I/O多路复用都是同步的。因为真正的I/O操作(
read/write)还是由调用者线程自己完成的。 - •异步 :调用发出后,这个调用就直接返回了,结果由被调用者通过通知或回调函数被动送达 。 •例子:只有异步I/O是真正的异步。
2. 阻塞 vs. 非阻塞
- •关注点 :调用者在等待结果时的状态。
- •阻塞:调用结果返回前,当前线程会被挂起,不能执行其他任务。
- •非阻塞:调用结果返回前,线程不会被挂起,可以执行其他任务。
组合关系
- •同步阻塞:阻塞I/O。
- •同步非阻塞 :非阻塞I/O、I/O多路复用(
select本身是阻塞的,但它阻塞在多个连接上,相对于单个连接的处理线程来说,它是非阻塞的)。 - •异步非阻塞:异步I/O。
总结
核心思想是:
- 1.基本I/O模型(阻塞I/O)效率低下,因为它让宝贵的线程资源在"等待"上白白浪费。
- 2.高级I/O模型的核心目标是减少线程在I/O等待上的耗时,用更智能的方式去"等待"。
- 3.I/O多路复用(如
epoll) 是实践中最重要、最高效的模型,它能用最少的资源处理最多的并发连接。
首先,我们明确故事中的角色对应关系:
- •唐僧 = 需要被处理的数据(比如网络数据包)
- •蒸笼 = 操作系统内核(负责真正执行I/O操作的地方)
- •小妖 = 应用程序/程序员(负责发起I/O调用和处理结果)
- •蒸唐僧 = 一次I/O操作(比如从网络读取数据)
场景一:同步阻塞(最传统的方式)
故事:一个小妖把唐僧放进蒸笼,然后啥也不干,就搬个凳子坐在蒸笼前,眼睛死死盯着蒸笼。他心里想:"在唐僧蒸好(调用返回)之前,我就在这儿等着,哪儿也不去。" 他一直等到蒸笼冒出蒸汽(得到结果),然后打开蒸笼取出唐僧。
技术对应:
- •同步 :小妖主动等待结果(坐在蒸笼前等)。调用(开始蒸)没有返回,他在等待最终结果(蒸熟的唐僧)。
- •阻塞 :在等待过程中,小妖被挂起,不能做任何其他事情(不能去巡山、不能去喝酒)。
总结 :这就是最基本的阻塞I/O。应用程序发起一个读取请求,线程就被挂起,直到数据完全准备好才返回。效率最低。
场景二:同步非阻塞(轮询方式)
故事 :小妖把唐僧放进蒸笼后,没有坐在那里干等。他对自己说:"我去干点别的(比如去巡山),但我每隔5分钟就跑回来看一眼蒸笼好了没。" 他每次跑回来,如果发现没好(EAGAIN错误),就继续去巡山;如果发现好了(调用成功返回),就取出唐僧。
技术对应:
- •同步 :小妖依然需要主动去查看结果(跑回来看)。结果的最终获取(取出唐僧)还是由他本人完成。
- •非阻塞 :在"蒸唐僧"这个操作没有完成时,小妖没有被挂起,他可以自由地去干其他事情(巡山)。
总结 :这就是非阻塞I/O 。应用程序发起读取请求,如果数据没准备好,内核立即返回一个错误码(如EWOULDBLOCK),线程可以继续执行其他任务,但需要不断地轮询(polling)检查数据是否就绪。虽然不阻塞了,但轮询消耗CPU。
场景三:异步(最高效的方式)
故事 :小妖是一个"现代化"的妖怪。他发明了一个带闹钟的智能蒸笼。他把唐僧放进蒸笼,按下开始键,然后就直接去喝酒吃肉了,完全不管蒸笼。当唐僧蒸好的那一刻,智能蒸笼会自动发出"滴滴滴"的响声(通知 ),或者甚至自动派一个小机器人把蒸好的唐僧送到小妖面前(回调)。
技术对应:
- •异步 :小妖不需要主动等待或查看结果 。他发起调用(按下开始键)后,调用立即返回,他可以彻底干别的事。结果的送达是由被调用者(智能蒸笼)通过通知 或回调的方式"推送"过来的。
总结 :这就是异步I/O。应用程序发起一个读取请求后,内核会自行完成所有工作(包括等待数据和拷贝数据),完成后主动通知应用程序。应用程序在整个过程中都可以执行其他代码。
总结与对比表格
| 模式 | 通信机制(同步/异步) | 等待状态(阻塞/非阻塞) | "妖怪蒸唐僧"比喻 |
|---|---|---|---|
| 同步阻塞 | 同步:调用者主动等待结果 | 阻塞:调用者被挂起,不能干别的 | 干等式:坐在蒸笼前啥也不干,直到蒸好 |
| 同步非阻塞 | 同步:调用者主动轮询结果 | 非阻塞:调用者可以干别的 | 轮询式:边巡山边每隔5分钟跑回来看一眼 |
| 异步 | 异步:被调用者通知结果 | 非阻塞:调用者可以干别的 | 智能式:设置好闹钟后就去玩,闹钟响或直接送货上门 |
核心区别:
- •同步 vs 异步 :关注的是结果如何被返回。是调用者主动去取(同步),还是被调用者送上门(异步)?
- •阻塞 vs 非阻塞 :关注的是等待结果时调用者的状态。是完全不能动(阻塞),还是可以自由活动(非阻塞)?
其他高级 IO
非阻塞 IO,纪录锁,系统 V 流机制,I/O 多路转接(也叫 I/O 多路复用),readv 和writev 函数以及存储映射 IO(mmap),这些统称为高级 IO.
非阻塞 IO
fcntl
一个文件描述符, 默认都是阻塞 IO.
函数原型如下.
C++
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
传入的 cmd 的值不同, 后面追加的参数也不相同.
fcntl 函数有 5 种功能:
• 复制一个现有的描述符(cmd=F_DUPFD).
• 获得/设置文件描述符标记(cmd=F_GETFD 或 F_SETFD).
• 获得/设置文件状态标记(cmd=F_GETFL 或 F_SETFL).
• 获得/设置异步 I/O 所有权(cmd=F_GETOWN 或 F_SETOWN).
• 获得/设置记录锁(cmd=F_GETLK,F_SETLK 或 F_SETLKW).
我们此处只是用第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为非阻塞.
实现函数 SetNoBlock
基于 fcntl, 我们实现一个 SetNoBlock 函数, 将文件描述符设置为非阻塞.
非阻塞代码如下:
C++
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <fcntl.h>
//0标准输入默认就是阻塞的
void SetNonBlock(int fd)
{
int fl=fcntl(fd,F_GETFL);// 获取文件描述符当前标志
if(fl<0)
{
perror("fcntl");
return;
}
fcntl(fd,F_SETFL,fl | O_NONBLOCK);//O_NONBLOCK为非阻塞
}
int main()
{
SetNonBlock(0);
char buffer[1024];
while(true)
{
//Linux中:ctrl+d:标识输入结束,read返回值是0,类似于读到文件结尾
ssize_t n=read(0,buffer,sizeof(buffer));
if(n>0)
{
buffer[n-1]=0;//将最后改为'/0'
std::cout<<buffer<<std::endl;
}
else if(n<0)
{
//1.读取出错//2.底层没有数据准备好
if(errno==EAGAIN||errno==EWOULDBLOCK)
{
std::cout<<"数据没有准备好。。"<<std::endl;
sleep(1);
continue;
}
else if(errno==EINTR)//系统中断
{
continue;
}
else
{
//真正的READ出错了
}
}
else
{
break;
}
sleep(1);
std::cout<<".:"<<n<<std::endl;//C++也有语言缓冲区
}
return 0;
}
四、技术要点总结
| 概念 | 说明 | 在本程序中的体现 |
|---|---|---|
| 阻塞I/O | read会一直等待直到有数据 | 通过SetNonBlock(0)禁用 |
| 非阻塞I/O | read立即返回,通过返回值判断 | 核心逻辑在n < 0的处理 |
| 轮询 | 循环检查是否有数据可读 | while(true)+ sleep(1) |
| 错误处理 | 区分真正错误和暂时无数据 | 检查errno的值 |
五、实际应用场景
这种模式是高性能网络编程的基础:
- 单线程处理多连接:一个线程可以同时监控成百上千个网络连接
- 事件驱动架构 :更高效的实现是使用
epoll等系统调用,而不是忙等待 - 避免线程阻塞:不需要为每个连接创建线程,节省系统资源
| 轮询 | 循环检查是否有数据可读 | while(true)+ sleep(1) |
| 错误处理 | 区分真正错误和暂时无数据 | 检查errno的值 |
五、实际应用场景
这种模式是高性能网络编程的基础:
- 单线程处理多连接:一个线程可以同时监控成百上千个网络连接
- 事件驱动架构 :更高效的实现是使用
epoll等系统调用,而不是忙等待 - 避免线程阻塞:不需要为每个连接创建线程,节省系统资源
这个简单的demo展示了非阻塞I/O的基本原理,是理解select/poll/epoll等高级I/O多路复用技术的重要基础。