目录
一、对IO的重新认识
二、IO的五种模型
1.阻塞IO
2.非阻塞IO
3.信号驱动IO
4.IO多路转接
5.异步IO
6.一些概念的解释
三、非阻塞IO的代码实现
1.fcntl
2.实现工具类
3.实现主程序
一、对IO的重新认识
如果有人问你IO是什么,你该怎么回答呢?
你可能会说,IO不就是input和output表示输入和输出,输入表示把数据从硬盘等外设拷贝到内存,而输出表示把数据从内存拷贝到其他外设。
虽然这样说没什么大问题,但还不够深刻。
我们不妨设想下面的现象,有一个进程,调用read/recv这样的系统调用读取数据。如果此时读取条件不满足,那就没有数据可供进程读取,进程只就会一直等待数据准备好。
IO除了拷贝数据需要消耗时间,还包含这个等待的过程所以我们使用的系统调用除了拷贝代码,也包含了等的这部分代码。
也就是说,IO=等+数据拷贝
那什么是高效IO呢?
我们知道,IO过程我们在意的是拷贝,而不是等待。而拷贝需要的时间是由电路还有系统实现等保证的。随着科技的发展,拷贝本身花费的时间已经基本没有提升空间了,所以拷贝本身的效率已经很难再有提升了。那么等待时间的长度就决定了IO的效率。
换句话说,单位时间内,等待的比重越低,IO效率越高。
二、IO的五种模型
1.阻塞IO
在内核将数据准备好之前,系统调用会一直等待。我们之前写代码使用的IO接口读取文件描述符,默认都使用阻塞IO方式。
下图就是阻塞IO的示意图,进程调用recvfrom这样的IO接口从内核中读取数据。如果数据没有准备好,进程就会阻塞在调用处等待,数据准备好后,才会将内核中的数据拷贝到用户缓冲区,并给出返回值。
阻塞IO是最常见的IO模型,也最简单,我们之前写的所有代码,IO都是阻塞式的。
2.非阻塞IO
如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EAGAN或者EWOULDBLOCK错误码。
如图所示,进程调用recvfrom从内核缓冲区中读取数据。如果数据没有准备好,就会给进程返回一个EWOULDBLOCK错误码,告诉进程数据还没准备好,进程就会接着去干自己的事情。
过了一段时间,进程还会调用recvfrom读取数据,不断反复,直到数据准备好。接着系统调用完成拷贝并返回成功的返回值。
非阻塞IO需要程序员设计循环代码,反复尝试读写文件描述符,这个过程称为轮询。但轮询对CPU有一定的性能浪费,只有特定场景下才使用。
3.信号驱动IO
信号驱动IO会在内核将数据准备好的时候,发送SIGIO信号通知进程进行IO操作。
如图所示,信号驱动IO模型,该模式使用信号处理函数执行IO。
首先使用signal注册信号处理函数为包含IO系统调用的函数。所以只要进程收到信号,就可以在处理函数中调用recvfrom拷贝已经准备好的数据。
也就是说,只要数据准备完成了,进程就会收到信号,进程直接来拷贝就可以了。其余时间进程还可以继续执行自己的代码。
但是我们之前也说过,如果我们给一个进程同时发很多信号,只有两个能被最终递达。而这里的信号丢失就相当于读取次数减少,就相当于数据丢失。所以,这种很少有符合这种模式的IO状态。
4.IO多路转接
IO多路转接可以理解为多个阻塞IO同时进行,并不断遍历检测哪个IO的文件描述符准备好了,准备好了就会执行拷贝。
如图所示为IO多路转接模式,它将IO的等待和拷贝分开了。
进程调用select系统调用等待内核中的数据就绪,就绪以后会通知进程调用recvfrom来将数据拷贝到用户缓冲区中。
由于多路转接可以同时等待多个文件描述符。所以,当一个或者多个文件的缓冲区中数据就绪时,都会通知上层用户读取。而且每个拷贝过程也是并行的,还是免不了等,但是等的比重降低了很多,从而提高了IO的效率。
多路转接既是效率最高的IO模式,也是我们以后讲解的重点。
5.异步IO
当一个异步IO调用发出后,调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态、信号等来通知调用者,或通过回调函数处理这个调用。
下图表示异步IO,进程调用aio_read,将等待数据就绪和将数据拷贝到用户缓冲区两个步骤的工作全部交给操作系统来完成。当操作系统完成两个步骤以后,直接通知上层用户去用户缓冲区中读取数据即可。
也就是,进程不需要再考虑数据的IO,而是将其全权交给操作系统完成。
6.一些概念的解释
什么是同步IO和异步IO?
同步和异步的区别在于消息通信的机制。
所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回,但是一旦调用返回,就得到返回值了。
换句话说就是,调用者在主动等待这个调用的结果。
而异步则是调用开始执行后直接返回,调用者不会立刻得到结果,而是在调用返回后,被调用者通过状态、信号等通知调用者或通过回调函数处理。
话句话说,就是把事情交给了其他应用去做,自己只根据通信数据接收处理结果。
线程同步和同步IO有什么关系?
我们在讲解Linux线程时也提到了同步。
线程同步表示多个线程同时对临界资源进行操作时,系统为了保证没有线程处于饥饿状态,会以一定的顺序安排各个线程的执行顺序。
而同步IO表示处理数据的进程本身是否全权参与IO过程。
也就是,同步IO和线程同步之间,除了都有同步这个词之外没有任何关系。
三、非阻塞IO的代码实现
1.fcntl
int fcntl(int fd, int cmd, ... /* arg */ );
头文件:unistd.h、fcntl.h
功能:修改文件描述符的属性或对其进行其他操作。
参数:int fd需要操作的文件描述符。int cmd表示对描述符的操作。...表示可变参数列表,传入的cmd不同,参数也不同
返回值:成功返回非-1的值,失败返回-1。
fcntl函数有5种功能:
- 复制一个现有的描述符(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).
我们只使用第三个功能,即获取/设置文件状态标记,可将一个文件描述符设置为非阻塞。我们写一个SetNonBlock函数支持该功能。
//将文件描述符设为非阻塞
void SetNonBlock(int fd)
{int fl = fcntl(fd, F_GETFL);//获取文件描述符的标志,该标志是一个位图结构if(fl < 0)//获取失败{std::cerr << "fctnl:" << strerror(errno) << std::endl;//打印错误码}else{fcntl(fd, F_SETFL, fl | O_NONBLOCK);//将该文件描述符设为非阻塞}
}
2.实现工具类
util.hpp
#pragma once
#include<iostream>
#include<fcntl.h>
#include<string.h>
#include<unistd.h>
#include<errno.h>//将文件描述符设为非阻塞
void SetNonBlock(int fd)
{int fl = fcntl(fd, F_GETFL);//获取文件描述符的标志,该标志是一个位图结构if(fl < 0)//获取失败{std::cerr << "fctnl:" << strerror(errno) << std::endl;//打印错误码}else{fcntl(fd, F_SETFL, fl | O_NONBLOCK);//将该文件描述符设为非阻塞}
}void print_work()
{std::cout << "I am working. ";
}
3.实现主程序
由于我们从标准输入流(文件描述符为0)中读取数据,所以只要我们敲击键盘输入文字,就相当于向标准输入流中写入数据。
main.cc
#include"util.hpp"
#include<iostream>int main()
{SetNonBlock(0);//设置文件描述符为非阻塞while(1){char buffer[1024];ssize_t n = read(0, buffer, sizeof(buffer)-1);//读取数据if(n > 0)//读到了数据{buffer[n] = '\0';std::cout << buffer << std::endl;}else if(n == 0)//读到了结尾{std::cout << "read end" << std::endl;break;}else//n等于-1有两种情况,一种是读取出错,另一种是数据还没准备好,read只能按-1返回{if (errno == EAGAIN)//错误码为EAGAIN表示数据还没有准备好{//std::cout << "我没错, 只是没有数据" << std::endl;print_work();//程序继续执行自己的事}else if (errno == EINTR)//错误码为EINTR表示读取时进程收到了信号,需要进行处理,读取就被暂时打断了。{continue;//继续循环}else//这次就是出错了,打印错误码就可以了{std::cout << " errno: " << strerror(errno) << std::endl;break;}}sleep(1);}return 0;
}
此时即使屏幕上输出的数据乱成一团,但是并不会影响输入标准输入流的信息。
换句话说,非阻塞等待时,程序确实可以继续执行自己的流程。