高级I/O知识分享【5种IO模型 || select || poll】

博客主页:花果山~程序猿-CSDN博客

文章分栏:Linux_花果山~程序猿的博客-CSDN博客

关注我一起学习,一起进步,一起探索编程的无限可能吧!让我们一起努力,一起成长!

目录

一,前文

2,5种IO模型(钓鱼例子)

[3. 5种IO模型图](#3. 5种IO模型图)

4,进程同步&IO同步区分

那如何区分同步通信和异步通信?

5,阻塞与非阻塞区别

二,非阻塞IO

1.设置IO为非阻塞

[2. I/O多路转接(select)](#2. I/O多路转接(select))

参数理解

用select优化listen_socket

select的优缺点

3.I/O多路转接(poll)


嗨!收到一张超美的图,愿你每天都能顺心!

一,前文

我们曾经学习过类似IO的知识,例如接口:C语言的fwrite,fread,操作系统的read,write等对磁盘的IO,我们现在可以称他为------单机IO,本质上是内存到磁盘之间的读写操作,相比与网络IO,本地IO有着一些先天的优势(物理优势------近):

  1. 低延迟:由于数据交换不涉及网络传输,因此相对网络IO,单机IO的延迟较低。
  2. 简单直接:实现较为简单,通常使用操作系统提供的API(如C语言中的fopen, fread, fwrite等)直接进行操作。
  3. 可靠性高:相比网络环境,本地IO受外界干扰小,数据传输更可靠。

应用的场景多是对本地文件内容的读写。

网络IO本质也是对外设进行读写(接口如:send,recv) ,但由于需要经过长延迟的网络环境与经历的繁琐步骤,网络IO的效率极其的低下,优化网络效率的问题被大家所关注。

网络IO的劣势:

  1. 高延迟:数据需要经过网络传输,可能受到网络延迟、丢包、拥塞等因素影响,因此延迟通常高于单机IO。
  2. 复杂性:需要处理网络连接建立、数据包封装/解封装、错误处理、流量控制等复杂问题。

如何理解IO时间 = 等 + 数据拷贝

答:

本地IO: 当我们通过read打开某个文件,实质上是让操作系统帮我们向磁盘中获取数据,我们应用层只需要等待 操作系统的缓冲区开始有数据,等到一定的时机,我们再拷贝到应用层准备好的缓冲区中。

网络IO: 网络版本其实也是类似外设从磁盘变成了网卡 ,依旧是操作系统为我们提取数据,应用程序等待 操作系统从网络接收数据到内核的套接字缓冲区,然后再拷贝到应用层缓冲区中。

2,5种IO模型(钓鱼例子)

5种模型为:IO = 等 + 数据拷贝,这里用一个人在河边钓鱼为例子,"等"=等鱼上钩,"数据拷贝" = 将鱼钓上放在桶(应用层缓冲区)里。

  • 阻塞式 :这个人 ,就坐在哪儿,盯着鱼漂,啥事不干,就单纯在哪儿等鱼上钩,再调上。
  • 非阻塞轮询式:这个人,过一会来看看是否有上钩,上钩了再把鱼钓起来。
  • 信号驱动式:这个人将鱼钩上安装一个上钩提醒器,而他就做其他的事情,等到提醒了才过来把鱼钓起。
  • 多路复用,多路转接式 :这个人安装100个鱼竿,坐在旁边,那个杆上钩了,把那个鱼钓起,因此这种方式效率最高
  • 异步IO式 :有两个人,老板自己嫌累,雇了个员工,员工在那里钓鱼,钓上来的鱼放在桶里,等到桶被装满,员工会提醒老板过来取鱼。

其中,除了异步IO,其余都是同步IO,那如何区分同步,异步IO只要(进程,线程)参与了等或者数据拷贝,都算同步IO,都没参加就是异步IO

通过了上面的IO模型下面我们来看看官方怎么解释的:(光看图理解是浅显的,这里就简单看看,后面会有代码实践

3. 5种IO模型图

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

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

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

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

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

小结

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

4,进程同步&IO同步区分

另外, 我们回忆 多进程多线程 的时候, 也提到 同步 。这里的 同步通信进程之间的同步是完全不想干的概念。
多进程多线程的同步 : 在操作系统或程序内部, 多进程或多线程之间的同步 是指控制多个进程或线程 访问共享资源的方式,以避免竞态条件和其他并发问题。常见的同步机制有:互斥锁,条件便利,信号量,原子操作等

同步通信 : 同步通信是指两个或多个网络实体之间进行有序的信息交换,其中一方在发送消息后 必须等待另一方的响应才能继续执行。

那如何区分同步通信和异步通信

以钓鱼为例,"在河边等待鱼上钩"=等待,"将鱼吊起"=拷贝,只要进程参与任一一步都理解为同步IO;都不参加,直接来提桶拿鱼为异步IO
以后在看到 "同步" 这个词, 一定要先搞清楚大背景是什么。这个同步, 是同步通信异步通信的同步, 还是进程,线程同步与互斥的同步。

5,阻塞与非阻塞区别

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

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回。
  • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

二,非阻塞IO

首先我们先看看我们常见的阻塞式IO:

cpp 复制代码
int main()
{
    while (1)
    {
        char buff[1024];
        int ret = read(0, buff, sizeof buff - 1);
        if (ret)
        {
            buff[ret] = 0;
            std::cout << "echo#:" << buff ;
        } else
        {
            std::cout << "....." << std::endl;
        }
    }
    return 0;
}

结论:I/O的文件描述符默认是阻塞式的。如果非阻塞式,需要我们通过fcntl设置。最常见的IO阻塞就是标准输入,会阻塞的等待我们输入内容。

1.设置I/O为非阻塞(fcntl)

#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );

(注:这个接口适用于 **所有的本地IO,**网络IO所打开的 **文件描述符设置是否阻塞形式。**有了这个我们就可以设置非阻塞IO,不用单独学习特定接口参数来设置非阻塞)
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).

废话少说,先上手看看,写一下用于 设置文件描述符方式为非阻塞式的函数,如下:

cpp 复制代码
int SetNoBlock(int fd)
{
    int ret = fcntl(fd, F_GETFL);  // 获取原来fd的读写标志位,就比如:open时设置的O_RDONLY,O_RDWR等等
    if (ret == -1)
        return -1;
    fcntl(fd, F_SETFL, ret | O_NONBLOCK);
    // fcntl 更换为设置读写标准位,并在原来标准位的基础上添加 非阻塞模式
    return 0;
}

我们此处只是用第三种功能, 获取/设置文件状态标记 , 就可以将一个文件描述符设置为非阻塞。设置好非阻塞后,在循环内文件描述符会被轮询,如果没有资源就绪,会 设置错误码,如:EAGAIN,EWOULDBLOCK------资源未就绪。

因此,修改后的非阻塞IO案例,如下:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cerrno>
#include <string.h>

int SetNoBlock(int fd)
{
    int ret = fcntl(fd, F_GETFL);  // 获取原来fd的读写标志位,就比如:open时设置的O_RDONLY,O_RDWR等等
    if (ret == -1)
        return -1;
    fcntl(fd, F_SETFL, ret | O_NONBLOCK);
    // fcntl 更换为设置读写标准位,并在原来标准位的基础上添加 非阻塞模式
    return 0;
}

int main()
{
    SetNoBlock(0);  //文件描述符也只需要设置一次,如果还要设置标准位可以后面自己添加
    char buff[1024];
    while (1)
    {
        sleep(1);
        errno = 0;
        int ret = read(0, buff, sizeof buff - 1);
        if ( ret > 0)
        {
            buff[ret] = 0;
            std::cout << "echo# " << buff;
            continue;
        }else  if( ret == 0)
        {
            std::cout << "echo# try again"  << std::endl;
        }else if (ret == -1)
        {
            std::cout << "echo# "<< errno << ": "<< strerror(errno);
            // 由于文件描述符采用非阻塞式后,我们无法区分是错误导致,还是单纯是IO资源未就绪
        }

        // 针对无法区分非阻塞式IO错误,我们需要对错误码进行区分
        if (errno == EAGAIN || errno == EWOULDBLOCK) // RWOULDBLOCK 本质是 EAGAIN
        {
            std::cout << " IO资源未就绪, try again" << std::endl;
            continue; 
        }else if (errno == EINTR) // read时被signal打断,返回时无法再调用read
        {
            continue;
        }else
        {
            break;
        }
    }
    return 0;
}

设置非阻塞后,socket的读事件未就绪,需要对错误值进行,判断。
其他错误值可能为:
EBADF 文件描述词为无效的或该文件已关闭
EINTR 此调用被信号所中断
EINVAL 参数n 为负值。
ENOMEM 核心内存不足

2. I/O多路转接(select)

select接口 是一个用于监控多个文件描述符(file descriptors,简称fd)的系统调用,它可以检测多个文件描述符上是否有事件发生(如数据可读、可写或异常)。这对于需要同时处理多个客户端连接的服务器程序来说非常有用,比如Web服务器、聊天服务器等。(AI)

参数理解

1). timeval结构体 类型,里面存放着微秒数,如下:

输入意义:告诉此次select 阻塞tv_sec + tv_usec秒后select超时返回。

输出意义:如果在阻塞时间内,有fd事件就绪,则会设置剩余时间量

这里扩展一下,获取时间戳的方法,如:C语言的time_t time(time_t *),系统接口gettimeofday

2). fd_set类型

