五种IO模型

先明确核心前提:IO 的两个必经步骤

不管哪种 IO 模型,完成一次数据读写(比如从网卡读数据、从文件读数据)都要走两步:

  1. 等待(Waiting):内核等待数据准备好(比如网卡接收到完整的网络数据包、硬盘把数据加载到内存);
  2. 拷贝(Copying):内核把准备好的数据拷贝到应用程序的内存空间(比如把网卡数据从内核缓冲区复制到你的代码能访问的内存)。

实际场景中,等待占了 99% 的时间(比如网卡等数据包可能要等 100ms,拷贝只需要 0.1ms),所以 IO 优化的核心就是 "怎么减少等待时间"。


阻塞 IO

你去奶茶店点单后,站在柜台前一直等,不做任何事,直到奶茶做好(等待),然后亲手接过奶茶(拷贝)------ 全程被 "买奶茶" 这件事阻塞,没法干别的。

  • 应用程序发起 IO 系统调用(比如read())后,内核数据没准备好时,系统调用会一直挂起,应用程序被阻塞(CPU 不会给它分配时间片);
  • 直到内核数据准备好,内核把数据拷贝到应用程序内存,系统调用才返回,应用程序恢复运行;所有套接字(socket)默认都是阻塞模式

非阻塞 IO

你去奶茶店点单后,店员告诉你 "还没好,你先走走",你没闲着 ------每隔 1 分钟就回来问一次 "好了吗?"(轮询),直到问的时候奶茶做好了,你接过奶茶。

技术本质

  • 先把文件描述符(比如 socket)设置为非阻塞模式(最后会讲解方法);
  • 应用程序发起 IO 系统调用后,内核数据没准备好时,系统调用会立刻返回EWOULDBLOCK错误码(表示 "暂时没数据,你别等了"),不会阻塞程序;
  • 程序员需要写循环(轮询),反复调用 IO 接口,直到内核数据准备好,完成拷贝。

特点

✅ 优点

  • 等待期间程序可以干其他事:比如在sleep(1)前加一行printf("轮询中,干其他事...\n");,就能在等待输入时执行其他逻辑;
  • 不会被单个 IO 请求卡住:比如如果是网络 socket,非阻塞模式下不会因为一个 socket 没数据就卡住整个程序。

❌ 缺点

  • CPU 空耗:即使加了sleep(1),轮询还是会占用 CPU(如果去掉 sleep,CPU 会 100% 占用);
  • 代码逻辑复杂:需要处理read返回值的各种情况,比阻塞 IO 多很多判断。

📌 适用场景

  • 极特殊的低延迟需求(比如高频次小数据读写);
  • 一般不单独使用,常和 IO 多路转接(epoll/select)结合(比如 epoll 监控非阻塞 socket,避免轮询空耗)。

信号驱动 IO

你去奶茶店点单后,留了手机号,告诉店员 "做好了给我发个短信",然后你去逛街(干别的事)------等收到短信(信号),再回来取奶茶

2. 技术本质

  • 应用程序先给文件描述符注册一个SIGIO信号处理函数(告诉内核:数据准备好时给我发这个信号);
  • 注册后应用程序可以正常运行,不用等待;
  • 内核数据准备好后,会给应用程序发送SIGIO信号,应用程序收到信号后,再发起 IO 系统调用完成数据拷贝。

3. 关键区别

信号驱动 IO 只通知 "数据准备好了,可以开始拷贝了",拷贝过程仍然是阻塞的 (比如收到信号后调用read(),拷贝时程序还是会卡住)。

4. 特点

  • ✅ 优点:等待期间 CPU 完全解放,不用轮询,利用率高;
  • ❌ 缺点:信号处理逻辑复杂(比如多个 IO 请求的信号可能混淆),且拷贝阶段仍阻塞;
  • 📌 适用场景:少量 IO 请求、对 CPU 利用率要求高的场景(比如嵌入式设备)。

IO 多路转接

