天天看点

[Linux]——I/O多路转接select技术浅析I/O多路转接select技术总结

浅析I/O多路转接select技术

在谈I/O多路转接技术之前,我们先来谈谈什么是I/O。I/O是input和ouput的缩写,即输入输出端口,每个设备都有自己的输出输入地址,用来处理自己的输入输出信息。

I/O的五种工作模式

首先我们简单介绍一下I/O的五种工作模式,看看不同的I/O方式是如何进行工作的:

  • 阻塞I/O模型:应用程序调用一个I/O函数,导致应用程序阻塞,此应用程序会一直等待内核将数据准备好,当数据准备事件完毕后将数据拷贝至用户区。I/O函数返回成功。
  • 非阻塞I/O模型:阻塞就是让应用程序调用I/O函数时卡住,从操作系统的角度上来看就是将当前函数设置为非R状态。然而如果我们不希望程序被阻塞就会将接口设置为非阻塞,如果所请求的I/O无法完成时就会立马返回一个EAGAIN错误,提示让你再试一次。这个过程会反复的被执行,直到数据被准备好为止,这种行为也被叫做轮询。但是这样的行为浪费大量的cpu资源,只有特定场景下才会使用。
  • 信号驱动I/O模型:我们首先允许接口进行信号驱动I/O,并安装一个信号处理函数。进程继续执行不被阻塞,当数据被准备好时进程收到一个SIGIO信号,可在信号处理函数中调用I/O。
  • I/O复用模型:此模型会使用到我们今天要谈的主角select函数,除了select还有poll和epoll函数。调用这几个函数也会被阻塞,然而与阻塞I/O不同的是,这几个函数可以同时阻塞多个I/O操作。而且可以对多个读操作多个写操作进行检测,当数据准备好时,才真正调用I/O函数。
  • 异步I/O模型:调用aio_read函数,告诉内核描述字,缓冲区指针,缓冲区大小,文件偏移以及通知的方式,然后立即返回。当内核讲数据拷贝到缓冲区后,再通知应用程序。

其实从上面的五种I/O模型中可以总结出一个共同的特征:任何I/O操作都需要执行两个步骤,第一个步骤是进行等待读写事件发生,第二个步骤是拷贝数据。我们发现往往大多数的I/O时间都被浪费在了等待上。而我们今天要介绍的select函数,他能帮助我们完成第一步等待的工作。与普通阻塞I/O不同的是,在调用select函数时,他能帮助我们监测多个文件描述符上的读写事件,一旦有某个文件描述符上被关心的读写事件发生时,才调用真正的I/O函数。

select函数

select系统调用是用来让我们的程序监视多个文件描述符的状态变化的,程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变。

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

参数解释:

  • nfds:此参数应设置为所关心文件描述符的最大值加一,比如你关心文件的文件描述符最大为5,那么此参数填6
  • readfds、writefds、exceptfds:分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集合及异常文件描述符的集合,这些类型为fd_set的集合实际上是一个个的位图。这些参数既是输入参数也是输出参数,作为输入参数时添加你想关注的文件描述符到位图中。而select函数最后返回读写已经就绪的文件描述符位图
  • timeout:select函数等待的方式设置,设置为NULL为阻塞等待

timeval结构体:下图是从/usr/include/linux/time.h文件中查找到的,结构体中tv_sec表示秒,如果你设置为0,那么select函数将会进行非阻塞式等待,如果填大于0的数字则会等待你所设置的秒数后超时返回。tv_usec表示微秒,与tv_sec道理相同,只是单位不同而已

[Linux]——I/O多路转接select技术浅析I/O多路转接select技术总结

fd_set相关:可以看出fd_set的类型也是一个结构体,结构体中放的是一个个的位图,位图为long int

[Linux]——I/O多路转接select技术浅析I/O多路转接select技术总结
[Linux]——I/O多路转接select技术浅析I/O多路转接select技术总结

