Bootstrap

五种 I/O 模型

序言

 本篇文章中,将带大家了解 五种 I/O 模型
在这里插入图片描述

以及详细介绍 多路复用 的三种常用的方式。那废话不多说,让我们开始吧!


1. 为什么 I/O 浪费 CPU 资源?

 为什么我们需要 五种 I/O ?因为我们需要其他非阻塞式的 I/O 来避免过多的 I/O 操作浪费 CPU 的资源。那为什么 I/O 浪费 CPU 资源?这个问题很重要,是我们所有问题的起因。

 在网络编程中,当我们需要用户输入数据时需要两个阶段:

  • 等待用户数据就绪,包括用户输入数据,并且数据传输到我们的设备中
  • 拷贝数据,数据从内核拷贝到我们的进程上

这就是 I/O 中的读取数据,时间大部分都消耗在了等待用户数据就绪上。但是我们知道,在等待某个资源就绪时,CPU 不会傻傻的等待,该进程会被挂到该等待资源的等待队列中,拷贝数据时 CPU 确实会处于空闲状态,但是看起来也不是那么严重呀😲!

 在高负载的服务器上中,哪怕是一丁点儿的漏洞都会带来严重的后果,这是因为 量变引起质变!在使用高峰期时,一个服务器会接受大量的 I/O 请求,问题就出现了:

  • 数据不是一开始就是有的,需要等待用户的输入,确实 CPU 不会等待,但是 CPU 会切换该进程或者是线程呀,频繁地切换造成消耗
  • 当大量拷贝数据时,CPU 长时间处于空闲的状态,这不是浪费吗
  • I/O 操作会触发中断,中断会打断 CPU 的正常运行,使得 CPU 要处理中断请求和恢复执行状态,这也会增加 CPU 的负担

现在知道了事情的起因经过,就该想出解决方案了。


2. 阻塞 IO

1. 定义

 这是最常见的 IO 方式,也是最简单的 IO 方式,当数据没有准备就绪时,会持续等待,过程如下:
在这里插入图片描述
这个流程可谓相当简单:

  1. 调用读取接口,进程等待数据就绪
  2. 数据就绪后从内核拷贝到我们的进程上
  3. 我们的进程进行处理数据

举个栗子,钓🐟的过程无非就是等待鱼上钩和上钩之后再把鱼钓上来,阻塞 IO 就相当于一心一意钓鱼的,耐心等待鱼上钩再钓起来。

2. 示例

 我们在这里就简单的使用代码举个栗子:

#include <iostream>
#include <unistd.h>

int main()
{
    char buf[1024];

    int n = read(0, buf, sizeof(buf) - 1);
    if (n > 0)
    {
        buf[n] = '\0';
        std::cout << buf << std::endl;
    }   
    return 0;
}

这个就是我们最熟悉不过的 阻塞IO,只要我们一直不输入数据,我们的进程就会一直等待。


3. 非阻塞 IO

1. 定义

 当数据没有准备好时,该进程不会傻傻地等了,他会去执行其他的任务,但是会采用 轮询 的方式每隔一段时间就来看一下数据是否准备好了:
在这里插入图片描述
这个流程只是多了轮询的过程:

  1. 调用读取接口
  2. 进程在等待时可执行其他工作,每隔一会询问一下
  3. 直到数据就绪,并从内核拷贝到我们的进程上
  4. 我们的进程进行处理数据

这个钓鱼佬在钓鱼的时候,不会一直等待着鱼上钩,他会在等于的过程中干些别的事儿,每隔一段时间他才会看鱼上钩了没。

2. 示例

 首先需要和大家先介绍一个系统调用的接口:

int fcntl(int fd, int cmd, … /* arg */ );

该函数可以改变已经打开的文件的性质,现在我们介绍它的参数:

  • fd:文件描述符,表示要操作的文件
  • cmd:操作指令,用于指定fcntl函数要执行的具体操作
  • arg:与 cmd 指令相关的参数

他的操作指令还是较多的,我们今天就介绍两个:

  • F_GETFL:获取文件描述符的状态标志
  • F_SETFL:设置文件描述符的状态标志

