五种IO模型

目录

一、五种IO模型

(一)阻塞IO

(二)非阻塞IO

(三)信号驱动IO

(四)IO多路转接

(五)异步IO

二、高级IO重要概念

(一)同步通信与异步通信

(二)阻塞与非阻塞

理解这四者的关系


在进行网络编程或文件操作时,IO模型的选择对程序的性能和效率有着重要的影响。本文将介绍五种IO模型,并详细讨论非阻塞IO的相关内容。

IO有两个阶段:数据准备、数据读写,两个完整的阶段我们就成为一个IO过程。

通俗一点: IO = 等待 + 拷贝

一、五种IO模型

(一)阻塞IO

在内核将数据准备好之前,系统调用会一直等待。所有的套接字默认都是阻塞方式,这是最常见、也是之前使用最多的IO模型。

当调用IO接口时,本质就是在做用户层和内核层的数据拷贝,但是调用的时刻大概率是不一定满足就绪条件的,此时就会阻塞等待,直到读/写条件满足。

(二)非阻塞IO

如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。

非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对CPU来说是较大的浪费,一般只有特定场景下才使用。

系统调用接口:

  • fcntl

函数原型:int fcntl(int fd, int cmd,... /* arg */ );,传入的cmd的值不同,后面追加的参数也不相同。此处使用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞。

  • 实现函数SetNoBlock
    void SetNoBlock(int fd){
        if(f1< 0){
            int fl = fcntl(fd, F_GETFL); 
            perror("fcntl");
            return; 
        }
        fcntl(fd, F_SETFL, fl | O_NONBLOCK); 
    }
    
    使用F_GETFL将当前的文件描述符的属性取出来(这是一个位图),然后再使用F_SETFL将文件描述符设置回去,设置回去的同时,加上一个O_NONBLOCK参数。

具体示例:

  • 非阻塞轮询方式读取标准输入

示例代码:

#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstdlib>
#include <cerrno>

// 对指定的fd设置非阻塞
void SetNonBlock(int fd)
{
    int fl = fcntl(fd, F_GETFL);
    if(fl < 0)
    {
        std::cerr << "fcntl error" << std::endl;
        exit(0);
    }
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}


int main()
{
    SetNonBlock(0);
    while(true)
    {
        char buffer[1024];
        ssize_t s = read(0, buffer, sizeof(buffer)-1); // sizeof(buffer)-1
        if(s > 0)
        {
            buffer[s] = 0;
            std::cout << "echo# " << buffer << std::endl; 
        }
        else if(s == 0)
        {
            std::cout << "end stdin" << std::endl;
            break;
        }
        else
        {
            // 非阻塞等待, 如果数据没有准备好,返回值会按照出错返回, s == -1
            // 数据没有准备好 vs 真的出错了 : 处理方式一定不是一样的。 s无法区分!
            // 数据没有准备好,算读取错误吗?不算。read,recv以出错的形式告知上层,数据还没有准备好
            if(errno == EWOULDBLOCK)
            {
                std::cout << "OS的底层数据还没有就绪, errno: " << errno << std::endl;
                // 做其他事情了
            }
            else if(errno == EINTR)
            {
                std::cout << "IO interrupted by signal, try again" << std::endl;
            }
            else
            {
                std::cout << "read error!" << std::endl;
                break;
            }
        }
        sleep(1);
    }
}

对于设置为非阻塞的文件描述符,读取的返回值 s :

  • s > 0: 读取成功,实际读取了 s 字节
  • s == 0: 读到结尾,正常结束。
  • s < 0: 数据并未准备好 / 读取出错
  1. 数据并未准备好:设置错误码为 EWOULDBLOCK 或 EAGAIN (本质是同一个数字)
  2. 当正在读取数据时收到信号被打断,设置错误码为 EINTR
  3. 排除前两种情况之后,则是真的出错了。

所以对于返回值小于0的情况需要再进一步判断。

以上是对于非阻塞的fd读取的示例,写入也是同理,同样把写条件未就绪归并为返回值小于0,并设置错误码。

(三)信号驱动IO

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

在信号驱动 I/O 中,进程首先使用 sigaction 系统调用安装一个信号处理函数,并通过 fcntl 系统调用将文件描述符设置为非阻塞和信号驱动模式。

当内核中数据准备好时,会发送一个信号给进程。进程在收到信号后,再进行实际的 I/O 操作。