select接口中有三个fd_set类型,意义:让系统分别注意,读事件,写事件,其他事件fd就绪。

fd_set 本质是一个位图数据结构 ,其中位图下标值代表fd ,一个位图最大能容纳1024个fd;

每个位的值为1,

  • 输入意义:告诉系统注意该fd的读事件(写事件,其他事件);
  • 输出意义:select会设置fd_set,输出时标记fd就绪的下标位为1;

设置fd_set类型,需要特定操作宏:

用select优化listen_socket

首先,我们需要理解listen_socket在网络通信中的角色。listen_socket就像是餐馆门口的迎宾员,负责接收潜在顾客(客户端)的到来,并准备好迎接他们(监听连接请求)。accept操作则像是餐馆内的服务员,负责正式接待顾客并安排座位(接受连接请求并创建新的套接字来处理这个连接)。

listen_socket不仅要监听传入的连接请求,还要与客户进行TCP的三次握手,以确认连接的建立。只有当三次握手成功完成后,accept操作才能接收这个新连接,并为其提供服务(创建新的套接字来处理连接)。

当客户完成数据交换并准备断开连接时,需要进行TCP的四次挥手,以确保双方都知道连接将要关闭,并且释放占用的资源。

使用如下例子:

cpp 复制代码
        MySocket tool;
        tool.listen_run(_server_socket);

        // 实现多路转接select,这里只有一个listen,不过演示一下即可
        fd_set fdset;
        while (1)
        {    
            timeval time = {0, 0} //非阻塞
            FD_ZERO(&fdset);
            FD_SET(_server_socket, &fdset);
            int n = select(_server_socket + 1, &fdset, nullptr, nullptr, &time);
           switch (n)
           {
           case 0:
                continue;
            break;
           
           case -1:
                Logmessage(ERROR, "select fail errno %d: %s", errno, std::to_string(errno).c_str());
                exit(-1);
           break;

           default:
                Logmessage(INFO, "select get a connect");
                // 获取成功
            }
         }

