目录
[同步通信 VS 异步通信](#同步通信 VS 异步通信)
[同步通信 VS 同步与互斥](#同步通信 VS 同步与互斥)
[阻塞 VS 非阻塞](#阻塞 VS 非阻塞)
[阻塞IO & 非阻塞IO验证](#阻塞IO & 非阻塞IO验证)
五种IO模型
**常见五种IO模型分别是:**阻塞IO、非阻塞IO、信号驱动IO、多路复用/多路转接IO、异步IO。
什么是IO
IO全称为(input/output)输入输出,所谓得IO本质是应用程序与外部设备之间得数据交互过程。只要是程序性需要"读取外部数据(input /I)",或者向外部写入数据(output/O)都属于IO操作。比如:我们用程序读取本地文件、通过网络请求从网卡获取数据、等操作都是需要IO操作。
IO问题
IO操作的核心特点就是 "速度慢",我们知道IO的操作的两个核心阶段:等待数据就绪 + **数据拷贝。**其中等待数据就绪占整个IO时间的百分之90以上。所以我们认为IO操作慢的核心原因就是"等待数据条件就绪时间长"。
因此IO往往成为一个程序的性能瓶颈问题,选择合适的IO模型可以尽可能大的优化cpu利用率,减少IO等待带来的性能损耗。所以我们要想使用不同的IO模型就必须了解一下各种IO模型。
OS如何知道外设数据准备好了
介绍之前我们需要知道os是如何知道外设条件准备好了,然后可以去拷贝外设的数据到内存了。
对于操作系统来说并不是主动去检测外设 "条件/数据" 是否就绪,因为对于大部分情况来讲大多数外设设备是没有条件就绪的,如果操作系统不定时去主动检测外部条件,那么更多时候对于操作系统来讲是无效的,这就会大大的降低操作系统的工作效率。
实际上操作系统采用的是一直硬件中断的方式来得知外设是否有数据就绪,当有外设数据准备好的情况下,外设会主动的向cpu中的中断控制器发送中断信号,中断控制器再根据产生的中断信号的优先级通知cpu。另外再os中会维护一张中断向量表,当CPU收到某个中断信号时就会自动停止正在运行的程序,然后根据该中断向量表执行该中断信号对应的中断处理程序,处理完毕后再返回原被暂停的程序继续运行。
所以外设数据准备好主动通知的OS,让OS来获取数据。
高效IO
我们上边介绍IO=等待时间+数据拷贝,大多数IO操作时间都浪费在了等待时间上,高效IO就是尽量缩短传统IO上的等待时间。
详细介绍五种IO模型
阻塞IO
应用程序发起IO请求后,一直阻塞等待,直到外部数据就绪并完成数据拷贝,才执行后续代码。

核心流程:
- 应用程序调用recvfrom()系统调用接口,发起IO请求,此时内核中并没有数据准备好。程序阻塞。内核也阻塞。
- 数据就绪后,将内核数据从外部设备拷贝到内核空间,再由内核空间拷贝到用户空间缓冲区,拷贝完成后内核通知用户程序,应用程序解除阻塞,执行后续逻辑。
在用户程序来看,进行IO时,等和拷贝阶段都没有执行后续代码,我们称这种现象为阻塞IO。
非阻塞IO
应用程序发起IO请求后,如果内核还未有 "数据/条件" 就绪,系统调用仍然会直接返回,并返回EWPULDBLOCK错误码,然后轮询发起系统调用请求,直到数据完成拷贝。

核心流程:
- 应用程序调用recvfrom()系统调用接口,将IO秒速父设置为非阻塞模式,发起请求。如果内核数据缓冲区没有准备就绪,内核立马返回EWPULDBLOCK错误码,此时应用程序不会进行阻塞,可以执行其他逻辑。
- 也可以继续发起请求,通过轮询的方式反复发起recvfrom()系统调用,直到外部设备数据就绪,数据从外设拷贝到内核缓冲区,再从内核缓冲区拷贝到用户缓冲区。
- 拷贝完成后成功返回,应用程序继续执行后边代码逻辑。
非阻塞IO会轮询的调用系统调用,检测数据是否就绪,这个轮询过储层非常消耗cpu资源。所以大多数情况下是不会用非阻塞轮询的。
如上在用户程序看来,调用IO后即使内核没有数据就绪我仍然可以收到回复然后执行后续代码,这个过程我们称之为非阻塞IO。
信号驱动IO
应用程序发起IO请求后,不阻塞等待,而是注册一个信号处理函数;当数据就绪时,内核发送一个信号给应用程序,应用程序收到信号后,再去完成数据拷贝。

核心过程:
- 应用程序调用sigaction()系统调用接口,注册一个信号处理函数,来接收数据就绪信号
- 应用程序发起IO请求比如recvfrom(),设置未异步监听模式。
- 内核数据就绪后,内核将发送SIGIO信号给应用程序。
- 应用程序接收到信号之后,暂停当前执行逻辑,执行注册的处理函数,完成对数据的拷贝。继续之心原代码逻辑。
信号处理机制复杂,容易出现信号丢失、信号乱序的问题,不适合处理高并发场景。
多路复用/多路转接IO
通过一个内核函数(select/poll/epoll),同时监听多个IO文件描述符,当其中任意一个IO的数据就绪时,内核通知应用程序,再去处理对应的IO操作。

核心流程:
- 应用程序创建一个"监听器select/poll/epoll",并将需要监听的多个IO描述符注册到监听器上。发起监听请求,此刻应用程序也会阻塞,但阻塞的是监听操作,而非单个IO请求。
- 在内核中同时监听所有注册的IO描述符号,等待任意一个IO的数据就绪。
- 当某个IO就绪时,内核通知应用程序,告知那个数据准备好了。
- 应用程序针对特定的IO发起recvfrio调用,完成数据的拷贝,然后继续监听其他IO。
注意:虽然 IO 多路复用(select)监听时会阻塞,但它阻塞的是 多个 IO 的监听过程,而非单个 IO 请求 --- 哪怕所有 IO 都没就绪,它也能同时 "盯着" 所有注册的 IO,一旦有一个 IO 就绪就会唤醒执行;
而阻塞 IO 是阻塞在单个 IO 请求上,一个 IO 未就绪,整个程序就卡住,无法处理其他任何 IO。
对于SELECT、POLL、EPOLL下个文章会更新。
异步IO
应用程序发起IO请求后,完全不阻塞,继续执行其他逻辑;内核负责完成"等待数据就绪"和"数据拷贝"两个阶段,当所有操作都完成后,内核通知应用程序,应用程序直接使用数据即可。
相当于把所有的任务都交给了内核,放空自己。

核心流程:
- 应用程序调用aio_read()等异步IO系统调用接口,发起IO请求。
- 发起IO请求后,立即返回,继续执行进程其他逻辑(不会阻塞)。
- 内核中,自动完成等待数据和数据拷贝的操作。
- 内核中完成这些操作后,通知应用程序,应用程序收到通知时,直接使用用户缓冲区中的数据。继续执行后续逻辑。
对于异步IO:实现复杂,内核实现成本高;跨平台兼容性差,所以也很少使用。
同步通信 VS 异步通信
- **同步通信:**核心是应用程序主动主导IO操作,发起请求后需等待操作完成才能继续执行,控制权始终在应用程序手中。无论是阻塞IO,还是非阻塞IO的主动轮询、IO多路复用的监听阻塞、信号驱动IO的信号等待,本质都属于同步通信------即便不阻塞,应用程序也需主动检测数据就绪、主动执行数据拷贝。
- **异步通信:**核心是应用程序发起请求后无需等待,直接继续执行其他任务,控制权转移给内核,由内核全程完成数据就绪等待和数据拷贝,完成后通过回调、信号等方式通知应用程序。真正的异步通信仅对应异步IO,全程无阻塞。
非阻塞IO在没有得到结果之前就返回
在非阻塞IO这里,发起IO操做,即使内核中没有数据但也会返回,但这个返回并不意味着已经完成了整个IO操作,实际上没有数据的返回是内核给应用程序的一个错误返回。
因此应用程序会轮询的区发情IO请求,检测内核缓冲区数据是否就绪,直到数据就绪,内核返回才是一个完整的IO。
因此我们可以理解:非阻塞IO在没有得到结果之前就返回并不是正真完成了IO操作,而是正确返回才是真正完成了IO操作。
同步通信 VS 同步与互斥
同步通信:"应用程序与外部设备" 的数据交互,核心是应用程序发起IO请求,需要主动等待操作完成才能继续执行,控制权在应用程序手里。核心目的是保证数据交互的有序性。确保接收方按需获取数据。
同步与互斥: 多线程/多进程环境下的资源访问控制,核心是解决多个执行单元对共享资源的竞争问题。同步: 通过一定机制(如信号量、条件变量)让多个执行单元按预定顺序执行,避免操作混乱;**互斥:**通过锁机制(如互斥锁)保证同一时刻只有一个执行单元能访问共享资源,防止数据竞争和脏数据产生。二者相辅相成,核心目的是保障多任务环境下的资源安全和执行有序,是多线程、多进程开发中不可或缺的核心机制。
阻塞 VS 非阻塞
- 阻塞调用是:调用结果返回之前,当前线程/进程 会被挂起,只有在得到返回结果之后,线程/进程才会重新被调度。
- 非阻塞调用:在不能立刻得到结果之前,该调用不会阻塞当前线程,另外会轮询发起请求。
阻塞IO & 非阻塞IO验证
阻塞IO
C++当中的cin和C语言当中的scanf也可以读取从键盘输入的字符,但是cin和scanf会提供用户缓冲区,为了避免这些因素的干扰,因此这里选择使用read函数进行读取。
cpp
#include<iostream>
#include<unistd.h>
using std::endl;
using std::cout;
int main(){
char buffer[1024];
while(true){
int n=read(0,buffer,sizeof(buffer)-1);
if(n>0){
buffer[n-1]=0;
cout<<"echo@:"<<buffer<<endl;
}
}
return 0;
}
/*
运行结果
[root@Ubuntu-24.04 /113/test/Test] # ./test
hello
echo@:hello
nihao
echo@:nihao
*/
由以上运行结果可以看出:对于阻塞IO,需要有文件就绪才会执行后边的逻辑,否则就阻塞哪里。
非阻塞IO
打开文件时默认都是以阻塞的方式打开的,如果要以非阻塞的方式打开某个文件,需要在使用open函数打开文件时携带**O_NONBLOCK** 或**O_NDELAY**选项,此时就能够以非阻塞的方式打开文件。

funtl
如果要将已经打开的某个文件或套接字设置为非阻塞,此时就需要用到fcntl函数。
int fcntl(int fd, int cmd, ... /* arg */); //把打开的文件描述符设置为非阻塞
- fd:已经打开的文件描述符。
- cmd:需要进行的操作。
- ...:可变参数,传入的cmd值不同,后面追加的参数也不同。
常用的5种功能与其对应的cmd取值:
- 复制一个现有的描述符(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)。
返回值说明:
- 如果函数调用成功,则返回值取决于具体进行的操作。
- 如果函数调用失败,则返回-1,同时错误码会被设置。
利用fcntl函数
利用fcntl函数把一个已经打开的文件描述符设置为非阻塞。
cpp
#include<funtl.h>
bool SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL); //打开文件fd 设置标记位
if (fl < 0){
std::cerr << "fcntl error" << std::endl;
return false;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);//非阻塞标记O_NONBLOCK
return true;
}
利用fcntl函数从标准输入非阻塞获取数据。
cpp
#include<iostream>
#include<unistd.h>
#include<fcntl.h>
#include<cerrno>
#include<cstring>
using std::endl;
using std::cout;
bool SetNoBlock(int fd){
//设置非阻塞
int f1=fcntl(fd,F_GETFL);//获取fd标志
if( f1 < 0){
cout<<"funtl err"<<endl;
return false;
}
fcntl(fd,F_SETFL,f1 | O_NONBLOCK);
return true;
}
int main(){
SetNoBlock(0);//先把标准输入设置位非阻塞状态
char buffer[1024];
while(true){
int n=read(0,buffer,sizeof(buffer)-1);
if(n>0){
buffer[n-1]=0;
cout<<"echo@:"<<buffer<<endl;
}else if(n<0){
//当读取小于0的时候,可能是读取失败,但也有可能内核没有准备好
if (errno == EAGAIN || errno == EWOULDBLOCK){ //底层数据没有就绪
std::cout << strerror(errno) << std::endl;
sleep(1);
continue;
}
else if (errno == EINTR){ //在读取数据之前被信号中断
std::cout << strerror(errno) << std::endl;
sleep(1);
continue;
}
else{
std::cerr << "read error" << std::endl;
break;
}
}else{
cout<<"读取完毕"<<endl;
break;
}
}
return 0;
}
ead函数以非阻塞方式读取标准输入时,如果底层数据不就绪,那么read函数就会立即返回,但当底层数据不就绪时,read函数是以出错的形式返回的,此时的错误码会被设置为EAGAIN或EWOULDBLOCK。如下图:我们没有输入数据,他会一直请求并返回错误码。
当我们输入数据时,正常返回。

因此在以非阻塞方式读取数据时,如果调用read函数时得到的返回值是-1,此时还需要通过错误码进一步进行判断,如果错误码的值是EAGAIN或EWOULDBLOCK,说明本次调用read函数出错是因为底层数据还没有就绪,因此后续还应该继续调用read函数进行轮询检测数据是否就绪,当数据继续时再进行数据的读取。