这种方式的优点在于:进程不必阻塞等待 I/O 操作完成,可以在等待数据准备好的同时执行其他任务,提高了 CPU 的利用率。

简单示例代码:

#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/stat.h>
#include <sys/types.h>

void signalHandler(int signum) {
    std::cout << "Signal received. Performing I/O operation." << std::endl;
}

int main() {
    int fd = open("test.txt", O_RDONLY | O_NONBLOCK);

    if (fd == -1) {
        std::cerr << "Error opening file" << std::endl;
        return 1;
    }

    // 设置文件描述符为信号驱动 I/O
    fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC);

    // 安装信号处理函数
    signal(SIGIO, signalHandler);

    // 设置当前进程为接收信号的进程
    fcntl(fd, F_SETOWN, getpid());

    char buffer[1024];
    while (true) {
        std::cout << "Doing other work..." << std::endl;
        read(fd, buffer, sizeof(buffer));
        sleep(1);
    }

    close(fd);

    return 0;
}

整个程序的目的是在执行其他工作的同时,能够响应与文件 I/O 相关的信号,并进行相应的处理。

例如,如果在文件有可读数据等情况下,会发送 SIGIO 信号,从而触发 signalHandler 函数的执行。

(四)IO多路转接

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

多路转接:select、poll、epoll,三者在IO种的定位都是相同的,都是在等待一堆文件描述符的条件就绪,只是原理和具体接口的使用不同,epoll的效率是最优的,后续文章再具体讲解多路转接部分。

(五)异步IO

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

也就是通过调用特殊的API,使得等待和拷贝的过程都由内核完成,进程只用坐享其成的使用数据即可,因为IO具体过程的两步(等待、拷贝)都没有参与,所以是异步IO。

以下是一个简单的 C++ 异步 I/O 示例代码,

使用了 std::future 和 std::async 来模拟异步文件读取操作:

#include <iostream>
#include <future>
#include <fstream>

// 异步读取文件的函数
std::string asyncReadFile(const std::string& filename) {
    std::ifstream file(filename);
    std::string content;
    if (file.is_open()) {
        std::string line;
        while (std::getline(file, line)) {
            content += line + '\n';
        }
        file.close();
    }
    return content;
}

int main() {
    std::string filename = "example.txt";
    // 启动异步任务
    std::future<std::string> future = std::async(asyncReadFile, filename);

    std::cout << "Doing other work while reading the file asynchronously..." << std::endl;

    // 获取异步任务的结果
    std::string fileContent = future.get();

    std::cout << "File content: " << fileContent << std::endl;

    return 0;
}

小结

任何IO过程中,都包含两个步骤:等待和拷贝。而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。让IO更高效,最核心的办法就是让等待的时间尽量少。

二、高级IO重要概念

(一)同步通信与异步通信

同步和异步关注的是消息通信机制。

  • 同步:在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了;换句话说,就是由调用者主动等待这个调用的结果。
  • 异步:调用在发出之后,这个调用就直接返回了,所以没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果;而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。

另外,我们在学习多进程多线程的时候,也提到同步和互斥。这里的同步通信和进程之间的同步是完全不相干的概念。

进程/线程同步也是进程/线程之间直接的制约关系,是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系,尤其是在访问临界资源的时候。

所以大家以后在看到“同步”这个词,一定要先搞清楚大背景是什么。这个同步,是同步通信异步通信的同步,还是同步与互斥的同步。

(二)阻塞与非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息、返回值)时的状态。

  • 阻塞调用:指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
  • 非阻塞调用:指在不能立刻得到结果之前,该调用不会阻塞当前线程。
理解这四者的关系

可以用“妖怪蒸唐僧”的例子来理解这四者的关系:

  • 同步阻塞:就像妖怪蒸唐僧时,一直守在蒸笼旁边,直到唐僧蒸熟(得到结果)才离开,这期间什么也不干,一直等待。
  • 同步非阻塞:还是妖怪蒸唐僧,但是会时不时去看看唐僧熟了没(轮询),没熟就去干别的事,熟了就回来处理。
  • 异步阻塞:这种情况比较奇怪,一般不太会出现,就像妖怪告诉别人,等唐僧熟了通知我,然后就在蒸笼旁边等着。
  • 异步非阻塞:妖怪告诉别人,等唐僧熟了通知我,然后就去干别的事了,等收到通知再回来处理。
  • 15
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值