Welcome to 9ilk's Code World

(๑•́ ₃ •̀๑) 个人主页: 9ilk
(๑•́ ₃ •̀๑) 文章专栏: Linux
之前在TCP通信中我们使用read/write将数据从缓冲区读取到应用层,这组接口我们叫做阻塞IO,因此网络问题本质上还是IO问题,本篇博客主要是对IO做进一步的认识以及认识常见的IO模型
高效I/O
之前站在进程的角度,我们认为I就是input,即中断之后把数据从外部拿到计算机内部,O就是output,也就是将数据从内存打到网卡。实际上应该怎么理解IO呢?
IO = 等待数据就绪 + 拷贝,一个简单的例子就是在调用read时,底层不一定有数据,只有数据准备好之后才能将数据拷过来。文件操作也是如此,在使用文件操作打开文件时,找文件是需要花时间的,找到文件之后还要进行加载文件属性和内容,创建对应数据结构等准备工作,然后接口才能通过文件描述符从文件缓冲区将数据拷贝到用户层,我们之前对IO没有感觉,是因为IO工作是在本地做的,对等的感觉不太明显,而网络就不同了,不同主机可能相隔千里,对"等"就有概念了。
从等←→不等 可以直接进行拷贝,这其中涉及到一个条件变化,比如说从等到不等,对于input来讲,一定是缓冲区中有数据了,我们把它都称为IO事件 ,表述出来就是"某种IO事件就绪了 ",再比如发送缓冲区满了,我们也叫IO事件就绪了。因此我们可以看到,在IO中,其实等是主要矛盾,IO效率低不一定是拷贝效率低,而可能是等的时间比较久。
什么叫高效IO呢?我们首先需要认识到,在任何通信场景中,IO效率一定是存在上限的,好比花盆里长不出参天大树,它一定会受到硬件的限制,比如网卡、带宽等限制,我们需要在硬件自身效率范围内讨论IO,因此我们认为,高效IO = 大部分时间在进行拷贝,此时就能充分利用硬件资源和网络资源。 建立完这个认识之后,我们如果要提高IO的效率应该怎么做呢**?本质就是要减少IO中等的比重**!
五种IO模型
在Linux系统中,常见的I/O模型主要分为以下五种:
-
阻塞I/O
-
非阻塞I/O
-
I/O 多路复用
-
信号驱动I/O
-
异步I/O
下面举个钓鱼的例子来理解这几种IO模型:
钓鱼的时候其实就两个动作,也就是等+钓,因此钓鱼的主要矛盾就是等,高效的钓鱼其实就是要 单位时间大大减少等的比重。现在假设有五种人要去钓鱼,第一种人,钓鱼的时候把鱼漂和鱼钩扔到河里,就死死地盯着鱼漂,全身心投入,鱼漂一动就开始钓鱼,鱼漂不动他就不动;第二种人,把鱼漂和鱼钩扔到河里之后,玩一会手机,看一会鱼漂动了没有,动了就开始钓鱼,没有就继续玩手机,玩一会儿就再检查,循环往复;第三种人,他将一个铃铛绑在鱼竿顶部,钓鱼的时候铃铛不响,他就玩手机,铃铛响了,有鱼上钩了他就直接把鱼竿拉起来钓鱼;第四种人,他有多套鱼竿+鱼漂+鱼饵,将他们都插在岸边,开始来回踱步,检测鱼漂是否有鱼上钩;第五种人,他自己不钓鱼,而是雇人帮他钓鱼,当水桶装满鱼之后他才过来。
在上面的例子中,鱼竿对应的是文件描述符fd ,钓鱼的人对应的是进程 ,钓鱼这个动作其实相当于是read、write这些系统调用,第一种人对应的就是阻塞式IO ,第二种人其实就是非阻塞IO轮询方式 ,第三种人只看铃铛动静来钓鱼,对应的是信号驱动式IO ,第四种人,对应的是多路转接/多路复用IO ,最后一种人,自己并没参与钓鱼的过程,而是交给别人缓冲区和通知方式,因此这属于异步IO。
深刻理解五种IO模型
- 阻塞 vs 非阻塞
IO = 等 + 拷贝,阻塞IO在进程读取到数据之前会一直阻塞而不去做其他的事,非阻塞IO不会阻塞在IO的过程之外,这个阶段会做其他事情和检测是否有数据轮询执行,因此两者的区别在于等的方式不同,一个是卡住等待,一个是不会卡住。看起来非阻塞IO的效率会高点,但非阻塞效率高其实是个误解,因为IO效率是发送方导致的 ,发送方一直没准备好我们就一直拿不到数据,也就是说,两种方式等待时间占比是基本一致的,都是只有这么一个鱼竿,只是非阻塞IO可以在同样时间做更多的事情,说的是整体效率比较高。
- 五种模型中,谁的IO效率是最高的
这几种IO模型中,IO多路转接模型的效率是最高的,这种模型下,一个进程可以拥有多个文件描述符,这意味着可以读取到多个文件描述符中的内容,因此整体来看当前进程的等待时间会减少,因为只要有一个文件描述符有数据,当前进程就会进行数据拷贝,由于多路复用模型文件描述符上数据就绪率很高,所以等的比重大大降低,它的效率页比较高。而异步IO,实现起来非常复杂,代码难度高并且可维护性没有多路转接号,因此相对多的情况下使用的是多路转接技术,其余三种IO模型,只有一个文件描述符,IO效率取决于发送方,而多路转接方案增加了收到数据的效率,此时能否及时处理数据就取决于自己,就可以保证最大程度上利用CPU资源。
- 信号驱动式IO特点是什么,效率如何,有没有参与IO
该种方式的特点是逆转了IO事件就绪的方式,通过信号的方式。虽然采用的是信号驱动,只是改变了等的不同,数据拷贝还得自己做,因此还是属于同步IO,只要参与IO过程的(等+拷贝)就是同步IO,需要注意的是,信号驱动式IO也是只有一个文件描述符,因此IO效率这块还是取决于发送方,效率和非阻塞/阻塞IO没有本质区别,只不过类似非阻塞IO一样,,能合理利用等的时间,而且不用自己轮询。
- 同步IO vs 异步IO
IO = 等 + 拷贝,异步IO和同步IO的区别是有没有参与IO过程,(局部)参与了就是同步IO,没有就是异步IO,异步IO扮演的是任务的发起者,由OS帮助接收数据和放到用户缓冲区。
接下来我们简单看下各个IO模型的流程即可:
- 阻塞IO:在内核将数据准备好之前,系统调用会一直等待,**所有的套接字, 默认都是阻塞方式,**由于简单因此阻塞IO是最常见的模型

