【Linux】从零开始认识五种IO模型 --- 理解五种IO模型,开始使用非阻塞IO


恐惧让你沦为囚犯,
希望让你重获自由。
--- 《肖申克的救赎》---


五种IO模型与阻塞IO

  • [1 前言](#1 前言)
  • [2 五种IO模型](#2 五种IO模型)
  • [3 非阻塞IO](#3 非阻塞IO)

1 前言

通过网络通信的学习,我们能够理解网络通信的本质是进程间通信,而进程间通信的本质就是IO。

IO就是input与output,站在进程角度,IO就是将数据从进程间输入输出数据;站在软件层面,IO就是与操作系统进行交互!以IO中常用的接口:read为例,当底层没有数据时,就会阻塞 。这种阻塞的本质是等待事件就绪。write写入数据时,会将数据拷贝到缓冲区中,当缓冲区满了之后,也会进行阻塞等待。

所以我们平时使用的IO都可以总结为等 + 拷贝!所以什么叫做高效的IO呢?IO中可以理解为等 + 拷贝,拷贝的效率是很快的,那么高效的IO就是"等"的效率高!如果调用read,write不需要等待,那么效率自然而然的就高了!

针对这个高效的IO,我们介绍一下五种IO模型

2 五种IO模型

五种IO模型是程序员们经过长时间的使用总结出来的常用情况。接下来我们使用钓鱼的例子来介绍这五种IO模型

  1. 张三今天去钓鱼,他带着一根鱼竿,鱼漂,小马扎。走到河边扎好座椅,甩杆钓鱼。张三目不转睛的盯着鱼漂,一有动静就收杆,其他事情一概不考虑,只盯着鱼漂看!
  2. 李四今天也去钓鱼,他也带着一根鱼竿,鱼漂,小马扎。他也扎好座椅,甩杆钓鱼。李四布置好之后,不时刷手机,聊天,看书。间断性的看一看检测鱼漂有没有动,鱼漂动了就需要收杆。
  3. 王五今天也来钓鱼,他是个聪明人,他布置好杆子后拿出来一个神器:铃铛,放在鱼竿上。之后就开始耍手机,看书,聊天,一有鱼上钩,铃铛就会响,就可以进行收杆!
  4. 赵六也来钓鱼了,他是附近一带的首富,带着一车鱼竿来钓鱼了,放好100根鱼竿后,赵六就开始巡逻式的检查鱼竿情况。有鱼的鱼竿就进行收杆。
  5. 田七是整个城镇的首富,他带着管家来钓鱼。田七知道自己想要的是河流里鱼,而不是钓鱼。所以他就安排司机钓鱼,他回家等待鱼,小王怎么钓鱼,田七并不关心。

上面五个人就是经典的五种IO模型,每个人都代表一种系统调用,鱼竿就是文件描述符,鱼就是数据,河是操作系统内部,鱼漂浮动就代表数据就绪,收杆就代表进行拷贝:

  • 张三的方式称之为阻塞IO
  • 李四的方式称之为非阻塞IO
  • 王五的方式称之为信号驱动IO
  • 赵六的方式称之为多路复用/多路转接IO
  • 田七的方式叫做异步IO

阻塞IO和非阻塞IO的区别就是等待的方式不同 ,拷贝数据时一模一样的!上面五种钓鱼效率最高的是赵六:多路复用IO,毕竟人家鱼竿多,成功等到数据的概率就高!

而非阻塞IO的高效是与阻塞IO进行对比的,张三李四一天钓的鱼最终可能差不多,但李四看完一本书,追了4集电视剧...非阻塞IO的高效体现在可以在等待IO的同时处理其他事情!

异步IO就是两件事情互不影响,等待数据与获取数据就是异步进行!同步IO与异步IO的区别就是是否自身参与IO。

阻塞 IO 是最常见的 IO 模型:

  • 阻塞 IO:在内核将数据准备好之前,系统调用会一直等待。所有的套接字,默认都是阻塞方式.

  • 非阻塞 IO:如果内核还未将数据准备好,系统调用仍然会直接返回, 并且返回EWOULDBLOCK 错误码。
    非阻塞 IO往往需要程序员循环的方式反复尝试读写文件描述符,这个过程称为轮询。这对 CPU 来说是较大的浪费,一般只有特定场景下才使用

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

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

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

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

这里强调两组概念:

同步通信 vs 异步通信(synchronous communication / asynchronouscommunication)同步和异步关注的是消息通信机制。

  • 所谓同步, 就是在发出一个调用时, 在没有得到结果之前, 该调用就不返回.但是一旦调用返回, 就得到返回值了;换句话说, 就是由调用者主动等待这个调用的结果
  • 异步则是相反, 调用在发出之后, 这个调用就直接返回了, 所以没有返回结果;换句话说, 当一个异步过程调用发出后, 调用者不会立刻得到结果 ;而是在调用发出后, 被调用者通过状态,通知来通知调用者, 或通过回调函数处理这个调用。另外,我们回忆在讲多进程多线程的时候,也提到同步和互斥。这里的同步通信和进程之间的同步是完全不相干的概念。
  • 进程/线程同步也是进程/线程之间直接的制约关系, 是为完成某种任务而建立的两个或多个线程, 这个线程需要在某些位置上协调他们的工作次序而等待、 传递信息所产生的制约关系. 尤其是在访问临界资源的时候。

以后在看到 "同步" 这个词, 一定要先搞清楚大背景是什么。 这个同步, 是同步通信异步通信的同步, 还是线程同步与互斥的同步.
阻塞 vs 非阻塞

  • 阻塞和非阻塞关注的是程序在等待调用结果(消息, 返回值) 时的状态。
  • 阻塞调用是指调用结果返回之前, 当前线程会被挂起。调用线程只有在得到结果之后才会返回。
  • 非阻塞调用指在不能立刻得到结果之前, 该调用不会阻塞当前线程

3 非阻塞IO

实现非阻塞IO的方式有很多种:

当使用open打开一个文件时,可以传入一个标志位O_NONBLOCK or O_NDELAY

c 复制代码
 int open(const char *pathname, int flags);
 int open(const char *pathname, int flags, mode_t mode);

这样读取时就是非阻塞的进行读取。

同样的recv和send系列接口也有对应的非阻塞标志位MSG_DONTWAIT

c 复制代码
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

但是这些方式都不太通用,我们可以通过fcntl接口将文件描述符设置为非阻塞的文件描述符:

c 复制代码
Linux Programmer's Manual                                                               
FCNTL(2)

NAME
      fcntl - manipulate file descriptor

SYNOPSIS
      #include <unistd.h>
      #include <fcntl.h>

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

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).

通过这个系统调用,可以写一个demo来看看效果。

这是j经典的阻塞IO:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include<cstdio>
// 阻塞IO
int main()
{
    char buffer[1024];

    // 读取标准输入中的数据
    while (true)
    {
        printf("Enter#");
        fflush(stdout);
        int n = read(0, buffer, sizeof(buffer) - 1);
        if (n == 0)
        {
            // 说明没有读取到
            continue;
        }
        else if(n > 0)
        {
            //读取到了数据
            buffer[n] = 0;
            std::cout << buffer ;
        }
        else
        {
            //读取出现错误了
            perror("error!\n");
        }
    }

    return 0;
}

读取效果是这样的:

如果没有数据输入就会阻塞等待数据输入。

那么如何更改为非阻塞IO呢?我们设置一个接口函数

cpp 复制代码
#pragma once
#include <unistd.h>
#include <fcntl.h>
#include <iostream>

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

void SetNonBlock(int fd)
{
   //首先获取原来标志位
   int fl = ::fcntl(fd , F_GETFL);
   if(fl < 0)
   {
       std::cout<< "fcntl error " << std::endl;
       return ;
   } 
   //设置非阻塞标志位
   int n = ::fcntl(fd , F_SETFL , fl | O_NONBLOCK);
   if(n < 0)
   {
       perror("fcntl error \n");
       return ;
   }
   return ;
}

通过这个接口可以快速将文件描述符设置为非阻塞。我们将标准输入设置为非阻塞我们再来运行一下:

  • 如果是非阻塞 , 底层数据没有就绪,IO 接口会以出错形式返回。

那么如何区分是真的出错了还是底层不就绪的非阻塞IO返回呢?仅仅通过返回值是无法区分的了,但是read接口中的返回值是这么描述的:

c 复制代码
RETURN VALUE
       On success, the number of bytes read is returned (zero indicates end of file), and the file position is advanced by this number.  It is not an error if this number is
       smaller than the number of bytes requested; this may happen for example because fewer bytes are actually available right now (maybe because we were close  to  end-of-
       file, or because we are reading from a pipe, or from a terminal), or because read() was interrupted by a signal.  See also NOTES.

       On error, -1 is returned, and errno is set appropriately.  In this case, it is left unspecified whether the file position (if any) changes.

出错时,read接口会设置全局的errno!同样的recv,send...IO系列接口都是会设置errno:

可以看到errno被设置为了11,那11代表什么呢?11就是EWOULDBLOCK错误:

c 复制代码
#define	EAGAIN		11	/* Try again */
...
#define	EWOULDBLOCK	EAGAIN	/* Operation would block */

这就表示底层数据不就绪,可以try again,如果真的出错了,就会被设置成其他格式。

cpp 复制代码
// 非阻塞IO
int main()
{
    char buffer[1024];
    SetNonBlock(0);
    // 读取标准输入中的数据
    while (true)
    {
        printf("Enter#");
        fflush(stdout);
        ssize_t n = read(0, buffer, sizeof(buffer) - 1);
        if (n == 0)
        {
            printf("read done\n");
            break;
        }
        else if (n > 0)
        {
            // 读取到了数据
            buffer[n] = 0;
            printf("echo# %s"  , buffer);
        }
        else
        {
            // 读取出现错误了
            if(errno == EWOULDBLOCK)
            {
                std::cout << "底层数据没有就绪,开始轮询检测" <<std::endl;
                //do other thing
                //可以做其他事情!
                continue;
            }
            else
            {
                //真的出错了!
                perror("read error!\n");
                break;
            }

        }
        //sleep(1);
    }

    return 0;
}

这样就可以进行正常的非阻塞轮询了!

注意:操作系统的两个缓冲区:输入与输出,在我们键盘进行输入时会到操作系统的输入缓冲区,然后再到进程的输入缓冲区。而键盘输入时,操作系统会判断是否需要回显,回显就会将输入缓冲区的数据拷贝到输出缓冲区一份,这里就是可以回显的原因。

当我们进行IO拷贝时,如果突然接收到一个信号,导致IO拷贝中断了,那么这个读取的返回可能并没有读取完毕!这种情况的错误码是EINTR,我们可以进行判断!

相关推荐
蚊子不吸吸29 分钟前
DevOps开发运维简述
linux·运维·ci/cd·oracle·kubernetes·gitlab·devops
写bug的小屁孩1 小时前
登录注册窗口(一)
运维·服务器·数据库·c++·用户界面·qt6.3
且随疾风前行.2 小时前
重学Android:自定义View基础(一)
android·自定义view
Session_MY2 小时前
Docker 配置镜像加速
运维·docker·容器
程序员陆业聪3 小时前
对比Java和TypeScript中的服务注册和查找机制
android·harmonyos
大山很山3 小时前
Python简介和程序设计思想 |【python技能树知识点1~2】
java·网络·python
Tp_jh3 小时前
推荐一款非常好用的C/C++在线编译器
linux·c语言·c++·ide·单片机·unity·云原生
花千树-0103 小时前
Milvus - GPU 索引类型及其应用场景
运维·人工智能·aigc·embedding·ai编程·milvus
软件技术员4 小时前
acmessl.cn推荐一款好用的免费申请ssl证书的平台
网络·网络协议·ssl
cleveryuoyuo4 小时前
命令行参数、环境变量、地址空间
linux