欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 房产 > 建筑 > 高级IO模型

高级IO模型

2025/4/4 12:28:37 来源:https://blog.csdn.net/ZZY5707/article/details/146885495  浏览:    关键词:高级IO模型

五种IO模型

I/O(输入/输出) 全名为 input/output, 比如我们平时在访问外设(比如网卡)时, 在进行 I/O 操作(如 recv 或 scanf )时, 大部分时间可能会花在等待数据到达等待 I/O 设备就绪, 真正的数据传输(拷贝)只占用其中的一小部分时间, 所以IO的本质等待 + 拷贝

我们常说IO效率比较低, 但是什么叫高效IO?

IO中无论是从 外设<->内存, 还是 内存<->内存 (用户缓冲区<->内核缓冲区), IO工作本质都是数据拷贝. 如果我们每时每刻都在拷贝, 那IO就是效率最高的, 因此高效IO的高效就在于减少单位时间内IO等待的比重, 提高IO效率就需要减少单位时间内IO等待的比重.


拿钓鱼这件事举例, 钓鱼的过程可以简化为: 等待 + 钓鱼

我们假设鱼塘是OS, 鱼是数据, 鱼漂是就绪条件, 钓鱼者是进程(准确来说是接口), 现在有这样五个人在钓鱼:

  • 张三: 永远盯着鱼漂, 谁叫我都不理  
  • 李四: 一会看看鱼漂, 一会看看书, 看看手机  
  • 王五: 铃铛绑在了鱼竿的顶部, 铃铛响了我就收杆
  • 赵六: 同时掌管100个鱼竿
  • 田七: 我其实就是想吃鱼, 我不钓鱼, 我让助理去钓鱼, 我坐享其成. 

线程(钓鱼者)调用 recv() 后,会一直等待数据(盯着鱼漂),直到数据到达(鱼漂动了),才会继续执行。 

线程不会一直等待数据,而是调用 recv() 发现没数据后就做其他事(看看书、玩手机),然后过一会儿再检查数据是否到达。

这五个例子对应了五种IO模型, 先笼统认识一下:

  • 阻塞IO: 在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式
  • 非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码.
  • 信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作
  •  IO多路转接: 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态
  • 异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).

其中还有一个假设鱼竿, 它其实指的是文件描述符, 为什么?

进程调用 I/O 操作的本质都是在等待文件描述符(FD, File Descriptor)变得可用, 无论是 socket、文件、管道、设备等.

1. 阻塞式IO

阻塞式IO是我们IO模型中最常见的IO模型, 没有之一.

比如网络通信中, 以 recv() 为例:

1. 调用 recv() 时, 内核检查 socket 接收缓冲区:

如果缓冲区有数据, 立即把数据从内核缓冲区拷贝到用户进程缓冲区并返回.

这里更重要的是, 阻塞式IO下: 如果缓冲区没有数据, 进程进入阻塞态, OS 将其PCB挂起到 socket (fd)的等待队列, 并切换 CPU 运行其他进程

2. 等待数据到达:

当对端发送数据到这个 socket, 网卡发送中断信号给OS, OS调用网卡驱动程序将数据从网卡(外设)拷贝到 内核接收缓冲区(内存), 随后 OS 发现有进程在该 socket (本质是对应的FD) 的阻塞队列中等待数据, 于是唤醒一个(或多个)阻塞的进程. 

3. 进程被唤醒, 状态从 “阻塞” 改为 “就绪”

唤醒指的是进程PCB中状态由阻塞态改为就绪态, 等待 CPU 调度执行, 调度器决定何时真正运行这个进程.

4. 进程恢复执行

CPU 轮到该进程运行后, 它继续执行recv(),数据从 内核缓冲区 拷贝到 用户态缓冲区, recv() 调用返回数据.

OS在其中的作用主要是: 需要OS唤醒进程, 发现等待队列中有阻塞的进程, 就修改进程状态为“就绪” 

2. 非阻塞IO:

