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

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

一. 什么是IO

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

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

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

高级IO 阻塞IO,非阻塞IO,多路转接select_第1张图片

网卡上面有数据(读数据肯定是网卡先读到),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;
	......
}

//其是一个双链表结构

高级IO 阻塞IO,非阻塞IO,多路转接select_第2张图片
解析时,先让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从网络中把数据读取上来放进接收缓存区。
高级IO 阻塞IO,非阻塞IO,多路转接select_第3张图片

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

5. 非阻塞IO

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

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

高级IO 阻塞IO,非阻塞IO,多路转接select_第4张图片
所谓的阻塞(OS发起,OS执行),是用户层的感受。在内核中本质是进程被挂起(S or T or D)。需要等待某种事件就绪。
所谓的非阻塞(由用户发起,OS执行)轮询的本质:在做事件就绪的检测工作

6. 信号驱动IO

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

高级IO 阻塞IO,非阻塞IO,多路转接select_第5张图片
上面信号产生是异步的

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

7. 异步IO

由内核在数据拷贝完成时,通知应用程序
高级IO 阻塞IO,非阻塞IO,多路转接select_第6张图片
异步 IO 在大部分编程中用的也比较少,不过在一些场景中,也有可能被使用

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

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

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

高级IO 阻塞IO,非阻塞IO,多路转接select_第7张图片
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 
#include 
#include 
int open(const char* pathname,int flag);
int open(const char* pathname,int flag,mode_t mode);
fcntl

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

#include 
#include 

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 
#include 
#include 

#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 
#include 
#include 

#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 

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 
#include 
#include 
#include 
#include 
#include 
#include 

#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 
#include 
#include 

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下一篇文章讲

你可能感兴趣的:(Linux,网络,linux)