高级IO 阻塞IO,非阻塞IO,多路转接select

前面我们讲解了基础的IO知识,掌握好他们后,对于我们理解高级IO有非常大的帮助

一. 什么是IO

1. IO是站在硬件角度进行输入输出

谁在IO呢?
在用户视角看,我们更关注进程或线程
在系统角度看,IO不仅用户去进行,可能会由OS去承担

2. 网络的本质其实就是IO

在这里插入图片描述

网卡上面有数据(读数据肯定是网卡先读到),OS如何得知?
  • OS主动去检测网卡有无数据(效率太低
  • 采用硬件中断+OS进行中断处理程序完成的

注:
CPU不和外设直接打交道指的是数据层面上的,有些控制信号是可以由外设达到CPU)

所以当外设上有数据时,外设会向我们CPU发送硬件中断(光电信号);CPU立马识别到,根据中断号执行网卡驱动中读数据的方法。
(中断 包含 中断号;每一个中断号对应一个处理方法。由OS执行,网卡驱动提供)


网卡读到的数据,本质上是通过中断的方式告诉CPU,CPU识别到对应的针脚(读取到编号),根据中断相关的处理函数来进行数据读取,将数据从网卡->内存

网卡是不是可能在一段时间内收到大量的报文?

是的

OS要不要将所有收到的报文管理起来呢?
需要的,先描述,再组织
(先用结构体描述,再用数据结构组织起来)
如:

//大概的结构如下,当然底层结构复杂的多,此时只是为了方便理解
struct sk_buffer{
	char* mac_header;
	char* net_header;
	char* tcp_header;
	char buffer[1024];
	struct sk_buffer *next;
	struct sk_buffer *prev;
	......
}

//其是一个双链表结构

在这里插入图片描述
解析时,先让buffer中的mac_header指向头部,然后开始提取;与有效载荷分离时,让net_header指向IP报头开始,这样通过指针的操作,就能分别提取出各个报头。
向上交付时,报文所在缓冲区就没变,只是在更改指针操作,将我们对应不同阶段的报头用指针指向特别的区域,然后进行数据分析
当我们识别完 tcp 后,将剩下一部分数据拷贝至网络的接收缓存区,我们即收到了

二. IO

进行IO的过程分两步进行:

  • 等待 IO 就绪
  • 拷贝 IO 数据到内核或者到外设

读时要接收缓存区中有数据,读事件就绪。
写时要发送缓冲区有空间,这才能拷贝,写事件就绪。

1. 本质上,IO 中真正有效的是哪一步呢?

拷贝

2. 那么什么才叫做高效的 IO 呢?

在特定时间段内,大大减小等的比重,增加拷贝的比重,即为高效的 IO

3. 结合例子理解

钓鱼,大家应该都知道,钓鱼分为几步呢?和 IO 一样,为等+钓,那现在以下有这几人在钓鱼,钓鱼的方式各有不同。
我们现在来区分一下谁钓鱼的效率最高。

  1. 张三:浮标不动,我不动,死死盯着浮标。(主动检测,阻塞的方式)
  2. 李四:定期的检测浮标,如果没有就绪,视角返回,做自己的事。(主动检测,非阻塞的方式)
  3. 王五:在浮标上挂一个铃铛,不检测浮标,只要铃铛响了,拉起鱼竿。(信号驱动的方式)
  4. 赵六:开着卡车,拉了一车的鱼竿,都插到岸边,定期的检测100个鱼竿。(多路转接,多路复用)

由上可知,A、B、C三人钓鱼效率是一样的。因为钓鱼的方式是一样的,只是等的方式不一样。
D的效率更高,因为其将等待的时间重叠,同时等待100个鱼竿,钓的比重增高了

总:

阻塞IO、非阻塞IO、信号驱动IO并不能提高IO的效率。但非阻塞IO、信号驱动IO可提高我们做事的效率
  1. 田七:想吃鱼,让其司机帮其去钓鱼,所以田七没有参与钓鱼,只是发起了钓鱼的任务。司机在钓鱼时,田七可能做任何事。(异步IO,提供一段缓冲区,通知方式,fd,OS帮你去拷贝)

