1.IO模型引入
我们之前说的IO,说白了就是输入和输出,可以理解成要么读数据,要么写数据。以前的认知中IO就是文件读写、网络收发消息,其实本质都一样:把数据从一个地方搬到另一个地方。比如读文件,是从硬盘读到程序里;写网络,是把程序里的数据发到网卡上。对应的系统调用就是 read 和 write,读用read,写用write。
以前我们简单理解成,read 就是把数据从操作系统拷贝到用户层,write 是把用户层的数据拷贝到操作系统。但这只说对了一半,真正的IO没这么简单。
就拿网络来说,你在程序里调用read去读数据,其实是想把操作系统里TCP接收缓冲区的数据,拷贝到你自己程序的缓冲区里。可万一接收缓冲区里啥数据都没有呢?那read就不会立刻返回,程序就卡在那儿不动,这就叫阻塞。它会一直等,等到缓冲区里有数据来了,才会开始拷贝,然后返回。所以read不只是拷贝,它还要先等数据就绪。
再看write,你想发数据,就是把程序里的数据拷贝到操作系统的TCP发送缓冲区。可如果发送缓冲区已经满了,没地方放了,那write也会阻塞,一直等缓冲区腾出空间,能放下数据了,才会完成拷贝。至于操作系统什么时候真正把数据发出去、发多少、出错怎么处理,那都是操作系统的事,程序管不着。
所以你看,不管是read还是write,都分成两步:第一步是等条件满足,要么等有数据可读,要么等有空间可写;第二步才是真正拷贝数据。所以IO的本质,就是等待加拷贝。
那什么时候高效?很简单,拷贝本身非常快,并且拷贝是操作系统的事情,我们也管不着,真正慢的是等待。所以高效IO,就是尽量少等待,把等待的时间压到最短。
以前单线程一个一个等IO,等完这个等那个,时间全浪费在等待上了。后来用多线程,或者IO多路复用,比如select、poll、epoll这些,能同时盯着很多个文件描述符,一个在等的时候,别的可能已经就绪可以拷贝了。这样等待的时间就重叠起来了,单位时间里能拷贝的数据就变多,IO效率自然就高了。
2.五种IO模型
IO一共就五种模型,所有读写数据的方式,都跑不出这五种。为了好懂,咱们就用钓鱼来比喻,IO = 等待 + 拷贝,对应到钓鱼就是:钓鱼 = 等鱼咬钩 + 拉杆把鱼钓上来。
第一种,阻塞式IO。就像新手张三去钓鱼,抛竿之后,眼睛死死盯着鱼漂,什么都不干,电话来了也不接,就干等着。只要鱼没咬钩,他就一直卡在那儿不动,直到鱼漂动了,他才拉杆。这就是阻塞IO:调用read或write之后,条件不满足就一直阻塞,程序啥也干不了,死等到底,等条件满足了再拷贝数据。

阻塞IO: 在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式.
第二种,非阻塞式IO。好比钓友李四,他也钓鱼,但不一直死盯。抛竿之后,他就去看书,过一会儿抬头看一眼鱼漂,没动静就继续看书,再过一会儿再看一眼,反复来回问。这就是非阻塞轮询:调用read/write,没数据或没空间就立刻返回,不卡住你,你可以去干别的,过一会儿再来问,直到数据准备好了再拷贝。

非阻塞IO: 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码.
第三种,信号驱动式IO。像老钓友王五,他给鱼漂装了个铃铛,鱼一咬钩铃铛就响。他就安心看书,铃不响就一直看,铃一响他就知道可以拉杆了。这就是信号驱动:程序先告诉操作系统,数据好了给我发个信号,我先干别的,收到信号我再去读、去拷贝。

信号驱动IO: 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作.
第四种,IO多路复用。也就是多路转接,好比赵六,他特别猛,直接摆100根鱼竿,然后挨个巡视。一根竿等一条鱼慢,100根一起等,只要任意一根有鱼,他就去拉。这样单位时间里,等待的时间被大大压缩,效率高很多。对应到系统里,就是select、poll、epoll,一个线程同时盯着一大堆文件描述符,哪个就绪就处理哪个,不用一个个死等。

IO多路转接: 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件.
第五种,异步IO。最舒服的,像大老板田七,他只想吃鱼,不想钓鱼。直接让司机小王去岸边钓,自己回去开公司会议。小王钓完了,再打电话通知他:鱼好了,老板记得来拿。田七自始至终,没等、没看、没拉杆,只发起了钓鱼这件事,最后拿结果就行。这就是异步IO:用户进程只发个请求,剩下的等待、拷贝全由操作系统做完,完成后通知你,你全程不参与IO过程。