我们首先通过第一个操作指令获取该文件描述符的状态,然后在该状态的基础上加上我们需要的非阻塞状态:

	// 获取文件描述符的状态
   int fd_flags = fcntl(fd, F_GETFL, 0);
   if (fd_flags < 0)
   {
       perror("fcntl:");
       return false;
   }
   
	// 设置非阻塞状态
    if (fcntl(fd, F_SETFL, fd_flags | O_NONBLOCK))
    {
        perror("fcntl:");
        return false;
    }

最后我们将该新的状态写回文件中。现在当我们读取一个文件,但是数据还未就绪时,read 函数就会返回 -1。现在问题来了,原来返回 -1 是代表读操作出问题了,但是现在不一定了,还有可能是非阻塞返回!

 我们如何判断是哪一种情况呢?大家还记得全局的错误码吗:

在非阻塞模式下,如果 read 操作不能立即完成,则 read 不会使调用进程进入阻塞状态等待数据到达。相反,它会立即返回一个特定的值,并设置全局变量 errno 来指示错误或特殊情况。

所以我们只需要再次判断 errno 的值即可:

   while (true)
   {
       int n = read(0, buf, sizeof(buf) - 1);
       if (n > 0)
       {
           buf[n] = '\0';
           std::cout << buf << std::endl;
       }
       else
       {
           if (errno == EWOULDBLOCK)
           {
               // do other
               std::cout << "暂时还没有数据处理!" << std::endl;
               sleep(1);
           }
           else
           {
               perror("read:");
               break;
           }
       }
   }

这就是不断地轮询去检查数据是否就绪啦!


4. 多路复用 IO

1. 定义

 前面两者都是单个进程处理单个 IO 请求,但是现在多路复用可就厉害了,它允许单个进程或线程同时处理多个 IO 请求。 当某个文件描述符就绪时,通知该进程进行读写操作;反之,进程被允许执行其他操作。
 总的来说,IO 多路复用是一种高效的 IO 处理模型,它允许单个进程或线程同时处理多个 IO 请求,提高了系统的资源利用率和吞吐量。
在这里插入图片描述
这个过程就不一样了,我们将我们的文件交给 select 管理,而不是自己管理:

  1. 将我们的文件描述符交给 select
  2. 进程在等待时可执行其他工作
  3. 直到数据就绪,select 返回可读条件
  4. 我们的进程拷贝数据进行处理

 我们再次结合钓鱼佬来理解,这个钓鱼佬很注重效率,他钓鱼放了很多杆儿,在等待的同时他也会干其他事儿。但是因为杆儿多了,所以想都不用想他肯定处理数据的效率是很高的,因为杆儿多了鱼肯定也上钩的数量多了。

2. select 函数

 多路复用主要涉及 select,poll,epoll 这三个,在本片文章中,我就介绍最经典的 select 方法,之后会专门给大家介绍后两个方法。
 这是 select 的相关函数和结构体字段:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

参数看起来不少,因为它可以帮我们监视 读,写,异常 条件是否就绪,但是我们在这里只是关注 读条件 是否就绪:

  • nfds:需要监视的文件描述符的 最大值加1
  • readfds:指向需要监视以进行读取操作的文件描述符集合的指针
  • timeout:指定 select 函数等待的最大时间。如果为 NULL,则 select 将无限期地等待
  • 返回值:函数返回准备好进行 I/O 操作的文件描述符的数量,或者如果发生错误,则返回 -1

 现在我们来聊一聊这个函数的工作原理,在这里这是针对于读事件哦,但是明白了读其他两个也就触类旁通了!

 文件集合 fd_set 本质是一个位图(最大容量为 1024 ),位图的位置 表示 相对应的文件描述符,内容表示该文件描述符是否有效,1 代表该位置的文件描述符有效,0 则表示该位置的文件描述符无效:

比如:0101 ,这代表监控 0,2 这两个文件描述符

所以当我们想要 select 帮我们监视 4 号文件描述符时,怎么设置呢?如下:

  fd_set read_fds;
  // 初始化文件描述符集合,并添加要监视的文件描述符  
  FD_ZERO(&read_fds);  
  FD_SET(4, &read_fds); 