上面是select只监视listen_socket一个标识符的读事件就绪,那如何将已连接客户端(或者本地IO)描述符一起管理起来呢?

思路:

  1. 需要一个外部的int数组,自称它为socket数组集,用于存放select关注的所有描述符。
  2. 每次客户端连接成功后,将soket放入数组集中;当select监视到其中某socket读事件就绪并处理后,需要从数组集中消除。
  3. 3.有个该数组集,我们可以每次遍历数组集循环设置fd_set,同时方便FD_ISSET判断。

下面是用web服务器,对socket的select处理代码示例链接(仅看webserver.hpp):

IO_Model/webserver.hpp · 逆光/Linux - 码云 - 开源中国 (gitee.com)

select的优缺点

优:

缺点:

1. 用户,OS层存在大量遍历:用户层,需要不断修改第三方数组;OS层,也需要不断遍历fd_set与修改

2. fd_set本身的1024上限。

  1. 输入输出型参数,从用户到内核,从内核到用户存在频繁的数据拷贝。

4.编码复杂

3.I/O多路转接(poll)

针对select的缺点,经过过优化后的poll能解决大部分问题。


fds 是一个 poll 函数监听的结构列表 . 每一个元素中 , 包含了三部分内容 : 文件描述符 , 监听的事件集合 , 返回的事件集合。
nfds 表示: fds 数组的长度。
timeout 表示: poll 函数的超时时间 , 单位是毫秒 (ms)。
events 如何设置事件集合

