欢迎来到我的频道!【点击跳转专栏】
本文所有代码已托管至码云:【点此转跳】
文章目录
- [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的理解
- IO时什么?本质是什么?
IO叫Input/output,是
外设 <-> 内存之间的交互。 网络通信本质是属于IO范畴的。
- 使用
read进行input需要 数据就位 + 拷贝- 使用
write进行output需要 空间就绪 + 拷贝所以 IO的本质就是
等待 + 拷贝! 所以我们IO觉得慢,大概率是因为等的时间太久了!而拷贝速度是跟硬件强相关的!
- 什么叫高效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:
- 阻塞 IO(Blocking I/O)
- 非阻塞 IO(Non-blocking I/O)
- IO 多路复用(I/O Multiplexing,select/poll/epoll)
- 信号驱动 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);
含义:
F_GETFL:获取sockfd当前的文件状态标志;F_SETFL:设置新的状态标志;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 里面的「待处理信号位图」。
只要有未处理信号:
- 先不回到用户态
- 准备好信号处理函数的栈帧
- 回到用户态去执行信号 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一般会根据以下逻辑进程执行:
- n_tty_receive_buf():批量接收字符
- n_tty_receive_char()单字符处理
- n_tty_receive_char_special():识别
^C^Z这类控制字符- 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);
}
}
}