你开了一家奶茶店雇了一个 "前台"------ 所有顾客点单后,都把订单交给前台,前台同时盯着所有订单的进度,哪个订单的奶茶做好了,就喊对应的顾客来取。你不用挨个问,只等前台通知。

2. 技术本质

  • 核心是select/poll/epoll(Linux)、kqueue(BSD)等多路转接函数,它们能同时监控多个文件描述符的就绪状态(比如 "是否有数据可读");
  • 应用程序调用epoll_wait()后,内核会阻塞这个调用,但会同时监控所有注册的文件描述符;
  • 只要有任意一个文件描述符的数据准备好,epoll_wait()就会返回,告诉应用程序 "哪个描述符就绪了";
  • 应用程序再针对就绪的描述符发起 IO 调用,完成拷贝。

3. 关键区别

  • 看似和阻塞 IO 一样 "等",但阻塞 IO 是 "等一个",多路转接是 "等多个"------ 比如一个进程用epoll可以监控 1000 个 socket,只要有一个有数据,就处理哪个;
  • 等待阶段是内核帮你批量等,CPU 利用率远高于阻塞 IO。

4. 特点

  • ✅ 优点:能高效处理高并发(比如单进程处理上万的 socket 连接),CPU 利用率高;
  • ❌ 缺点:拷贝阶段仍然是阻塞的;
  • 📌 适用场景:网络编程的核心场景(比如 Web 服务器、游戏服务器),是目前高并发 IO 的主流方案(Nginx、Redis 都用epoll)。

异步 IO

1. 通俗类比

你在奶茶店点单后,直接告诉店员 "做好了帮我送到家",然后你该干啥干啥 ------既不用等,也不用自己取,全程不用管,直到奶茶送到家(你负责在拷贝完成后直接使用,而不需要关系拷贝本身)。

2. 技术本质

  • 应用程序发起异步 IO 调用(比如 Linux 的aio_read()),告诉内核 "我要读数据,读完了通知我",然后立刻返回,完全不阻塞;
  • 内核会自己完成 "等待数据准备好 + 拷贝数据到应用程序内存" 的全部过程;
  • 等所有步骤都完成后,内核才会通知应用程序(比如通过信号或回调函数)。

3. 关键区别

和信号驱动 IO 的核心差异:

  • 信号驱动 IO:内核通知 "可以开始拷贝了"(只完成等待);
  • 异步 IO:内核通知 "拷贝已经完成了"(等待 + 拷贝都完成)。

4. 特点

  • ✅ 优点:IO 全程不阻塞应用程序,CPU 利用率最高;
  • ❌ 缺点:实现复杂,操作系统支持有限(如 Linux 的异步 IO 对文件 / 网络的支持不完全)
  • 📌 适用场景:超大规模并发、对响应时间要求极高的场景(比如分布式存储系统)。

关于异步IO的具体实现等我们复习到了c++11的异步关键字了再说


非阻塞 IO 的实现

非阻塞 IO 的本质是:文件描述符(fd)被设置为非阻塞后,调用read()/write()等 IO 函数时,内核若未准备好数据,函数会立刻返回(而非阻塞等待),并返回错误码(通常是EWOULDBLOCKEAGAIN)。

  • 标准输入(fd=0)、socket 等默认都是阻塞模式:比如阻塞模式下read(0, ...)会一直等你输入,程序卡着不动;
  • 设为非阻塞后:read(0, ...)会立刻返回 ------ 有输入就读数据,没输入就返回错误,程序不会卡。

fcntl 函数

fcntl是 Linux 下操作文件描述符属性的多功能函数,我们只关注它的获取 / 设置文件状态标记功能(对应cmd=F_GETFL/F_SETFL):

  • F_GETFL:读取当前文件描述符的 "状态标记"(比如是否是阻塞、是否是只读 / 只写等,本质是一个位图);
  • F_SETFL:修改文件描述符的 "状态标记";
  • O_NONBLOCK:非阻塞标志 ------ 给状态标记加上这个值,文件描述符就变成非阻塞模式。
  1. 为什么不能直接设置,要 "先读再改"?