select 返回一个大于 0 的数,代表有文件描述符就绪了,我怎么直到哪一个就绪呢。这里的 readfds 是一个输入输出型参数:

  • 输入时,代表需要管理哪些文件描述符
  • 输出时,代表哪些文件描述符就绪了

我们可以使用 FD_ISSET(int fd, fd_set *set) 来判断该文件描述符是否在 fd_set 中。

 其实 timeout 也是一个输入输出型参数,如果 timeoutreadfds 中有事件发生,则返回 timeout 剩下的时间。

 好的,我们来看一下整体的流程:
在这里插入图片描述

select 函数作为最初版的多路复用函数,还是存在不少缺点:

  • 文件描述符有上限 1024,不方便监视大量的文件描述符
  • select 函数在每次调用时都需要遍历所有被监视的文件描述符,即使其中大部分都没有发生变化
  • 每次使用都会从用户态切换到内核态,切换带来的消耗
  • 每次调用都需要重新设置 fd_set,因为他是输入输出型参数

所以说尽管并不完美,但是 select 对于少量文件的场景也是不错的。


3. 示例

 在这里我们不再使用简单的 IO 作为演示了,我们在这里使用网络编程,因为在网络上我们才能更为便捷的创建多个 IO 请求,体现多路复用的强大!
 在开始之前,如果大家不了解 Socket 编程 过程是怎么回事儿,可以先看一下 这篇文章😚了解一下。好的现在我们回忆一下 TCP 通信的具体的步骤:

  1. 创建套接字
  2. 绑定端口号和 IP
  3. 创建监听套接字
  4. 等待客户端连接
  5. 连接成功,数据处理

现在我们将我的代码包装了一下,我们前 3 步相当于直接完成了,我们只需要关心其他客户端的连接,以及管理建立连接后的套接字文件。

简单介绍他的操作流程:

首先非常重要的三个成员变量:

  int _maxfd;
  std::vector<int> _fds;
  std::unique_ptr<Socket> _listensock;
  • _maxfd:每次新创建 fd 后都要更新这个值,方便传入 select 函数
  • _fds:我们在之前说过,select 函数会更新 readfds,所以我们需要自己记录一下哪些文件描述符需要被监管
  • _listensock:这个就是被包装后的监听套接字

通过 select 我们就可以使用单进程管理多个套接字文件,达到多线程的效果:

 void Loop()
 {
     while (true)
     {
         // 初始化 rfds,并将需要监控的文件描述符写入
         fd_set rfds;
         InitFdset(rfds);

         int n = select(_maxfd, &rfds, nullptr, nullptr, nullptr);
         switch(n)
         {
             case 0:
                 std::cout << "超时了..." << std::endl;
                 break;
             case -1:
                 perror("select:");
                 break;
             default:
                 std::cout << n << " 个读事件已经就绪,尽快处理!" << std::endl;
                 HandleEvent(rfds);
                 break;
         }
     }
 }

这个核心代码的逻辑如下:

  1. 每次循环都初始化 fd_set,将我们的需要管理的文件写入
  2. fd_set 交给 select 管理,等待文件出现事件
  3. 更具返回值处理事件,现在假设 n > 0,处理读取事件