- 非阻塞IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK 错误码,非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符,也就是轮询,这对 CPU 来说是较大的浪费,一般只有特定场景下才使用

- 信号驱动IO:内核将数据准备好的时候,使用 SIGIO 信号通知应用程序进行IO操作

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

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

总结:
任何 IO 过程中,都包含两个步骤,第一是等待, 第二是拷贝, 而且在实际的应用场景中, 等待消耗的时间往往都远远高于拷贝的时间,让IO更高效,最核心的办法就是让等待的时间尽量少。
高级IO重要概念
同步通信 vs 异步通信
同步和异步关注的是通信机制:
- 同步:指的是在发出一个调用时,在没有得到结果之前,该调用就不返回;但是一旦调用返回,就得到返回值了,换句话说,由调用者主动等待这个调用的结果
- 异步:调用在发出之后,直接返回,因此没有返回结果。也就是说,当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态、通知来通知调用者或通过回调函数处理这个调用
需要注意的是在多进程多线程中的同步互斥,和同步通信是完全不相干的概念:进程/线程同步是进程/线程之间直接的制约关系,这几个线程需要在某些位置上协调他们的工作次序而等待,尤其是访问临界资源时。
阻塞 vs 非阻塞
阻塞和非阻塞关注是程序在等待调用结果(消息,返回值)时的状态。
- 阻塞调用是指调用结果之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程
其他高级IO
非阻塞IO,记录锁,System Vl流机制,IO多路转接(IO多路复用),readv和writev函数以及存储映射IO(mmap),这些统称为高级IO。
非阻塞IO
之前我们使用的文件描述符都是默认阻塞IO,怎么让一个fd进行的是非阻塞呢?
fcntl
该函数的原型如下:
bash
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
传入的cmd值不同,后面追加的参数也不同,fcntl函数有5种功能:
- cmd = F_DUPFD:复制一个现有描述符
- cmd = FGETFD / F_SETFD :获得 / 设置文件描述符标记
- cmd = F_GETFD / F_SETFL :获得 / 设置文件状态标记
- cmd = F_GETOWN / F_SETOWN :获得 / 设置异步 I/O 所有权
- cmd = F_GETLK,F_SETLK / F_SETLKW :获得 / 设置记录锁
我们此处只是用第三种功能,获取/设置文件状态标记,就可以从一个文件描述符设置为非阻塞。
SetNonBlcok
- 使用F_GETFL将当前的文件描述符的属性取出来(相当于是一个位图)
- 然后再使用F_SETFL将文件描述符设置回去,设置回去的同时,加上一个O_NONBLOACK参数
cpp
void SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
非阻塞IO测试
cpp
#include <iostream>
#include <string>
#include <unistd.h>
using namespace std;
int main()
{
std::string message = "Please Enter# ";
while (true)
{
// 先向标准输出中写入数据
write(1, message.c_str(), message.size());
char buffer[1024] = {0};
// 再从标准输入中读取数据
ssize_t n = read(0, buffer, sizeof(buffer) - 1);
if(n > 0)
std::cout << buffer << std::endl;
}
return 0;
}
上面这段代码就是经典的阻塞IO,如果用户不输入,就会一直卡在read这个系统调用中:

