Linux下 五种IO模型的认识与⾮阻塞IO的实现

欢迎来到我的频道!【点击跳转专栏】

本文所有代码已托管至码云:【点此转跳】

文章目录

  • [1. 五种IO模型](#1. 五种IO模型)
    • [1.1 关于IO的理解](#1.1 关于IO的理解)
    • [1.2 引出5种模型](#1.2 引出5种模型)
      • [1.2.1 阻塞IO](#1.2.1 阻塞IO)
      • [1.2.2 ⾮阻塞IO](#1.2.2 ⾮阻塞IO)
      • [1.2.3 信号驱动IO](#1.2.3 信号驱动IO)
      • [1.2.4 IO多路转接](#1.2.4 IO多路转接)
      • [1.2.5 异步IO](#1.2.5 异步IO)
    • [1.3 ⾼级IO重要概念](#1.3 ⾼级IO重要概念)
      • [1.3.1 阻塞 vs ⾮阻塞](#1.3.1 阻塞 vs ⾮阻塞)
      • [1.3.2 同步IO VS 异步IO](#1.3.2 同步IO VS 异步IO)
  • [2. ⾮阻塞IO](#2. ⾮阻塞IO)
    • [2.1 fcntl](#2.1 fcntl)
    • [2.2 实现函数SetNoBlock](#2.2 实现函数SetNoBlock)
    • [2.3 加餐:信号唤醒阻塞进程的原理](#2.3 加餐:信号唤醒阻塞进程的原理)
    • [2.4 轮询⽅式读取标准输⼊](#2.4 轮询⽅式读取标准输⼊)

1. 五种IO模型

1.1 关于IO的理解

  1. IO时什么?本质是什么?

IO叫Input/output,是 外设 <-> 内存 之间的交互。 网络通信本质是属于IO范畴的。

  • 使用read进行input需要 数据就位 + 拷贝
  • 使用write进行output需要 空间就绪 + 拷贝

所以 IO的本质就是 等待 + 拷贝! 所以我们IO觉得慢,大概率是因为等的时间太久了!而拷贝速度是跟硬件强相关的!

  1. 什么叫高效IO?如何设计高效IO?

在单位时间能进行更多次数据交互,就叫高效IO! 因为拷贝速率跟硬件强相关我们无法控制,所以也就是IO中等的比例越低,IO效率越高!设计高效IO的方法就是 减少单位时间内IO中等的比重!

1.2 引出5种模型

我们以钓鱼举例,钓鱼分为等+钓,将鱼漂向下理解为鱼就绪!此时来了五个人。

  • 张三:一直关注鱼漂,鱼上钩立马识别,完成钓鱼,如果鱼漂不动,什么都不做。
  • 李四:轮询式关注鱼漂,不会因为鱼漂没有鱼而卡住自己,会刷手机看抖音小姐姐跳舞,时不时看看有没有鱼咬钩。
  • 王五: 将铃铛挂在鱼竿中间,将鱼竿一放就不管了,也不会中途看鱼漂,自己做自己的事情,认真阅读《如何讨富婆欢心》,直到铃铛响(表示有鱼咬钩),此时直接拉钩上鱼!
  • 赵六: 带了100个鱼竿,同时一直检测多个鱼竿上的鱼漂是否动,因为大力出奇迹,有个鱼竿咬钩就把鱼钓上来,没过一会又有另一个鱼竿也咬钩了,忙得乐此不疲。
  • 田七: 有钱,单纯想吃鱼,但是公司忙有自己事情要做,就顾了一个哥们替他钓鱼,钓鱼方式他不管,钓到20条后联系田七来取。

那么谁钓鱼效率最高?

假设咬钩概率相同,一共104个鱼竿,那么赵六100个杆的比重永远是上鱼效率最高的!因为哪怕你不是在等鱼上钩而是在做别的事情 但是就以钓鱼这件事情来说 赵六效率就是最高的!
(⚠️:是就以钓鱼这一件事情来说 不是比谁单位时间做事情效率谁更高!拜托🙏,我问的是钓鱼 不是问你钓鱼间隙干了些别的啥!)

换个角度想 以追女孩为例子:一个一直追一个 ;一个轮询式追一个; 一个追一个等别人回应你再跟进一步;一个雇佣别人帮忙追一个;一个直接同时追100个!谁效率高?!

这里 张三相当于阻塞IO;李四相当于非阻塞IO;王五相当于信号驱动IO;赵六相当于多路转接IO;田七相当于异步IO!

1.2.1 阻塞IO

阻塞IO: 在内核将数据准备好之前, 系统调⽤会⼀直等待. 所有的套接字, 默认都是阻塞⽅式.

阻塞IO是最常⻅的IO模型,非常简单易懂,不解释!

1.2.2 ⾮阻塞IO

⾮阻塞IO: 如果内核还未将数据准备好, 系统调⽤仍然会直接返回, 并且返回EWOULDBLOCK错误码

⾮阻塞IO往往需要程序员循环的⽅式反复尝试读写⽂件描述符, 这个过程称为轮询. 这对CPU来说是较⼤的浪费, ⼀般只有特定场景下才使⽤。

1.2.3 信号驱动IO

信号驱动IO: 内核将数据准备好的时候, 使⽤SIGIO信号通知应⽤程序进⾏IO操作,即在信号捕捉函数里面调用recv!

常规信号在递达之前产生多次只计一次,在高密集IO下会出现信号丢失,所以这种方法最不常用!

1.2.4 IO多路转接

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

后面会单独开专题细说,请期待!

1.2.5 异步IO

异步IO: 由内核在数据拷⻉完成时, 通知应⽤程序(⽽信号驱动是告诉应⽤程序何时可以开始拷⻉数据)

核心思想:是发起IO后,OS内核会自动检测文件描述符里是否有数据,有数据后自动拷贝到buffer里,不影响程序接着往后面跑,只发起IO不参与IO!

text 复制代码
发起读取请求 → 先做别的事 → 系统通知"数据已读好"

出场率不高,因为后期(C++20)出现了协程技术,是以同步的方式实现比异步IO简单效率不亚于异步IO的做法,所以异步IO已经算是个被淘汰的技术!感兴趣可以自行了解。

1.3 ⾼级IO重要概念

1.3.1 阻塞 vs ⾮阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。(以一个fd为例子分析)

  • 阻塞调用是 指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
  • 非阻塞调用指 在不能立刻得到结果之前,该调用不会阻塞当前线程。

阻塞与非阻塞IO最大的区别是: 等待事件的方式不同!非阻塞去干别的事情是和IO没关系的,单纯以IO这件事情来说,二者效率是一样的,因为我们只考虑IO,不是看谁单位时间干更多事情!

只是非阻塞IO把等的时间利用起来,可以在等待时间做更多事情 你可以说非阻塞IO做事情效率高 不能说他IO效率高!


那么问题来了:非阻塞IO在等待时间能不能读别的fd?

当然可以!那么此时我就可以说非阻塞效率IO高!不过我们一般是以单文件描述符为主!

1.3.2 同步IO VS 异步IO

我们将下面4种IO称为同步IO:

  1. 阻塞 IO(Blocking I/O)
  2. 非阻塞 IO(Non-blocking I/O)
  3. IO 多路复用(I/O Multiplexing,select/poll/epoll)
  4. 信号驱动 IO(Signal-driven I/O,SIGIO)

异步 IO(Asynchronous I/O,AIO) , 单独为异步IO!

IO本质是等+拷贝,进程只要参与等或者拷贝中任意一步就是同步IO!

还是以钓鱼为例子(1.2里的),不管等不等,张三、李四、王五、赵六都是自己提的鱼竿不是吗?

哪怕是王五 全程看上去不在等 但是你是不是没离开杆子?而且铃铛一响你是不是得提鱼竿!换成进程就是 哪怕你明面上不等一直做别的事情,但是信号一来你最起码还是要分出线程进入信号处理函数进行拷贝!!
而提鱼竿 不就相当于拷贝!而所有人中 只有田七是没有等没有提鱼竿的!异步IO 是不参与IO过程的 只发起IO!

2. ⾮阻塞IO

2.1 fcntl

fcntl() 是 Linux/Unix 中用来控制文件描述符的函数。socket 也是文件描述符,所以网络编程中经常用它设置非阻塞、复制描述符等属性。

cpp 复制代码
#include <fcntl.h>

int fcntl(int fd, int cmd, ...);

常见用法是设置 socket 为非阻塞:

cpp 复制代码
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

含义:

  1. F_GETFL:获取 sockfd 当前的文件状态标志;
  2. F_SETFL:设置新的状态标志;
  3. O_NONBLOCK:开启非阻塞模式。

设置后调用 recv() 或 accept() 时,如果暂时没有数据或连接,函数不会一直等待,而是立即返回,通常通过 errno == EAGAIN 或 errno == EWOULDBLOCK 表示"当前暂时没有"。

其他常见命令:

text 复制代码
F_DUPFD    复制文件描述符
F_GETFD    获取描述符标志
F_SETFD    设置描述符标志
F_GETFL    获取文件状态标志
F_SETFL    设置文件状态标志

例如复制描述符:

cpp 复制代码
int newfd = fcntl(sockfd, F_DUPFD, 0);

注意设置标志时要先读取原有标志,不能直接覆盖:

cpp 复制代码
// 推荐
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);

简单说,fcntl() 就像"文件描述符控制器",可以调整 socket 的阻塞方式、复制描述符和设置其他属性。

⚠️:成功返回对应fd,失败返回-1.


而文件标志位在内核中就是如图所示,你要读、追加、还是阻塞、非阻塞读写 都是设置的这个标志位(以位图的形式!)

2.2 实现函数SetNoBlock

基于fcntl, 我们实现⼀个SetNoBlock函数, 将⽂件描述符设置为⾮阻塞

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

2.3 加餐:信号唤醒阻塞进程的原理

信号什么时候被内核发现?其实是分两种场景

场景 A:进程正在 CPU 上运行(用户态 / 内核态),收到信号

内核在准备从内核态返回用户态的瞬间,检查当前进程 PCB 里面的「待处理信号位图」。

只要有未处理信号:

  1. 先不回到用户态
  2. 准备好信号处理函数的栈帧
  3. 回到用户态去执行信号 handler

✅ 信号检查点:内核→用户态的返回路径,这是 Linux 最核心的信号检查时机。

这种也就是我们以前学习信号的时候 最常见的信号处理流程。

场景二:进程处于可中断睡眠

例如进程正在阻塞式 read() 中等待数据,状态为:

text 复制代码
TASK_INTERRUPTIBLE(可中断睡眠状态)

此时另一个进程调用 kill() 发送信号,大致过程是:

text 复制代码
发送方调用 kill()
       ↓
调用syscall x80 软件中断陷入内核态
       ↓
内核系统调用入口
       ↓
sys_kill()(这个就是系统调用函数接口)
       ↓
内核找到目标进程(通过sys_kill()里面的子函数)
       ↓
send_signal_locked(sys_kill()里的子函数)
【还会通过后续逻辑判断是否需要唤醒目标】
       ↓
设置待处理信号标志
       ↓
发现目标进程处于 TASK_INTERRUPTIBLE(可唤醒休眠)
       ↓
signal_wake_up()(sys_kill()里的子函数)
       ↓
try_to_wake_up()((sys_kill()里的子函数))
       ↓
将目标进程从睡眠队列移到就绪队列
       ↓
内核态进行调度将该进程放入CPU,返回用户态前执行do_signal()进行场景A的函数处理场景 

不过,被唤醒不等于立即运行。目标进程只是从睡眠队列进入就绪队列,之后还要由调度器选择它获得 CPU。

目标进程重新运行后,会回到内核中的系统调用路径,检查待处理信号。 如果信号需要执行处理函数,内核会先安排信号处理;随后原来的阻塞系统调用可能出现以下情况:

text 复制代码
read() 返回 -1,并设置 errno = EINTR(表示被信号打断)

如果是输入ctrl+z打断阻塞的前台进程,按键触发硬件中断 → CPU 进入中断上下文 ,调用硬件键盘中断处理函数,该函数会调用TTY 内核驱动中断处理函数,该函数识别到ctrl+z 并不是调用sys_kill(系统调用),而是进行该函数类似逻辑进行 设置信号位+唤醒!


TTY一般会根据以下逻辑进程执行:

  1. n_tty_receive_buf():批量接收字符
  2. n_tty_receive_char()单字符处理
  3. n_tty_receive_char_special():识别^C ^Z这类控制字符
  4. n_tty_receive_signal_char():投递 SIGINT/SIGTSTP 信号
cpp 复制代码
端收到 Ctrl+Z 对应的字符
      ↓
TTY 行规程接收字符
(常见实现是 n_tty_receive_buf())
      ↓
逐个处理字符
(例如 n_tty_receive_char())
      ↓
识别这是特殊控制字符
例如 n_tty_receive_char_special())
      ↓
调用信号字符处理逻辑
(例如 n_tty_receive_signal_char(),发送 SIGTSTP)
      ↓
找到当前终端的前台进程组
      ↓
通过 kill_pgrp() 等逻辑向进程组发送 SIGTSTP
      ↓
TTY 找到当前终端的前台进程组
      ↓
向整个前台进程组发送 SIGTSTP
      ↓
设置待处理信号标志
      ↓
如果阻塞中的目标处于 TASK_INTERRUPTIBLE,
且该信号没有被阻塞,内核会尝试唤醒它
      ↓
signal_wake_up()
      ↓
try_to_wake_up()
      ↓
目标进程变为可运行状态,进入就绪队列 

⚠️: kill_pgrp() 是 Linux 内核里的函数,用来向一个进程组发送指定信号。

补充:是键盘硬件中断处理函数调用TTY将收到字符 → 把字符写入内核缓冲区(TTY 内核缓冲区即写缓冲区),以调用n_tty_receive_char()的方式将键盘的"某种信号"转成转成 ASCII 字符。

2.4 轮询⽅式读取标准输⼊

cpp 复制代码
#include <asm-generic/errno-base.h>
#include <asm-generic/errno.h>
#include <cerrno>
#include <cstring>
#include <fcntl.h>
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
void SetNoBlock(int fd)
{
    int fl = fcntl(fd, F_GETFL);
    if (fl < 0)
    {
        perror("fcntl error!");
        return;
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

int main()
{
    char inbuffer[1024];
     SetNoBlock(0);
    while (true)
    {
       
        ssize_t n = read(0, inbuffer, sizeof(inbuffer) - 1);
        if (n > 0)
        {
            inbuffer[n - 1] = 0;
            std::cout << "echo#" << inbuffer << std::endl;
        }
        else if (n == 0)
        {
            std::cout << "end of file"
                      << std::endl; //对于Linux来说 读到文件结尾是ctrl+d
        }
        //如果一个文件描述符没有就绪,也是以出错的形式返回的!!但是它不是错误!
        //必须区分是读出错还是没就绪
        else
        {
            //即错误代码11 表示数据没就绪
            if(errno == EWOULDBLOCK || errno == EAGAIN)
            {
                std::cout<<"data is not ready!"<<std::endl;
            }
            //调用被信号打断 → continue 重试
            else if(errno == EINTR)
            {
               continue;
            }
            //读取失败
            else 
            {
                std::cerr<<"read error..."<<std::endl;
            }
            sleep(1);
        }
    }
}
相关推荐
Mikko71 小时前
Linux 重启后日志没了?进程被 OOM 杀了去哪看?logrotate 之后日志还往旧文件写?三台真机实测日志与性能排查
linux·运维·centos
书生执笔画浮沉1 小时前
分享一个开源小工具CAdapt
linux·c·gnu·glibc·kernel·升级·灵感
mounter6252 小时前
Linux 内核热升级(Live Update)技术详解:PCI、VFIO 与 IOMMU 子系统的演进与实战解析引言
linux·linux kernel·kernel·iommu·vfio
砚凝霜2 小时前
【软考信息安全】第十九章 Unix/Linux安全分析与防护
linux·安全·unix
天国梦3 小时前
国密门禁品牌怎么选?2026年最新3个避坑要点
运维·服务器·数据库·python
creator_Li3 小时前
Linux 的 Page Cache
linux·page cache
夜之眷属4 小时前
PriorityQueue:for-each 遍历和 poll 取出,顺序一样吗?
java·服务器·数据结构·后端·算法
龙仔7254 小时前
RustDesk 自建服务器 Key 强校验配置笔记
运维·服务器·笔记
KING-WU5125 小时前
Linux GDB 调试实战指南:从编译到断点调试
linux·运维·服务器