非阻塞IO和阻塞IO的区别在于等待的方式, 非阻塞式IO下进程往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这对CPU来说是较大的浪费, 一般只有特定场景下才使用.

1. 调用 recv() 时, 内核检查 socket 接收缓冲区:

如果缓冲区有数据, 立即把数据从内核缓冲区拷贝到用户进程缓冲区并返回.

非阻塞式IO下: 如果缓冲区没有数据, 进程会立即返回 EWOULDBLOCK或 EAGAIN, 表示当前没有数据可读, 需要过一段时候再调用一次recv, 而不是被挂起到FD的等待队列下被阻塞.

2. 等待数据到达:

当对端发送数据到这个 socket, 网卡发送中断信号给OS, OS调用网卡驱动程序将数据从网卡(外设)拷贝到 内核接收缓冲区(内存), 此时进程不会被立即唤醒. 而是在等待下一次 recv 函数被执行.

因此非阻塞IO下不会有“进程等待 OS 唤醒”的情况,因为进程从未进入阻塞状态.

3. 进程恢复执行

CPU 轮到该进程运行后, 它会再次调用recv(), 此时OS检测到缓冲区有数据, 满足 recv 函数的就绪条件, 数据从 内核缓冲区 拷贝到 用户态缓冲区, recv成功返回.

OS对于进程状态的改变没有起到太大的作用, 也就是不需要OS去唤醒.

 3. 信号驱动IO

信号驱动 I/O 是 基于信号机制 进行 I/O 操作的, 它的核心思想是让 OS 主动通知进程 “你的 socket 有数据了”, 而不是让进程自己轮询 recv()

这个情况不多解释, 主要需要知道它和前两者的区别, 它既有非阻塞IO的属性, 也就是 recv 不满足成功条件会立即返回,  不会被挂起, 但是它并不需要轮询, 而是像阻塞IO一样, 等待OS去为进程状态的改变做工作, 只不过阻塞IO下是OS直接修改进程的状态, 而信号驱动IO下是OS发送SIGIO信号, 进程立即执行信号处理函数, 操作权交给进程, recv的数据拷贝工作是在信号处理程序中进行.

一句话总结: 信号驱动 I/O 结合了非阻塞 I/O 的“立即返回”, 又避免了轮询, 通过 SIGIO 信号实现了 OS 主动通知进程.

4. IO多路转接:

前三种IO模型, 无论是阻塞IO, 非阻塞IO还是信号驱动IO, 它们的唯一区别在于IO两步中的等待, 它们等待的方式不同, 仅此而已, 不管等待的方式再怎么不同, 在处理IO的效率方面, 它们是没有本质的区别的, 因为它们在数据就绪时, 都要自己拿着这个fd去拷贝; 即使非阻塞IO有效率的提升, 它的效率高也仅仅体现在可以把等待的时间用在其它的事情上, 只是减少了等待时间的浪费, 而没有提高IO处理过程的高效.

为了能够更好的进行多路复用, linux系统提供了全新的系统调用select, poll, epoll:

虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态. 

在以前recv, read, write这样的接口, 它们传递的文件描述符只有一个, 而 select 允许用户把多个文件描述符交给它管理, IO的两步中, select只负责"等待", 多个文件描述符只需要其中一个就绪(就绪的概率大大增加), 就可以通过编程通知应用程序去调用recv, 此时 recv 就一定能读取到数据. 因为这本质是优化了IO的两步, 各司其职, 不要让recv这样的接口身兼两职, 等待的工作交给select, 而你们那只需要去拷贝就可以了! 

这样的方案就叫做多路复用:

为什么多路复用高效呢? select是一个执行流下等待多个文件描述符, 而多线程虽然也可以达成类似的效果, 但这也提高了创建, 调度的成本, 而 select 始终都可以是单进程的. 

 5. 异步IO

由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).

所以本质区别是: 异步IO中, 操作系统既帮忙等待又帮忙拷贝, 而信号驱动IO是依然要自己(应用程序进程)调用接口去拷贝.

同步和异步 