如果想将上面的阻塞式IO修改为非阻塞式IO可以使用fcntl接口:
cpp
#include <iostream>
#include <string>
#include <unistd.h>
#include <fcntl.h>
using namespace std;
void setNonBlock(int fd)
{
// 获取当前文件描述符状态
int stat = fcntl(fd, F_GETFL);
if(stat < 0)
{
std::cerr << "状态错误" << std::endl;
return;
}
// 设置为非阻塞
fcntl(fd, F_SETFL, stat | O_NONBLOCK);
}
int main()
{
std::string message = "Please Enter# ";
setNonBlock(0);
while (true)
{
// 先向标准输出中写入数据
write(1, message.c_str(), message.size());
char buffer[1024] = {0};
// 再从标准输入中读取数据
ssize_t n = read(0, buffer, sizeof(buffer) - 1);
if(n > 0)
std::cout << buffer << std::endl;
}
return 0;
}
此时由于是非阻塞,我们不做输入,数据就会不就绪,就会出错返回:

我们可以使用sleep函数观察,同时完善判断read的出错条件:
cpp
std::string message = "Please Enter# ";
setNonBlock(0);
while (true)
{
// 先向标准输出中写入数据
write(1, message.c_str(), message.size());
char buffer[1024] = {0};
// 再从标准输入中读取数据
ssize_t n = read(0, buffer, sizeof(buffer) - 1);
if(n > 0)
std::cout << buffer << std::endl;
else
{
std::cout << "read err: " << n << endl;
}
sleep(1);
}