文件描述符的状态标记是位图(比如阻塞 = 0、非阻塞 = 1,只读 = 2、只写 = 4...),如果直接用F_SETFL设置O_NONBLOCK,会覆盖原有属性(比如原本是 "只读 + 阻塞",直接设成O_NONBLOCK会丢失 "只读" 属性)。所以正确做法是:先读取原有属性 → 加上非阻塞标志 → 再写回去。


SetNoBlock 函数

复制代码
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h> // 补充perror需要的头文件

void SetNoBlock(int fd) {
    // 步骤1:读取fd当前的状态标记
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) { // 读取失败(比如fd无效)
        perror("fcntl"); // 打印错误原因
        return;
    }
    // 步骤2:修改状态标记------保留原有属性,新增O_NONBLOCK
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
  1. int fl = fcntl(fd, F_GETFL);

    • 调用fcntl,传入cmd=F_GETFL,表示 "读取 fd 的状态标记";
    • 返回值fl是一个整数,每一位代表一个属性(比如第 0 位 = 1 表示非阻塞,第 1 位 = 1 表示只读...)。
  2. if (fl < 0) { perror("fcntl"); return; }

    • 如果返回值 < 0,说明读取失败(比如 fd 是无效的,或者没有权限);
    • perror("fcntl")会打印具体错误(比如 "fcntl: Bad file descriptor" 表示 fd 无效)。
  3. fcntl(fd, F_SETFL, fl | O_NONBLOCK);

    • fl | O_NONBLOCK:按位或操作 ------ 保留fl原有所有属性,同时把 "非阻塞位" 设为 1;
    • 传入cmd=F_SETFL,把修改后的状态标记写回 fd,此时 fd 就变成非阻塞模式。

轮询读取标准输入:非阻塞 IO 的实战

标准输入的 fd 是 0,下面的代码把 fd=0 设为非阻塞,然后用循环 "轮询" 读取输入(非阻塞 IO 的典型用法):

复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h> // 补充memset需要的头文件

void SetNoBlock(int fd) {
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0) {
        perror("fcntl");
        return;
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

int main() {
    // 步骤1:将标准输入(fd=0)设为非阻塞
    SetNoBlock(0);

    // 步骤2:无限循环轮询读取输入
    while (1) {
        char buf[1024] = {0}; // 初始化缓冲区
        // 步骤3:非阻塞读取标准输入
        ssize_t read_size = read(0, buf, sizeof(buf) - 1);
        
        // 步骤4:处理read的返回值
        if (read_size < 0) {
            // 读取失败:非阻塞模式下,无数据时会返回-1,错误码是EWOULDBLOCK/EAGAIN
            perror("read"); 
            sleep(1); // 轮询间隔,避免CPU空耗
            continue; // 继续下一次循环
        }
        // 步骤5:读取成功,打印输入内容
        printf("input:%s\n", buf);
        // 清空缓冲区(可选,避免残留数据)
        memset(buf, 0, sizeof(buf));
    }
    return 0;
}
相关推荐
宋军涛2 小时前
SqlServer性能优化
运维·服务器·性能优化
rocksun2 小时前
Neovim,会是你的下一款“真香”开发神器吗?
linux·python·go
郝学胜-神的一滴2 小时前
Linux线程属性设置分离技术详解
linux·服务器·数据结构·c++·程序人生·算法
知识分享小能手2 小时前
Ubuntu入门学习教程,从入门到精通, Ubuntu 22.04中的进程管理详解(15)
linux·学习·ubuntu
zfj3212 小时前
Linux内核和发行版的的区别、职责
linux·运维·服务器·内核·linux发行版
leoufung3 小时前
LeetCode 120. Triangle:从 0 分到 100 分的思考过程(含二维 DP 与空间优化)
linux·算法·leetcode
`林中水滴`3 小时前
Linux Shell 命令:nohup、&、>、bg、fg、jobs 总结
linux·服务器·microsoft
appearappear3 小时前
阿里云同区域不同账户下两个服务器打通内网
服务器·阿里云·云计算
helloworddm3 小时前
防止应用多开-WPF
服务器·架构·c#