而为了让用户更方便的操作这些位图,系统为我们提供了下面的一组函数:

void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
 int 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的全部位
           

返回值:

  • 执行成功:返回状态已经改变的文件描述符个数
  • 返回零:代表此次检测任务超时,在规定的时间内没有文件描述符状态发生变化
  • 返回负一:错误原因可能为,EBADF文件描述词为无效的或该文件已关闭,EINTR此调用被信号所中断INVAL参数n为负值。ENOMEM核心内存不足

select函数原因大体就是这样,更多的细节我们在下面模拟实现一个select服务器中详细谈。不过,虽然你还没有关注更多的细节,光看了上面的函数原型你就发现了select函数的一个缺点:设置的不够友好,用户体验极差。

理解select执行过程

[Linux]——I/O多路转接select技术浅析I/O多路转接select技术总结

这里一定注意最后返回的set中原先fd=5的位置已经被清理空了。这个流程很简单,但是实际在实现起来并没有那么简单。

模拟实现select服务器

实现服务器前我们必须关注一个问题,我们以上面的实现流程来说,我们发现我们之前设置的5号文件描述符由于没有发生状态的变化,所以在一次返回之后被清空。而往往我们希望接着关注此文件描述符上所发生的读写事件,这就不得不导致需要我们重新设置。

然鹅重新设置一个还行,可是如果关心很多个文件描述符,就需要记住这些描述符。所以又不得不引入一个辅助数组来帮我们记住这些描述符。

#pragma once
#include<iostream>
#include<sys/select.h>
#include<sys/stat.h>
#include<unistd.h>
#include<vector>
#include<sys/socket.h>
#include<stdlib.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<fcntl.h>
#include<strings.h>
#include<stdio.h>
using namespace std;

class SockApi{//将socket类使用一个类单独封装,在select服务器中直接调用
    public:
        static int Socket()
        {
            int sock = socket(AF_INET,SOCK_STREAM,0);
            if(sock < 0){
                exit(2);
            }
            int opt = 1;
            setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
            return sock;
        }
        static void Bind(int sock, int port)
        {
            struct sockaddr_in local;
            bzero(&local, sizeof(local));
            local.sin_family = AF_INET;
            local.sin_port = htons(port);
            local.sin_addr.s_addr = htonl(INADDR_ANY);

            if(bind(sock, (sockaddr*)&local, sizeof(local)) < 0){
                exit(3);
            }
        }
        static void Listen(int sock)
        {
            if(listen(sock, 5) < 0){
                exit(4);
            }
        }
        static int Accept(int listen_sock)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int sock = accept(listen_sock, (sockaddr*)&peer, &len);
            if(sock < 0){
                return -1;
            }
            return sock;
        }
};

