目录
1. 什么是IO
2. 阻塞的本质
3. 五种IO模型
3.1. 通过故事认识五种IO模型
3.2. 上述故事的总结
3.3. 具体的五种IO模型
3.3.1. 阻塞IO
3.3.2. 非阻塞轮询式IO
3.3.3. 信号驱动IO
3.3.4. 多路转接IO
3.3.5. 异步IO
4. 非阻塞IO
4.1. fcntl 系统调用
1. 什么是IO
冯诺依曼体系:
站在冯诺依曼体系的视角,从输入设备读取数据到存储器,这个过程就是Input;而将存储器的数据写入到输出设备,这个过程就是Output;
因此,IO本质上就是访问外设的过程。
因为外设相较于内存、cache缓存、寄存器、CPU的速率是比较低的,故IO的效率是比较低的,尤其涉及到网络,效率问题就更加突出。
2. 阻塞的本质
IO过程的低效,我们可以用读取数据为例:
当进程 read/recv 时,如果底层缓冲区没有数据,read/recv 会被阻塞;
当进程 read/recv 时,如果底层缓冲区有数据, read/recv 会将数据从内核缓冲区拷贝到应用层;
阻塞的本质:
- 站在操作系统的视角: 将该进程的PCB放在等待队列中;
- 站在进程自身的视角: 本质上就是让我这个进程等待;
因此,当进程等了 (等待事件就绪),数据就绪后,再进行数据拷贝,这就是一次IO过程;
故我们认为,IO = 等待 (事件就绪) + 数据拷贝;
因此, read、recv、write、send 等,本质上都是先等待IO类事件就绪,在进行数据拷贝 (内核将数据拷贝给用户或者用户将数据拷贝给内核);
那么什么叫做低效的IO呢?
根据 IO = 等待 (事件就绪) + 数据拷贝,我们发现,单位时间,只要等待的比重越高,那么这个IO过程就越低效;
因此,那什么叫做高效的IO呢?即如何提高IO效率?
在单位时间,让等待的比重变得越低,那么IO的效率就变得越高,因此,高效IO的本质:降低IO过程中等待的比重,提高单位时间内拷贝数据的量;
3. 五种IO模型
3.1. 通过故事认识五种IO模型
通过一个钓鱼故事,来认识这五种IO模型:
今天,我们对钓鱼的过程进行简化一下(不要考虑什么打窝的事情了😄😄😄),我们认为钓鱼就分两步:
- step 1: 等待鱼上钩, 等待事件就绪;
- step 2: 鱼上钩后,把鱼钓起来, 数据拷贝。
根据上面对IO的简单理解,类比到钓鱼过程中,什么情况下,一个人钓鱼的效率非常高呢?
- 钓鱼 = 等待 + 钓起来;
- 因此,只要单位时间等待的比重非常低,那么这个人钓鱼的效率一定非常高。
下面我们就通过一个故事,来认识下五种IO模型:
张三是一个钓鱼爱好者,带着帽子、墨镜、马扎,就来到鱼塘边,在钓鱼过程中:
张三死死的盯着鱼漂,其他事情都不做,鱼漂不动,他也不动,过了一会,鱼漂动了,张三就将鱼钓上来,这是张三;
李四是张三的老朋友,路过鱼塘时,看到张三在钓鱼,自己也拿着鱼竿去钓鱼了,在钓鱼过程中:
李四一会儿刷下手机,一会儿和张三聊天 ( 当然张三没理他 ),一会儿又盯着鱼漂,反正一直没闲着,过了一会儿,鱼漂动了,他抬头看了一眼,就将鱼钓了起来,这是李四;
王五也是一个钓鱼爱好者,路过鱼塘,也拿着鱼竿跑过来了,王五与前两者相比,多做了一步,他在鱼漂的位置挂了一个铃铛🔔,只要鱼漂一动,铃铛就会响,在钓鱼过程中:
王五一会儿看下张三、一会儿又和李四闲聊、一会儿又刷手机,在整个钓鱼过程中,反正王五就是不看鱼漂,过了一会儿,铃铛🔔响了,他头都不抬,直接收杆,将鱼钓起来了,这是王五;
赵六家是卖鱼竿的,路过鱼塘时,也想钓鱼,就从家里拿了100只鱼竿,将这些鱼竿全都用上,在钓鱼过程中:
因为挂了100只鱼竿,一会儿这边的鱼漂动了,一会儿那边的鱼漂动了,所以赵六就来回的跑,陆陆续续的鱼被钓上来了,这是赵六;
田七作为全村的首富,有一个司机叫小吴,这天,田七坐着豪华轿车路过鱼塘,看到鱼塘边的四个奇葩,一个一动不动,像个石头一样;一个像是多动症一样的;一个一直不看鱼漂;一个挂了密密麻麻的鱼竿,来回跑的汉子;
田七虽然不是非常想钓鱼,但是他却想吃鱼,因此对小吴说,咱去钓鱼,但是小吴说,不行,老板,你要去公司开会,不能钓鱼;田七想了想,行,这样,你帮我去钓鱼,我自己去公司,鱼钓上后,你给我打电话,我再过来;于是,小吴就去帮田七钓鱼去了,田七自己开车去公司开会了,这是田七;
3.2. 上述故事的总结
张三的钓鱼方式:阻塞式;
李四的钓鱼方式:非阻塞轮询式;
王五的钓鱼方式:信号驱动;
赵六的钓鱼方式:多路转接 (或多路复用);
田七的钓鱼方式:异步IO;
这五种方式,我们称之为五种IO模型;
谁钓鱼最高效呢?为什么?
赵六钓鱼是最高效的,因为:
- 站在鱼🐟的角度,鱼🐟正在水里游哉悠哉的游着,抬头一看,看到104个食物 (诱饵) 在我的眼前,假设鱼🐟咬任何一个食物 (诱饵) 是等概率的,那么如果此时鱼🐟咬钩了,这个诱饵有 100/104,即25/26的概率是赵六的鱼饵;
- 站在钓鱼者的角度,因为赵六的鱼竿很多,所以鱼🐟咬钩有很大概率咬的是赵六的鱼竿,所以赵六有很大概率钓上鱼,故在单位时间内,赵六等待的比重是非常低的,因此,赵六钓鱼的效率是非常高的。
只要一个执行流 (进程、线程) 参与了IO过程,我们就称之为同步IO;
IO的过程分两步:
- 等待事件就绪;
- 拷贝数据。
因此只要执行流参与了上述的任何一步、或者两者都参与了,那么我们就称之为同步IO;
故,在上面的五种IO模型中,前四种 (阻塞式、非阻塞轮询式、信号驱动、多路转接) 我们都称之为同步IO;
而对于最后一种,即田七的钓鱼方式而言,他既没有等待鱼🐟咬钩 (等待事件就绪),也没有钓起鱼🐟 (拷贝数据),故我们将这种IO方式,称之为异步IO;
王五的信号驱动算同步IO吗?
- 首先,王五的信号驱动是同步IO, 可是,我们知道,信号的产生是异步的,这如何解释呢?
- 因为IO = 等待事件就绪 + 拷贝数据, 虽然王五在等待过程中,可以做其他事情,但是一旦鱼咬钩了,王五是会将其钓上来的,换言之,当底层缓冲区有数据后,王五会进行数据拷贝,即王五是会参与IO过程的,故信号驱动这种方式也属于同步IO;
- 虽然信号产生的确是异步的,但是当信号产生之后,信号驱动是要参与IO过程的,故信号驱动属于同步IO;
- 换言之,我们认为,只要一个执行流参与了IO过程 (等待事件就绪 + 拷贝数据),我们就认为它是一个同步IO;
- 如果一个执行流在整个IO过程都没有参与,完全脱离,那么就是异步IO;
阻塞IO和非阻塞轮询式IO,它们的区别是什么呢?
首先,阻塞IO和非阻塞轮询式IO都属于同步IO,因为它们都要参与IO过程 (等待数据就绪 + 拷贝数据);
其次,阻塞IO和非阻塞轮询式IO的主要区别就在于:等待数据就绪,这个等的比重不一样罢了,前者阻塞等待,后者非阻塞等待;
我们是学习过系统知识的,IO是谁在IO呢? 当然是执行流在IO;
因此,阻塞式IO,我们可以理解为执行流去检测某个文件描述符上是否有事件就绪,如果没有就绪,执行流就阻塞等待,等待事件就绪;
那什么是阻塞呢?
- 站在操作系统的视角,就是把该执行流的PCB的状态由R -> !R状态,比如S状态,并将该PCB链入到某个等待队列中,这个队列一般都是与该执行流所等待的文件描述符相匹配的;
- 此时这个执行流就被挂起阻塞了,后续就需要操作系统帮助处理了,比如操作系统识别到某个事件就绪,那么操作系统将在该文件描述符下等待的相关执行流唤醒,状态更改为R状态,并将PCB链入到运行队列中,此时这个执行流不就可以继续被调度,拷贝数据了吗?
多提一嘴,一般而言,执行流在等待什么,什么就需要提供相关队列,或者其他数据结构;
- 比如,执行流等待某个条件变量,那么条件变量需要自身提供一个等待队列;
- 再比如,执行流等待某个文件描述符,那么该文件描述符也需要提供一个等待队列。
那么什么是非阻塞呢?
- 非阻塞,就是不阻塞啊,站在操作系统的视角,如果一个执行流检测某个文件描述符上的事件不就绪时,那么操作系统不会去更改这个执行流的状态,也不会将它的PCB链入到等待队列中,换言之,此时,操作系统并不关心,也不处理;
- 因此,在非阻塞情况下,执行流不会被阻塞,故它可以在整个IO过程中不断的检测事件是否就绪,如果不就绪,可以处理其他任务,并稍后在进行检测,而这种模式不就是轮询过程吗?
不知道各位有这样的疑惑吗? 线程同步和同步IO这两个有关系吗?
- 先说答案, 毫无关系;
- 线程同步:在多线程场景下,多执行流协同工作时,为了解决访问临界资源合理性的问题,让执行流可以按照特定的顺序访问临界资源,我们称之为线程同步;
- 同步IO:一个执行流在进行IO时,如果参与了IO过程 (等待事件就绪或者拷贝数据),我们就认为它是同步IO;
- 线程同步是在多线程场景下,多执行流进行协同工作时,才会谈论的;
- 同步IO是在IO过程中,才会谈论的;
- 可见,线程同步和同步IO的应用场景都不相同,因此,这两者毫无关联。
3.3. 具体的五种IO模型
3.3.1. 阻塞IO
阻塞IO: 在内核将数据就绪之前,系统调用会一直等待 (阻塞等待),所有的文件描述符或者套接字,默认都是阻塞方式;
3.3.2. 非阻塞轮询式IO
非阻塞轮询式IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回 EWOULDBLOCK (Error Would Block)错误码;
非阻塞IO往往需要以循环的方式反复读写文件描述符,这个过程称之为轮询,非常消耗CPU的资源,一般只会在特定场景下使用。
3.3.3. 信号驱动IO
内核将数据准备好的时候,使用SIGIO信号通知执行流进行IO操作。
从下图我们也可以看出,信号驱动这个等,并不是等待信号产生 (信号产生是异步的),而是等待数据,数据就绪后,内核会向执行流发送信号 (SIGIO),应用程序在拷贝数据;
3.3.4. 多路转接IO
多路转接可以同时处理多个文件描述符,并且它只负责IO过程中的一个过程:等待事件就绪(数据拷贝它不关心,也不处理)。
- 多路转接虽然也是阻塞等待,但是它与前面不同的是,它可以同时阻塞等待多个文件描述符,将多个文件描述符的等待时间重叠在一起,这些文件描述符可以在任意时刻就绪,只要其中一个文件描述符的事件就绪了,上层就可以处理这个文件描述符,此时上层绝不会被阻塞,因为此时这个事件已经就绪;
- 通过多路转接,执行流可以将对多个文件描述符的IO操作集中在一起等待,当其中任何一个文件描述符上的IO事件就绪时,就会通知应用程序,从而避免了阻塞并提高了IO效率。
3.3.5. 异步IO
- 可以看到,在整个IO过程中,这个应用程序没有参与其中,表现为,既没有等待数据就绪,也没有拷贝数据,因此,该执行流完全脱离IO过程,故它是异步IO;
- 在整个IO过程中,等待数据就绪是内核完成的,将数据在内核和应用层拷贝也是操作系统进行的,数据拷贝完成后,通知应用程序;
- 在整个IO过程中,应用程序可以在此期间处理其他任务。
4. 非阻塞IO
一个文件描述符或者套接字,默认情况下,都是阻塞式IO,而接下来,我们需要自己通过 fcntl 系统调用将特定文件描述符设定为非阻塞;
因此,我们先来见见 fcntl 系统调用吧 😊~~~~。
当然,也有其他的方法可以设置为非阻塞,比如,open 打开一个文件时,有一个选项,O_NONBLOCK 或者 O_NDELAY。
还比如, 创建一个套接字时,我们也可以设置选项,SOCK_NONBLOCK,设置为非阻塞;
但在后续处理过程中,对于文件描述符或者套接字,我们都会使用 fcntl 系统调用接口,以一种统一的方式来设置非阻塞;
事实上,一个文件在读写数据时,阻塞和非阻塞无外乎就是文件的一个属性罢了;
4.1. fcntl 系统调用
fcntl() 是一个Linux系统调用,用于对文件描述符 (flie descriptor) 进行控制操作。
它的第二个参数 cmd 是一个整数,指定了要执行的操作类型,其余的参数取决于具体的操作类型。
函数原型如下:
man 2 fcntl --- 在2号手册
NAME
fcntl --- manipulate file descriptor
SYNOPSIS
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
RETURN VALUE
on error, -1 is returned, and errno is set appropriately.
For a successful call, the return value depends on the operation.
fcntl 根据传入的值不同, 其可变参数也不相同,在这列举几个,详细请看手册,或者文档:
- F_SETFL:获取文件状态标志;
- F_GETFL:设置文件状态标志;
- F_DUPFD:复制文件描述符;
- F_SETFD:设置文件描述符标志;
- F_GETFD:获取文件描述符标志;
- F_SETLK:设置文件锁;
- F_GETLK:获取文件锁;
通过 fcntl() 系统调用将特定文件描述符设置为非阻塞的大致思路:
- 通过 cmd = F_GETFL 在底层获取当前文件描述符的文件状态标志,这个文件状态标志可以理解为一个位图;
- 通过 cmd = f_SETFL 设置当前文件描述符的文件状态标志,因为目的是非阻塞,故:文件状态标志 按位或 O_NONBLOCK,这里的文件状态标志就是步骤1获得的文件状态标志。
如下:
bool SetNonBlock(int fd)
{
// 步骤一
// 通过F_GETFL获取当前fd对应的文件状态标志
// 可以将该文件状态标志(fl)理解为一个位图
int fl = fcntl(fd, F_GETFL);
if(fl == -1)
{
std::cout << "fcntl error" << std::endl;
return false;
}
// 步骤二
// 获取文件描述符的文件状态标志成功后
// 将该文件描述符设置为非阻塞
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
return true;
}
注意:
对于一个文件描述符而言,只需要通过 fcntl() 设置一次即可;
如果文件描述符设置为非阻塞,此时 read 时,我们需要通过返回值判定不同的处理方式,比如:
- 如果返回值 > 0,代表读取成功;
- 如果返回值 == -1,此时我们需要再次判断,是读取错误,还是底层数据没有就绪呢?
因此,我们需要通过 errno 这个全局变量,判别是读取错误,还是底层数据没有就绪, 比如:
- 如果 errno == 11,即 errno == EWOULDBLOCK,那么代表着底层数据没有就绪,try again 即可;
- 如果 errno == 4,即 errno == EINTR,代表着此次IO可能被某个信号中断,try again 即可;
- 如果是其他错误码,进行差错处理。
简单实现一个,非阻塞轮询式IO,实现如下:
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <fcntl.h>
bool SetNonBlock(int fd)
{
// 步骤一
// 通过F_GETFL获取当前fd对应的文件状态标志
// 可以将该文件状态标志(fl)理解为一个位图
int fl = fcntl(fd, F_GETFL);
if(fl == -1)
{
std::cout << "fcntl error" << std::endl;
return false;
}
// 步骤二
// 获取文件描述符的文件状态标志成功后
// 将该文件描述符设置为非阻塞
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
return true;
}
int main()
{
// 众所周知, 从0号文件描述符读取内容默认是以阻塞方式进行的
// 但我们可以通过 fcntl 系统调用设置非阻塞IO
if(!SetNonBlock(0)) exit(1);
// 只需要设置一次即可
// 后续的0号文件描述符就是非阻塞的
char buffer[1024] = {0};
while(true)
{
sleep(1);
errno = 0;
ssize_t real_size = read(0, buffer, sizeof buffer - 1);
if(real_size > 0)
{
buffer[real_size] = 0;
std::cout << "echo: " << buffer << "errno: " << errno << " errnoMessage: " << strerror(errno) << std::endl;
}
else
{
if(errno == EAGAIN || errno == EWOULDBLOCK)
{
// #define EWOULDBLOCK EAGAIN
// #define EAGAIN 11
// 当errno == 11时, 其实并没有错, 只不过底层数据没就绪, 再试一次吧~~~~
std::cout << "Resource temporarily unavailable, Try again" << std::endl;
continue;
}
else if(errno == EINTR)
{
// 此时也并不代表有错, 此次IO可能被某个信号中断了, Try again
std::cout << "IO operation was interrupted by a signal, Try again" << std::endl;
continue;
}
else
{
// 其他错误, 差错处理即可
std::cout << "other error" << std::endl;
exit(2);
}
}
}
return 0;
}