同步和异步关注的是消息通信机制.

  • 所谓同步, 就是在发出一个调用时, 在没有得到结果之前, 该调用就不返回. 但是一旦调用返回, 就得到返回值了, 换句话说, 就是由调用者主动等待这个调用的结果;
  • 异步则是相反, 调用在发出之后, 这个调用就直接返回了, 所以没有返回结果; 换句话说, 当一个异步过程调用发出后, 调用者不会立刻得到结果; 而是在调用发出后, 被调用者通过状态、通知来通知调用者, 或通过回调函数处理这个调用.

这里会有一个疑问: 按照这个定义, 非阻塞 I/O 和信号驱动 I/O 似乎也符合“异步”, 因为它们都会在读取失败时立刻返回, 而且进程的等待和信号的产生一定是异步的, 但它只是局部的异步, 一旦数据准备好了, 它        们最后都仍然需要进程亲自去调用recv主动获取数据, 所以实际上它们仍然属于同步 I/O

 此外, 需要注意 多线程同步互斥的同步 和 同步IO的同步 完全无关:

  • 多线程同步是指: 多线程协同工作时按照特定的步调执行, 进程/线程同步也是进程/线程之间直接的制约关系, 是线程需要在特定的工作场景下协调它们的工作次序而进行等待, 传递信息所产生的制约关系, 尤其是在访问临界资源的时候.
  • 同步IO是指: 调用 I/O 操作的线程会等待 I/O 完成后才能继续执行, 在 I/O 完成前, 线程处于阻塞状态. 这里还可以把同步识别为, 只要涉及到IO的等待或拷贝中的一种, 就认为是同步IO. 比如阻塞和非阻塞式IO都涉及等待和拷贝, 只是等待的方式不同; 信号驱动式IO等待不明显但是也有主动去拷贝的动作; 而多路复用类似阻塞IO, 自然也涉及等待和拷贝. 

非阻塞IO

一个文件描述符, 默认都是阻塞IO. 我们有两种方式可以改变IO的阻塞方式, 一是如果IO接口参数中有flag字段, 修改flag字段即可, 比如 recv , 如果把 flags 设置为零, 其和直接调用 read 是一模一样的.

但是并不是所有接口都有flags选项, 一种更直接的方式是用fcntl直接修改文件描述符标记:

fcntl函数原型如下:

#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).

我们此处只是用第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为非阻塞.

这段代码修改标准输入流的状态为非阻塞, 正常我们输入什么就回显什么, 而IO等待时会输出一条提示信息 "OS底层数据没有就绪, errno: xx" :

#include <unistd.h>
#include <iostream>
#include <fcntl.h>void setNonBlock(int fd)
{int flag = fcntl(fd, F_GETFL);if (flag < 0) {perror("fcntl");return;}fcntl(fd, F_SETFL, flag | O_NONBLOCK);
}int main()
{setNonBlock(0);while(true){char buff[1024];ssize_t n = read(0, buff, sizeof(buff)-1);if(n > 0){buff[n] = '\0';std::cout << "echo# " << buff;}else if(n == 0){std::cout << "echo done" << std::endl;break;}else{if(errno == EWOULDBLOCK){std::cerr << "OS底层数据没有就绪, errno: " << errno << std::endl;}else if(errno == EINTR){std::cerr << "数据读取被信号中断" << errno << std::endl;}else{std::cerr << "读取错误, errno: " << errno << std::endl;break;}}sleep(1);}return 0;
}

值得我们注意的是,  如果数据没有准备好,返回值会按照出错返回 -1, 并设置错误码.

数据没有准备好 vs 真的出错了

它们的处理方式一定是不一样的, 仅仅只通过返回值是无法区分的, 数据没有准备好, 算读取错误吗? 不算, read, recv 只能以出错的形式告知上层, 数据还没有准备好, 具体怎么处理要再根据errno去判断.

如果错误码为 EWOULDBLOCK 或 EAGAIN 时, 就代表此时数据还没有准备好: 

 查看结果:


版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词