class SelectServer{
    public:
        int port;
        int listen_sock;
        std::vector<int> rfdv;//存放要关注文件描述符的数组
        int cap;//select能关注最大文件描述符的数量
    public:
        SelectServer(int port_ = 8080):port(port_),listen_sock(-1),rfdv(1024,-1)
        {
            cap = 1024;//计算方式是sizeof(fd_set)*8
        }
        void ServerInit()
        {
            listen_sock = SockApi::Socket();//一些熟悉的socket函数调用
            SockApi::Bind(listen_sock, port);
            SockApi::Listen(listen_sock);
        }
        void Run()
        {
            rfdv[0] = listen_sock;
            //监听描述符一定要设置进数组中,笔者因为开始忘记设置出了1w个bug,都是泪
            for(;;){
                fd_set rfds;//读文件描述符集合
                FD_ZERO(&rfds);//初始化
                int max_fd = listen_sock;//记录最大的文件描述值,为设置nfds做铺垫
                rfdv[0] = listen_sock;//一定记住这一步
                for(int i = 0; i < cap; i++){
                    if(rfdv[i] == -1){//为-1表示这个位置没有有效的文件描述符
                        continue;
                    }
                    if(max_fd < rfdv[i]){
                        max_fd = rfdv[i];//更新max_fd
                    }
                    FD_SET(rfdv[i], &rfds);//设置进位图
                }
                struct timeval timeout = {5, 0};//将timeout时间设置为5s
                switch(select(max_fd+1, &rfds, nullptr, nullptr, nullptr)){
                    case 0:
                        cout << "timeout ... " <<endl;
                        break;
                    case -1:
                        cout << "select error ..." <<endl;
                        break;
                    default:
                        cout << "handler start ..." <<endl;
                        Handler(&rfds);//已经有文件描述符读事件就绪
                        cout << "handler end ..." <<endl;
                        break;
                }
            }
        }
        void Handler(fd_set* rfds)
        {
            for(int i = 0;i < cap; i++)
            {
                if(rfdv[i] == -1){//为-1跳过
                    continue;
                }

                if(i == 0 && FD_ISSET(rfdv[i],rfds)){
                    int fd = SockApi::Accept(listen_sock);
                    //走到这里说明就绪的读事件是有新的连接,新连接在select中也是读事件
                    int j;
                    for(j = 1; j < cap; j++){
                        if(rfdv[j] == -1){
                            cout << "get a new connect ..." <<endl;
                            rfdv[j] = fd;
                            //将这个fd放入数组中,下次设置这个fd
                            break;//一定要break,不然就循环设置满数组最后溢出
                        }
                        if(j == cap){//此时说明关注的文件描述符超过了1024,设置失败
                            cout << "rfdv is full ..." <<endl;
                            close(fd);
                            break;
                        }
                    }
                }
                else if(i != 0 && FD_ISSET(rfdv[i], rfds))//其他文件读事件就绪
                {
                    char buf[1024];
                    ssize_t s = read(rfdv[i], buf, sizeof(buf) - 1);
                    if(s > 0){//这里其实这样读会有粘包问题,但是我们关注点不在这
                        fflush(stdout);
                        cout << buf <<endl;
                    }
                    else if(s == 0){
                        cout << "clinet quit" <<endl;
                        //这里很关键,读到0说明对端把连接关了,所以文件描述符不需要在关心
                        close(rfdv[i]);
                        rfdv[i] = -1;
                    }
                    else{
                        close(rfdv[i]);
                        //读出错我们使用和对端关闭一样的处理方式
                        rfdv[i] = -1;
                        cout << "read error" <<endl;
                    }
                }
            }
        }
        ~SelectServer()
        {}
};
           

关于事件的就绪问题:

读就绪:

  • socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT. 此时可以无阻塞的读该文件

    描述符, 并且返回值大于0;

  • socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
  • 监听的socket上有新的连接请求;
  • socket上有未处理的错误;

写就绪:

  • socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0;
  • socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE

    信号;

  • socket使用非阻塞connect连接成功或失败之后;
  • socket上有未读取的错误;

关于select技术的问题

关于select技术的问题,我们直接从它的优缺点谈一谈吧。

优点:

  • select资源占用比较少
  • 用户量较多的时候它的性能和效率比较好,上面那个服务器很多人同时连处理速度还是相当快

缺点:

  • 它总共监视的文件描述符是有限制的. 最多1024(在我的机器下)
  • 当select频繁调用的过程中,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时很大
  • select所监视的文件描述符,select的调用频繁,然后它会反复遍历,可能会达到性能瓶颈
  • 因为参数为输入输出性,在操作时得一直进行遍历,查找使用给用户带来了极大的不便

总结

要明确的了解select各个参数的意义是什么,搭建简单的select服务器也要熟练操作。最关键的是要理解select的工作特点以及他的优缺点。要强调的是,select并没有被广泛使用,因为他的缺点导致服务器性相比其他多路转接技术来说很容易达到瓶颈,并且函数设计的并不是很友好。

继续阅读