总:

  • 张三、李四、王五、赵六,全部都是同步 IO ,等必须自己等,拷贝必须自己拷贝。他们只是等的方式不一样,等的数量不一样。
  • 田七,不用自己等,不用自己拷贝,直接拿到最终结果,为异步IO

这里的钓鱼过程可看作一个IO过程

4. 阻塞IO

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

当实际在进行 IO 时,要从套接字上读数据,可是数据可能还没有来;对方还没有给你发送,数据可能还在网络中。所以必须由OS从网络中把数据读取上来放进接收缓存区。
在这里插入图片描述

这里的阻塞本质上是将进程的状态设为S非R状态,然后将该进程放入等待队列中
当数据准备好,OS会把你从等待队列中唤醒,执行recvfrom

5. 非阻塞IO

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

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

在这里插入图片描述
所谓的阻塞(OS发起,OS执行),是用户层的感受。在内核中本质是进程被挂起(S or T or D)。需要等待某种事件就绪。
所谓的非阻塞(由用户发起,OS执行)轮询的本质:在做事件就绪的检测工作

6. 信号驱动IO

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

在这里插入图片描述
上面信号产生是异步的

本质是 同步 IO 的一种,因为 IO 的过程是同步的。(当数据就绪时,要自己把数据从内核拷贝到用户)

7. 异步IO

由内核在数据拷贝完成时,通知应用程序
在这里插入图片描述
异步 IO 在大部分编程中用的也比较少,不过在一些场景中,也有可能被使用

异步 IO 实际在调用时需要你去调用一下 异步 IO接口,同时,还需要你提供一个用户缓冲区

8. IO多路转接(IO多路复用)

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

在这里插入图片描述
IO 分为等和拷贝,所以 recv、read、write、send 除了进行拷贝以外,还要进行等待

select、poll、epoll为多路转接的函数(只等)
再用recv、read、write、send进行拷贝,此时recv、read、write、send(只能传入一个文件描述符)只关注拷贝

所以靠上面多路转接的函数与recv、read、write、send即可完成多路转接。

9. 总

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

==我们现在用的最多的是:阻塞、非阻塞、多路转接

三. 高级IO重要概念

1. 阻塞 vs 非阻塞

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

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

2. 同步通信 vs 异步通信

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

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

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

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

3. 其他高级IO

非阻塞IO、记录锁、系统V流机制、I/O多路转接(也叫I/O多路复用),readv和writev函数以及存储映射IO(mmp),这些统称为高级IO

我们重点关注IO多路转接

四. 非阻塞IO

1. 设置非阻塞

open在打开时设置非阻塞

在open中可设置O_NONBLOCK,让文件打开时以非阻塞方式打开

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char* pathname,int flag);
int open(const char* pathname,int flag,mode_t mode);
fcntl

将一个已经打开的文件设为非阻塞
文件打开默认进行的都是阻塞IO

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

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

fcntl可以设置非阻塞,不代表它只能设置非阻塞

其传入的cmd值不同,后面追加的参数也不相同
fcntl函数有5种功能

复制一个现有的描述符(cmd=F_DUPFD)
获得/设置文件描述符状态(cmd=F_GETED或F_SETFD)
获得/设置文件状态标记(cmd=F_GETFL或F_SETFL)
获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN)
获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW)

我们只用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞

基于fcntl,我们实现一个SetNoBlack函数,将文件秒速符设为非阻塞

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

2. 非阻塞与阻塞的实例代码

阻塞
#include <iostream>
#include <unistd.h>
#include <fcntl.h>

#define NUM 1024

int main(){
	while(1){
		char buffer[1024];
		ssize_t size = read(0,buffer,sizeof(buffer)-1);
		if(size < 0){
			std::cerr << "read error" << size << std::endl;
			break;
		}
		buffer[size] = 0;
		std::cout << "echp#" << buffer << std::endl;
	}
}
非阻塞
#include <iostream>
#include <unistd.h>
#include <fcntl.h>

#define NUM 1024

bool SetNoBlock(int fd){
	int fl = fcntl(fd,F_GETFL);
	if(fl < 0){
		std::cerr << "fcntl error" << std::endl;
		return false;
	}
	fcntl(fd,F_SETFL,fl | O_NONBLOCK);
	return true;
}