Q:我们知道read在真正读取失败的时候也是返回-1,那读取失败和底层数据不就绪,两者性质一样吗?
对于底层数据不就绪来说,这并不算读取失败,因为底层数据与我无关,整个读取过程我并没出错,但是接口上设计为一种失败类型,也就是说,返回-1,读取失败和底层数据就绪我们的后续处理应该是不同的,那么我们该如何区分这两种情况呢?
实际上我们可以根据错误码区分出不同的错误情况,errno表示出错的详细原因,即最近一次调用出错的错误码,我们可以使用EAGAIN或EWOULDBLOCK来表示底层数据没有就绪的情况,EAGAIN的值是11,含义是Try Again,EWOULDBLOCK是EAGAIN宏定义出来的:
cpp
#include <asm-generic/errno-base.h>
#include <asm-generic/errno.h>
#include <iostream>
#include <string>
#include <unistd.h>
#include <fcntl.h>
#include <cerrno> // 必须包含
#include <cstring> // 为了用 std::strerror
using namespace std;
void setNonBlock(int fd)
{
// 获取当前文件描述符状态
int stat = fcntl(fd, F_GETFL);
if(stat < 0)
{
std::cerr << "状态错误" << std::endl;
return;
}
// 设置为非阻塞
fcntl(fd, F_SETFL, stat | O_NONBLOCK);
}
int main()
{
std::string message = "Please Enter# ";
setNonBlock(0);
while (true)
{
// 先向标准输出中写入数据
write(1, message.c_str(), message.size());
char buffer[1024] = {0};
// 再从标准输入中读取数据
ssize_t n = read(0, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
cout << "echo# " << buffer << endl;
}
else if(n == 0)
{
std::cout << "read file end! " << endl;
break;
}
else
{
if(errno == EAGAIN || errno == EWOULDBLOCK)
{
cout << "底层数据没有就绪..." <<endl;
}
else
{
cout << "read err:" << n << ", errno:" << errno << endl;
}
}
sleep(1);
}
return 0;
}
注意:ctrl + d表示让标准输入结束,读到文件结尾
一个进程在read阻塞,处于等待状态,即S状态,这是轻度睡眠状态,它收到信号就可能被唤醒,一旦它把信号处理完了,是继续阻塞还是继续执行后续代码呢?大部分IO类的系统调用本身就包含了IO事件的判断和对进程进行挂起的逻辑,read系统调用内部会判断缓冲区是否有数据,也就是说,是read这个系统调用在你没数据时把你挂起的。对于非阻塞IO,如果正在拷贝的时候来了个信号,此时可能导致读写出错,因此在非阻塞IO一旦被信号中断导致IO工作未完成,它的错误码一般设置为EINTER,我们需要根据错误码来进行判断,决定被信号中断后后续如何处理:
cpp
#include <asm-generic/errno-base.h>
#include <asm-generic/errno.h>
#include <iostream>
#include <string>
#include <unistd.h>
#include <fcntl.h>
#include <cerrno> // 必须包含
#include <cstring> // 为了用 std::strerror
using namespace std;
void setNonBlock(int fd)
{
// 获取当前文件描述符状态
int stat = fcntl(fd, F_GETFL);
if(stat < 0)
{
std::cerr << "状态错误" << std::endl;
return;
}
// 设置为非阻塞
fcntl(fd, F_SETFL, stat | O_NONBLOCK);
}
int main()
{
std::string message = "Please Enter# ";
setNonBlock(0);
while (true)
{
// 先向标准输出中写入数据
write(1, message.c_str(), message.size());
char buffer[1024] = {0};
// 再从标准输入中读取数据
ssize_t n = read(0, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0;
cout << "echo# " << buffer << endl;
}
else if(n == 0)
{
std::cout << "read file end! " << endl;
break;
}
else
{
if(errno == EAGAIN || errno == EWOULDBLOCK)
{
cout << "底层数据没有就绪..." <<endl;
}
else if(errno == EINTR)
{
cout << "被中断,重新.." << endl;
continue;
}
else
{
cout << "read err:" << n << ", errno:" << errno << endl;
}
}
sleep(1);
}
return 0;
}