现在我们再来看文件处理逻辑:

  void Accepter()
  {
      // 获取最新连接的套接字
      SockAddHelper Client;
      int newsockfd = _listensock->AcceptConnect(Client)->GetFd();
      if (_fds.size() == MaxSize) 
      {
          std::cout << "FD_SET is full..." << std::endl;
          return;
      }

      _fds.push_back(newsockfd);
      if (newsockfd > _maxfd) _maxfd = newsockfd;

      std::cout << "Successful add fd = " << newsockfd << ", the client is " << Client.GetIP() << std::endl;
  }

  void HandleIO(int index)
  {
      char Buf[MaxSize];
      int n = recv(_fds[index], Buf, MaxSize, 0);
      
      // 客户端退出逻辑
      if (n == 0)
      {
          std::cout << "Client quit..." << std::endl;
          close(_fds[index]);
          _fds.erase(_fds.begin() + index);
      }
      else if(n > 0)
      {
          Buf[n] = '\0';
          std::cout << Buf << std::endl;
      }
      else
      {
          perror("recv:");
      }
  }

  // 文件描述符处理逻辑
  void HandleEvent(fd_set& rfds)
  {
      for (int i = 0; i < _fds.size(); i++)
      {
          // 如果该文件已经就绪
          if (FD_ISSET(_fds[i], &rfds))
          {
              // 判断是否是 listenfd
              if (_fds[i] == _listensock->GetFd())
              {
                  Accepter();
              }
              else
              {
                  HandleIO(i);
              }
          }
      }

      std::cout << "Successful handle all fd..." << std::endl;
  }
  1. 遍历管理的文件,查看哪一个文件就绪了
  2. 判断是监听文件就绪了,还是普通套接字文件就绪了
  3. 监听文件就绪就执行增添套接字的逻辑
  4. 普通文件就绪就执行读或者是其他逻辑

依次不断地循环,就可以达到管理多个文件描述符的作用。


5. 信号驱动 IO

1. 定义

信号驱动IO 通常用于非阻塞模式下,当文件描述符(如套接字、管道或终端)的状态发生变化时,通过发送信号来通知进程。但是并不是很推荐,因为它们会打断正常的程序流程。在信号处理函数中执行复杂的IO操作可能会导致不可预测的行为。
 这种方式并不常见,但是我们还是介绍一下过程:
在这里插入图片描述

  1. 设置文件描述符为非阻塞模式:这是 信号驱动IO 的前提。
  2. 使用 fcnt l的 F_SETOWN 操作:将文件描述符与进程 ID 关联,以便当文件描述符就绪时,可以向该进程发送信号。
  3. 使用 ioctlFIOASYNC 操作:启用文件描述符的异步通知功能。
  4. 设置信号处理函数:使用 sigaction 来设置一个信号处理函数,该函数将在接收到信号时被调用。
  5. 在信号处理函数中处理 IO:当文件描述符就绪时,系统将发送信号。在信号处理函数中,你可以检查文件描述符的状态,并相应地处理 IO

6. 异步 IO

1. 定义

异步IO 是指程序在等待某个 IO 操作(如读写文件、网络通信等)完成的同时,能够继续执行其他任务,而不是一直等待 IO 操作完成。当IO操作完成时,通常会通过回调函数、事件通知或状态检查等方式来通知程序。
 有些时候,可能经常会混淆异步和多路复用,现在大家只要记住一点:异步IO既不参与数据的等待,也不参与数据的拷贝!
在这里插入图片描述

  1. 调用读取函数,但是不参与过程,直接返回做自己的事
  2. 在内核完成IO操作后,通过信号或回调来通知调用者

钓鱼佬的例子又来啦,这个钓鱼佬比较豪横,根本不想等待,直接请了个人帮自己钓鱼,自己去干其他事儿了,只需要那个人通知自己就行了。

2. 示例

 这个就简单举个例子吧:

#include <iostream>
#include <future>
#include <chrono>
#include <thread>

// 模拟一个异步 I/O 操作
int asyncIOOperation(int duration) {
    std::this_thread::sleep_for(std::chrono::seconds(duration)); // 模拟耗时操作
    return duration; // 返回操作持续的时间
}

int main() {
    std::cout << "开始异步 I/O 操作..." << std::endl;

    // 使用 std::async 启动异步操作
    std::future<int> result = std::async(std::launch::async, asyncIOOperation, 3);

    // 在这里可以执行其他任务
    std::cout << "异步操作正在进行..." << std::endl;

    // 获取异步操作的结果
    std::cout << "异步操作的结果: " << result.get() << " 秒" << std::endl;

    std::cout << "异步 I/O 操作完成!" << std::endl;
    return 0;
}

7. 总结

 在本篇文章中我们首先了解了为什么大量的 IO 操作浪费 CPU 资源?之后介绍了 五种 IO 模型,特别是着重介绍了 多路复用IO,希望大家有所收获!

;