int main(){
	SetNoBlock(0);
	while(true){
		char buffer[1024];
		ssize_t size = read(0,buffer,sizeof(buffer)-1);
		if(size < 0){
			if(errno == EAGAIN || errno == EWOULDBLOCK){
				std::cout << "底层的数据没有准备就绪,再轮询检测一下" << std::endl;
				sleep(1);
				continue;
			}
			if(errno == EINTR){
				std::cout << "底层的数据就绪未知,被信号打断" << std::endl;
				continue;
			}
			else{
				std::cerr << "read error" << std::endl;
				break;
			}
		}
		buffer[size] = 0;
		std::cout << buffer << std::endl;
	}
}

这里面种有几点需要解释一下:

  • 当底层没有数据时,read返回-1,errno设置为EAGAIN或EWOULDBLOCK
  • 如果非阻塞,读取数据时,如果没有就绪,read是以出错的形式返回
  • 信号打断,read返回-1,errno设置为EINTR。

五. I/O多路转接之select

1. select

select本质是一种就绪事件的通知机制
它的核心工作是等,一旦事件就绪即通知上层

read,底层数据从无到有,从有到多,读事件就绪
write,底层缓冲区剩余空间从无到有,从有到多,即写事件就绪

底层只要有数据,底层缓冲区只要有空间,都叫做select的读事件和写事件就绪。
然后再去调用read、recv、write、send等不会被阻塞

select可以一次等待多个文件描述符

2.select函数

#include <sys/select.h>

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

nfds:select在等待的多个文件描述符值中,最大的文件描述符+1

所有的fd_set*类型为输入输出型参数

从第二个开始,分别为读事件、写事件、异常事件的集合

timeout:设置select等待时间,传NULL即让select一直等,直到有一个就绪再返回。传0,现在去等,然后马上返回

select不进行读写,读写时recv、read的工作

select要对多个文件描述符进行轮询检测,告诉我要等的范围,我会对整个范围轮询检测

fd_set类似sigset_t,是一个位图,可以将特定的fd添加到位图中;使用位图中对应的位来表示要监视的文件描述符

所以提供了一组操作fd_set的接口,来比较方便的操作位图

void FD_CLR(int fd,fd_set* set);    //用来清除描述词组set中相关fd的位
void FD_ISSET(int fd,fd_set* set);  //用来测试描述词组set中相关fd的位是否为真
void FD_SET(int fd,fd_set* set);    //用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set* set);          //用来清除描述词组set的全部位
select是一个系统调用,所有的参数是OS给的

用户调用select,user->kernel(用户想告诉内核什么呢?):请OS帮我检测一下在readfds与writefds中,所有的fd状态

select返回时,kernel->user(是内核想告诉用户什么呢?):我检测的readfds或writefds中,已有读/写事件就绪

例:我想关注一下3和4read和write事件
将3和4文件描述符添加到readfds和writefds

3. select返回值

成功返回就绪文件描述符总数。如果返回值为0,则时间过期,超过设置的等待时间。出错则返回-1,错误原因存于errno,此时参数readfds、writefds、exceptfds和timeout的值变成不可预测

错误值可能为:
EBADF文件描述词为无效的或该文件已关闭
EINTR此调用被信号所中断
EINVAL参数n为负值
ENOMEM核心内存不足

4. select的工作流程

select调用,每一次都需要进行对所关心的fd进行重新设置

如果是连接事件到来呢?

连接事件到来,在多路转接看来,都统一当作读时间就绪。
所以我们在监听套接字去accept时,不放在最前面,因为如果没有人连接我,那么accept一直处于阻塞状态。(accept阻塞过程就相当于等的过程,accept把底层的连接拿到上层的过程,就是一个IO的过程)。那么下面的代码就不会被调用,select就不能被调用。所以我们可以直接将监听套接字加入readfds去进行监听

5.select多路转接的简单实现

sock.hpp

#pragma once

#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet.in.h>

#define BACK_LOG 5
#define NUM 1024
#define DEL_FD -1

