Linux --- 高级IO

发布于:2024-04-19 ⋅ 阅读:(150) ⋅ 点赞:(0)

目录

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这两个有关系吗?

  1. 先说答案, 毫无关系;
  2. 线程同步:在多线程场景下,多执行流协同工作时,为了解决访问临界资源合理性的问题,让执行流可以按照特定的顺序访问临界资源,我们称之为线程同步;
  3. 同步IO:一个执行流在进行IO时,如果参与了IO过程 (等待事件就绪或者拷贝数据),我们就认为它是同步IO;
  4. 线程同步是在多线程场景下,多执行流进行协同工作时,才会谈论的;
  5. 同步IO是在IO过程中,才会谈论的;
  6. 可见,线程同步和同步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() 系统调用将特定文件描述符设置为非阻塞的大致思路:

  1. 通过 cmd = F_GETFL 在底层获取当前文件描述符的文件状态标志,这个文件状态标志可以理解为一个位图;
  2. 通过 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;
}