异步IO: 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).
这里面有个很重要的区分:同步IO和异步IO。
同步IO,就是前面四种:阻塞、非阻塞、信号驱动、多路复用。它们的共同点是,你自己都参与了IO过程,都在等,只是等的方式不一样。张三死等,李四轮着等,王五等信号,赵六一起等,但都在等,等完之后还要自己去拷贝数据。
异步IO,就是第五种,你完全不参与IO,只发命令,等操作系统把等待和拷贝全都做完,你直接拿结果。
再说说阻塞和非阻塞的区别,很简单:就是等待的方式不一样。阻塞是条件不满足就卡死不动;非阻塞是条件不满足就立刻返回,你去干别的,过会儿再来问,反复轮询。
在这五种里,意义上最常用、最重要、效率最高的,是IO多路复用。异步IO听起来很爽,但实际用起来复杂,代码难写难维护;而多路复用能同时等一大堆IO,把等待时间重叠,大大减少等待占比。
3.非阻塞IO的接口和实现
我们平时要做非阻塞IO,最常用、最标准的办法,就是用 fcntl 这个函数,把文件描述符设置成非阻塞模式。一旦设置完,你再用 read 去读数据,就变成非阻塞IO了。文件描述符默认都是阻塞的,你直接写 while 循环 + read,它就会一直卡住等数据。
先看阻塞版本的 read 是怎么从标准输入读数据的。
我们先建一个用户层的缓冲区 buffer,然后写一个死循环。
然后调用 read,从 0 号文件描述符也就是标准输入读数据。默认是阻塞IO,参数就是文件描述符0、缓冲区、缓冲区大小减1,留一个字节放字符串结束符 \0,防止溢出。
read 会有三种返回情况:
第一种,返回值大于0,说明读到数据了,我们在数据末尾加个 \0,把它当正常字符串打印出来就行。
第二种,返回值等于0,表示读到文件结尾,在网络里就是对端关闭了连接,这时候直接退出循环。
第三种,返回值小于0,就是读取出错了,比如文件描述符非法、资源出错之类,用 cerr 打印错误信息,然后退出循环。
这就是最基础的阻塞式 read。
cpp
int main()
{
char buffer[1024];
while(true)
{
// printf("Please Enter# ");
// fflush(stdout);
ssize_t n=read(0,buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n-1]=0;
cout<<"echo: "<<buffer<<endl;
}
else if(n==0)
{
cout<<"read done"<<endl;
break;
}
else
{
cerr << "read error" << endl;
break;
}
}
}
运行结果:

每次输入一条指令后,会阻塞住,等待用户输入,用户输入0号文件就有数据,读取用户输入的数据。

第一个参数传文件描述符,比如标准输入就是 0,socket 就是你创建的 fd。
第二个参数是命令,常用的就是两个
一个是 F_GETFL,获取文件状态标记;
一个是 F_SETFL,设置文件状态标记。
先讲 F_GETFL,表示把这个文件描述符当前的所有属性拿出来。一个文件描述符打开时,会带一堆属性,比如只读、只写、同步方式、阻塞方式等等。这些属性都是用位图存的,每一个比特位代表一种状态。
调用 fcntl(fd, F_GETFL) ,系统就会把这些状态打包,以返回值的形式给你,我们用一个变量 fl 存起来。
拿到 fl 之后,我们才能改。因为不能直接覆盖原来的属性,只能在原来的基础上添加一个非阻塞的属性。
所以第二步就是 F_SETFL,用来设置状态。我们要加的非阻塞状态,宏名叫 O_NONBLOCK。
因为是位图,所以只要用 按位或 |,把 O_NONBLOCK 加到原非阻塞IO,代码逻辑几乎一样,只是在最前面多一步:用 fcntl 把文件描述符设置成 O_NONBLOCK。
设置完之后,read 就不会卡住了:没数据的时候直接返回 -1,错误码是 EWOULDBLOCK,表示现在没数据,你可以先去干别的,过一会儿再来读,我们让进程睡眠一秒来代替做别的事情。
这就是非阻塞轮询的核心:不阻塞、不卡死,没数据立刻返回,有数据再处理。
cpp
#include<iostream>
#include<unistd.h>
#include<fcntl.h>
#include<cerrno>
#include<cstring>
using namespace std;
void SetNoblock(int fd)
{
int fl=fcntl(fd,F_GETFL);
if(fl<0)
{
perror("fcntl");
return;
}
fcntl(fd,F_SETFL,fl | O_NONBLOCK);
cout<<" set "<<fd<<" nonblack done"<<endl;
}
int main()
{
char buffer[1024];
SetNoblock(0);
while(true)
{
ssize_t n=read(0,buffer,sizeof(buffer)-1);
if(n>0)
{
buffer[n-1]=0;
cout<<"echo: "<<buffer<<endl;
}
else if(n==0)
{
cout<<"read done"<<endl;
break;
}
else
{
if(errno==EWOULDBLOCK)
{
cout<<" 0 fd data not ready,try again"<<endl;
//do other things
sleep(1);
}
else
{
cerr<<"ready errno,n= "<<n<<"errno code: "<<errno<<" ,errno str"<<strerror(errno)<<endl;
}
}
}
}
运行结果:

0号(显示器文件)文件没有数据的时候,就sleep一秒,然后再过来检测有没有数据,有数据就打印出来。
阻塞IO:没数据就死等,程序卡住不动。
非阻塞IO:先用 fcntl 设置模式,没数据直接返回,程序可以继续做其他事,反复轮询即可。