namespace ns_sock{
	class Sock{
	public:
		static int Socket(){
			int sock = socket(AF_INET,SOCK_STREAM,0);
			if(sock < 0){
				std::cerr << "socket error!" << std::endl;
				exit(1);
			}
			int opt = 1;
			setsockopt(sock,SDL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
			return sock;
		}
		static bool Bind(int sock,unsigned short port){
			struct sockaddr_in local;
			memset(&local,0,sieof(0));
			local.sin_family = AF_INET;
			local.sin_port = htons(port);
			local.sin_addr.s_addr = INADDR_ANY;
			if(bind(sock,(struct sockadd*)&local,sizeof(local)) < 0 ){
				std::cerr << "bind error!" << std::endl;
				exit(2);
			}
			return true;
		}
		static bool Listen(int sock,int backlog){
			if(listen(sock,backlog) < 0){
				std::cerr << "listen error" << std::endl;
				exit(3);
			}
			return true;
		}
	};
}

select_server.hpp

#pragma once

#include "sock.hpp"
#include <sys/select.h>
#include <sys/types.h>
#include <sys/socket.h>

namespace ns_select
{
    using namespace ns_sock;

#define NUM (sizeof(fd_set) * 8)

    const int g_default = 8080;

    // class EndPoint{
    //     int fd;
    //     std::string buffer;
    // };

    class SelectServer
    {
    private:
        u_int16_t port_;
        int listen_sock_;

        int fd_arrar_[NUM];

        // EndPoint fd_array_[NUM];