使用示例如下:

cpp 复制代码
#include <stdio.h>
#include <sys/time.h>
#include <sys/poll.h>

int main() {
    struct pollfd fds[2];
    int timeout;

    // 初始化第一个文件描述符为标准输入
    fds[0].fd = 0; // 标准输入
    fds[0].events = POLLIN; // 只关心可读事件

    // 假设这里有一个套接字,其文件描述符为 sockfd
    int sockfd = 3;
    fds[1].fd = sockfd;
    fds[1].events = POLLRDNORM; // 监听套接字是否有数据可读

    timeout = 5000; // 超时时间为5秒

    // 进行 poll 调用
    int ret = poll(fds, 2, timeout);

    if (ret > 0) {
        for (int i = 0; i < 2; i++) {
            if (fds[i].revents & POLLIN) {
                printf("文件描述符 %d 可读\n", fds[i].fd);
                fds[i].revents = 0; //poll不在关注该描述符
            }
        }
    } else if (ret == 0) {
        printf("超时\n");
    } else {
        perror("poll error");
    }

    return 0;
}

poll优点:

缺点:

  1. 用户层,OS层任存在不少的遍历。用户层需要不断检测数组就绪;OS层不断检测fd就绪,本质上与select一样需要维护第三方数组。

  2. 当有大量链接接入,但就绪事件较少,这样遍历fd数组量加大,效率线性下降。

  3. 编码较select容易些。
    没关系,epoll会解决,按照 man 手册的说法 : 是为处理大批量句柄而作了改进的 poll . epoll 几乎具备了之前所说的一切优点,被公认为Linux2.6 下性能最好的多路 I/O 就绪通知方法。

结语

本小节就到这里了,感谢小伙伴的浏览,如果有什么建议,欢迎在评论区评论,如果给小伙伴带来一些收获,请动动你发财的小手点个免费的赞,你的点赞和关注永远是博主创作的动力源泉。

相关推荐
CodeHackerBhx25 分钟前
如何使用VMware安装Linux操作系统
linux·运维·服务器
Miraitowa_cheems27 分钟前
[JavaEE] UDP协议
网络·tcp/ip·java-ee
网络研究院28 分钟前
人工智能有助于解决 IT/OT 集成安全挑战
网络·人工智能·安全·报告·工业·状况
PythonFun31 分钟前
如何快速找回Finalshell中VPS的SSH密码
运维·ssh
吴半杯3 小时前
Linux-mysql5.7-mysql8.0安装包下载及安装教程,二合一
linux·运维·服务器
默行默致3 小时前
Linux 常用命令
linux·运维
魏 无羡3 小时前
pgsql 分组查询方法
java·服务器·数据库
码哝小鱼3 小时前
firewalld实现NAT端口转发
linux·网络
网络研究院3 小时前
执行网络攻击模拟的 7 个步骤
网络·安全·web安全·攻击·模拟·实践·步骤