    public:
        SelectServer(int port = g_default) : port_(port), listen_sock_(-1)
        {
            for (int i = 0; i < NUM; i++)
            {
                fd_arrar_[i] = -1;
            }
        }
        void InitSelectServer()
        {
            listen_sock_ = Sock::Socket();
            Sock::Bind(listen_sock_, port_);
            Sock::Listen(listen_sock_);
            fd_arrar_[0] = listen_sock_;
        }
        std::ostream& PrintFd()
        {
            for(int i = 0; i < NUM; i++)
            {
                if(fd_arrar_[i] != -1) std::cout << fd_arrar_[i] << ' ';
            }

            return std::cout;
        }
        // 1111 1111
        // 0000 1111
        void HandlerEvent(const fd_set &rfds)
        {
            //判断我的有效sock,是否在rfds中
            for (int i = 0; i < NUM; i++)
            {
                if(-1 == fd_arrar_[i]) {
                    continue;
                }
                //如何区分: 新链接到来,真正的数据到来?
                if(FD_ISSET(fd_arrar_[i], &rfds))
                {
                    if(fd_arrar_[i] == listen_sock_)
                    {
                        //新链接到来
                        struct sockaddr_in peer;
                        socklen_t len = sizeof(peer);
                        int sock = accept(listen_sock_, (struct sockaddr*)&peer, &len);
                        if(sock < 0)
                        {
                            std::cout << "accept error" << std::endl;
                        }
                        else
                        {
                            //不能直接读取新的sock,为什么要见它添加到数组就完了??
                            //将新的sock添加到文件描述符数组中!
                            // int index = -1;
                            int j = 0;
                            for(; j < NUM; j++)
                            {
                                if(fd_arrar_[j] == -1)
                                {
                                    // index = j;
                                    break;
                                }
                            }
                            // if(index == -1) 
                            if(j == NUM)
                            {
                                std::cout << "fd_array 已经满了!" << std::endl; 
                                close(sock);
                            }
                            else
                            {
                                fd_arrar_[j] = sock;
                                // fd_arrar_[index] = sock;
                                std::cout << "获取新的链接成功, sock: " << sock << " 已经添加到数组中了, 当前: " << std::endl;
                                PrintFd() << " [当前]" << std::endl;
                            }
                        }
                    }
                    else
                    {
                        // 数据到来
                        // 这样写是有BUG的!这里不解决,epoll
                        // 你能保证你用1024就能读取完毕吗??有没有可能有粘包问题??
                        // 网络通信,定制协议,业务场景有关
                        // 是不是每一个sock,都必须有自己独立的buffer
                        char buffer[1024];
                        ssize_t s = recv(fd_arrar_[i], buffer, sizeof(buffer), 0);
                        if( s > 0 )
                        {
                            buffer[s] = '\0';
                            std::cout << "clint say# " << buffer << std::endl;
                        }
                        else if(s == 0)
                        {
                            std::cout << "client quit ---- sock: " << fd_arrar_[i] << std::endl;

                            // 对端链接关闭
                            close(fd_arrar_[i]);

                            // 从rfds中,去掉该sock
                            fd_arrar_[i] = -1;

                            PrintFd() << " [当前]" << std::endl;

                        }
                        else
                        {
                            //读取异常,TODO
                            std::cerr << "recv error" << std::endl;
                        }
                    }
                }
            }
        }
        void Loop()
        {
            //这样写有问题吗??

            //在服务器最开始的时候,我们只有一个sock,listen_sock
            //有读事件就绪,读文件描述符看待的!
            fd_set rfds; // 3, 4,5,6
            // fd_set wfds;
            // fd_set efds;
            // FD_SET(listen_sock_, &rfds);
            while (true)
            {
                // struct timeval timeout = {0, 0};
                // 对位图结构进行清空
                FD_ZERO(&rfds);
                int max_fd = -1;

                for (int i = 0; i < NUM; i++)
                {
                    if (-1 == fd_arrar_[i])
                        continue;
                    FD_SET(fd_arrar_[i], &rfds);
                    if (max_fd < fd_arrar_[i])
                        max_fd = fd_arrar_[i];
                }

                // select是可以等待多个fd的,listen_sock_只是其中之一
                // 如果有新的链接到来,一定对应的是有新的sock,你如何保证新的sock也被添加到select 中?
                // rfds: 1111 1111 (输入)
                //       1000 0000 (输出)
                // select 要被使用,需要借助于一个第三方数组,管理所有的有效sock

                // fd_set: 不要把它当做具有sock保存的功能,它只有互相通知(内核<->用户)的能力
                // select模型: 要保存历史所有的sock(为什么?),需要借助于第三方的数组.
                // select : 通知sock就绪之后,上层读取,可能还需要继续让select帮我们进行检测,对rfds进行重复设置
                int n = select(max_fd + 1, &rfds, nullptr, nullptr, nullptr);
                switch (n)
                {
                case 0:
                    std::cout << "timeout ..." << std::endl;
                    break;
                case -1:
                    std::cout << "select error" << std::endl;
                    break;
                default:
                    // select成功, 至少有一个fd是就绪的
                    HandlerEvent(rfds);
                    // std::cout << "有事件发生了..." << std::endl;
                    break;
                }
            }
        }
        ~SelectServer()
        {
            //没什么意义
            if (listen_sock_ >= 0)
                close(listen_sock_);
        }
    };
} // namespace ns_select

server.cc

#include "select_server.hpp"

using namespace ns_select;

int main()
{
    SelectServer *svr = new SelectServer();
    svr->InitSelectServer();
    svr->Loop();
    
    // std::cout << sizeof(fd_set) << std::endl;

    return 0;
}
问:数组大小范围是固定的,用其去存放文件描述符不会超过范围吗?不会出错吗?

不会,fd_set是一个具体的数据类型->大小是确定的,是一个位图结构。而我们的文件描述符的个数也是有上限的。
如:在云服务器上的上限为1024,而我用的云服务器来编写上述代码,所以我写的1024,一般写出

#define NUM (sizeof(fd_set)*8)

更好

6. 总:

缺点

  • select 能够同时等待的文件描述符是有上限的,你的进程能够打开的文件只有32个,虽然可以扩展。但select等待是有上限的,这个是select的缺点
  • select需要和内核交互数据,涉及到较多数据的也会拷贝,当select面临的连接很多,就绪的也较多,因为数据拷贝,而导致效率降低。
  • select每次调用,都必须重新添加fd,一定会影响程序运行的效率,而且非常麻烦,容易出错
  • OS在检测fd就绪时,需要遍历的。所以当有大量的连接的时候,内核同步select底层遍历,成本会越来越高,效率会降低

优点

  • select可以同时等待多个fd,而且只负责等待,有具体的accept,recv,send等来完成实际的IO操作

那为什么我们不用多进程和多线程?因为这样太耗费系统资源了

适用场景
如果有一定的连接,每个连接都很活跃,是不是必须得使用多路转接
不适合
多路转接的适合场景为:适合有大量的连接,但是只有少量是活跃的

剩下的poll、epoll下一篇文章讲

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值