IO多路转接之select

目录

1. 前置性认识

1.1. 关于 timeval 结构

1.2. 关于 fd_set 结构

1.3. fd_set 的相关操作方式

2. 初识 select

2.1. select 函数原型

2.2. select 参数介绍

2.3. select 返回值

3. select 的实现 

3.1. 理解文件描述符集

3.2. 如何看待 listensock 

3.3. 通用文件

3.3.1. sock.hpp

3.3.2. Log.hpp

3.3.3. Date.hpp

3.4. select demo

3.5. select 服务器的一般编写模式

3.6. select 的总结


1. 前置性认识

1.1. 关于 timeval 结构

timeval 是一个描述当前时间的结构,具体如下:

#include <sys/types.h>

/* A time value that is accurate to the nearest
microsecond but also has a range of years.  */
struct timeval
{
	__time_t tv_sec;    /* Seconds.  */
	__suseconds_t tv_usec;  /* Microseconds.  */
};
  • tv_sec: 这是一个整数类型的成员,用于存储秒数部分的时间值。它表示从 1970 年 1 月 1 日 00:00:00(也称为 Unix 时间戳)以来经过的秒数;
  • tv_usec: 这也是一个整数类型的成员,用于存储微秒(百万分之一秒)部分的时间值。它表示从 tv_sec 所表示的时间值之后经过的微秒数。

1.2. 关于 fd_set 结构

fd_set 是一个文件描述符集,用于在进行多路复用 I/O(如 select()、poll()、epoll() 等系统调用)时管理文件描述符(file descriptor)。

它通常用于设置和检查文件描述符的状态,以便确定哪些文件描述符上发生了 I/O 事件。

结构如下:

#include <sys/select.h>

/* The fd_set member is required to be an array of longs.  */
typedef long int __fd_mask;

/* fd_set for select and pselect.  */
typedef struct
{
	/* XPG4.2 requires this member name.  Otherwise avoid the name
	from the global namespace.  */
#ifdef __USE_XOPEN
	__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->fds_bits)
#else
	__fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS];
# define __FDS_BITS(set) ((set)->__fds_bits)
#endif
} fd_set;

/* Maximum number of file descriptors in `fd_set'.  */
#define FD_SETSIZE    __FD_SETSIZE
/* Number of descriptors that can fit in an `fd_set'.  */
#define __FD_SETSIZE    1024

事实上,fd_set 是一个位图结构,它是通过数组实现的位图结构。一般情况下,fd_set 结构所能表示最大的文件描述符数量是宏 FD_SETSIZE,数组的大小就是 FD_SETSIZE / 8;

在使用多路复用 I/O 的系统调用时,例如 select(),可以通过将要监视的文件描述符添加到 fd_set 中,然后调用 select() 来等待事件发生。当有事件发生时,可以通过检查 fd_set 中相应的位来确定哪些文件描述符上发生了事件。

1.3. fd_set 的相关操作方式

通常,通过相关系统调用 FD_ZERO()、FD_SET()、FD_CLR() 和 FD_ISSET() 来对 fd_set 进行操作。 

FD_ZERO()、FD_SET()、FD_CLR() 和 FD_ISSET() 这四个接口 (本质上是宏) 都定义在头文件 <sys/select.h>中

FD_CLR,将文件描述符 fd 从 set 中移除,即将对应位设置为 0。

void FD_CLR(int fd, fd_set *set);

FD_ISSET,用来判断 set 中是否有这个 文件描述符fd;

返回值:如果 fd 在 set 中被设置了,返回非零值;否则返回 0。

int FD_ISSET(int fd, fd_set *set); 

FD_SET,将文件描述符 fd 添加到 set 中,即将对应位设置为 1。

void FD_SET(int fd, fd_set *set); 

FD_ZERO,将 set 中的所有位都清零。

void FD_ZERO(fd_set *set);

2. 初识 select

2.1. select 函数原型

#include <sys/types.h>

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

2.2. select 参数介绍

nfds:这个参数指定文件描述符集合中所有文件描述符的范围,其值是最大文件描述符值加 1。这是因为 select() 需要知道需要检查的文件描述符的数量范围。

除开第一个参数 (nfds) 之外,后四个参数都是输入输出型参数。 

select 可以同时等待多个文件描述符,等待策略是可以选择的,通过 timeout 这个参数:

  • 如果 timeout 为 nullptr, 代表阻塞等待;
  • 如果 timeout 是 {0, 0},那么代表非阻塞等待;
  • 如果 timeout 是 {大于0的整数, 0},比如 {5, 0}, 那么代表着五秒内阻塞式等待,如果在五秒内期间没有文件描述符事件就绪,那么立马返回;
  • 如果在等待时间内 (五秒内期间),有 fd 事件就绪,那么此时的 timeout 会体现它的输出性,表示距离下一次 timeout  剩余多长时间。比如,2秒后事件就绪,那么此时 timeout就会输出 {3, 0},表示距离下层 timeout 还剩 3 秒;

中间的三个参数: readfds、writefds、exceptfds。

  • readfds:应用程序想要检查读事件状态的文件描述符集;
  • writefds:应用程序想要检查写事件状态的文件描述符集;
  • exceptfds:应用程序想要检查异常事件的文件描述符集。

这三个参数都是输入输出型参数,具体如下:

  • 作输入型参数时,用户告诉内核,操作系统要帮用户关心哪些文件描述符的哪一种事件;
  • 作输出型参数时,内核告诉用户,用户所关心的文件描述符中,哪些文件描述符上的那类事件已经就绪了;
  • 比如,如果用户想要关心文件描述符的读事件,那么就将相应的文件描述符添加到 readfds 这个文件描述符集中,如果用户关心文件描述符的写事件,那么就将该文件描述符添加到 writefds 这个文件描述符集中,如果都关心,那都添加即可;
  • 比如读事件,如果一个文件描述符的读事件就绪,那么此时底层数据一定就绪,上层就可以进行拷贝数据,此时拷贝数据,一定不会被阻塞。

2.3. select 返回值

On success,返回已经事件就绪的文件描述符的数量;

如果设置了超时时间,即 timeout,在 timeout 期间,如果没有事件就绪的文件描述符,将返回0;

On failure, 返回 -1,可以用 errno 查看错误原因;

3. select 的实现 

3.1. 理解文件描述符集

我们以读事件为例,即设置 readfds。

众所周知,文件描述符本质上是数组下标 (struct file* fd_array[]),因此这些文件描述符实质上是从0开始依次递增的整数;

因为 readfds 本质上是一个位图结构,且它是一个输入输出型参数:

  • 当 readfds 作输入型参数时:用户告诉内核,此时这个 readfds 位图结构,比特位的位置表示文件描述符的值,而比特位中的内容表示:用户是否关心读取事件;
  • 当 readfds 作输出型参数时:内核告诉用户,此时这个 readfds 位图结构,比特位的位置表示文件描述符的值,而比特位中的内容表示:读取事件是否就绪;

假设第一个比特位的代表的是0号文件描述符,比如:

可以看到,当处理读事件时,用户和内核都会使用同一个位图结构 (readfds),并进行修改,即这里的 readfds 是一个输入输出参数,也正因此,在一般情况下,这里的 readfds 使用完一次之后,用户必须对其进行重新设定,因为内核也会修改这个 readfds,writefds和exceptfds与readfds同理;

3.2. 如何看待 listensock 

对于一个服务器而言,它的第一个套接字一定是 listensock;

对于 listensock 而言:

  • 它的目的就是帮助服务器获取已经完成三次握手过程的连接,即新连接,而连接本质上是一个对象,即是一种数据,因此,服务器获取新连接,本质上就是在读取数据,即这个过程属于一个 Input 事件;
  • 同时,如果读事件就绪,就代表着底层连接已经建立完毕 (完成三次握手过程的新连接),上层就可以通过 listensock 调用 accept 获取这个新连接,并返回服务套接字;
  • 而对于其他的服务套接字而言,读事件就绪,就代表着可以进行拷贝数据了。

我们知道IO过程分两步:

  • 第一步:等待事件就绪;
  • 第二步:拷贝数据。

如果底层连接没有建立完毕,即没有完成三次握手过程,此时服务器直接调用 accept,那么服务器不就被阻塞了吗?

  1. 而对于我们要实现的这个 select 服务器而言,它可以不用创建多进程或者多线程,就可以为多个客户端进程提供服务,如果此时服务器阻塞了,那么不是尴尬了吗?
  2. 因此,对于 select 服务器而言,不可以直接调用 accept 获取新连接;
  3. 而应该是首先将监听套接字添加 (FD_SET) 到读事件的文件描述符集中,让 select 去检查读事件是否就绪 (对于监听套接字而言,读事件就绪就是底层连接建立完毕,即完成三次握手过程),只要 select 告诉服务器监听套接字的读事件就绪,此时上层再去调用 accept,就一定不会被阻塞。

3.3. 通用文件

下面是一些 select demo 所需要的小组件,例如我们之前自己封装的套接字接口 sock.hpp,还有我们的日志 Log.hpp,还包括日期转换 Date.hpp。

3.3.1. sock.hpp

套接字接口的相关封装。

#ifndef __SOCK_HPP_
#define __SOCK_HPP_
 
#include <iostream>
#include <cstring>
 
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
 
namespace Xq
{
  class Sock
  {
  public:
    Sock() :_sock(-1) {}
 
    // 创建套接字
    void Socket(void) 
    {
      _sock = socket(AF_INET, SOCK_STREAM, 0);
      if(_sock == -1)
      {
        exit(1);
      }
      // 设置套接字属性, 允许地址复用
      int optval = 1;
      setsockopt(_sock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &optval, sizeof(optval));
    }
 
    //  将该套接字与传入的地址信息绑定到一起
    void Bind(const std::string& ip, uint16_t port)
    {
      sockaddr_in addr;
      addr.sin_family = AF_INET;
      addr.sin_port = htons(port);
      addr.sin_addr.s_addr = ip.empty() ? INADDR_ANY : inet_addr(ip.c_str());
 
      int ret = bind(_sock, reinterpret_cast<const struct sockaddr*>(&addr), sizeof(addr));
      if(ret == -1)
      {
        exit(2);
      }
    }
 
    // 封装listen 将该套接字设置为监听状态
    void Listen(void)
    {
      int ret = listen(_sock, 10);
      if(ret == -1)
      {
        exit(3);
      }
    }
 
    //封装accept
    // 如果想获得客户端地址信息
    // 那么我们可以用输出型参数
    // 同时我们需要将服务套接字返回给上层
    int Accept(std::string& client_ip, uint16_t* port)
    {
      struct sockaddr_in client_addr;
      socklen_t addrlen = sizeof client_addr;
      bzero(&client_addr, addrlen);
 
      int server_sock = accept(_sock, \
          reinterpret_cast<struct sockaddr*>(&client_addr), &addrlen);
      if(server_sock == -1)
      {
        return -1;
      }
      // 将网络字节序的整数转为主机序列的字符串
      client_ip = inet_ntoa(client_addr.sin_addr);
      // 网络字节序 -> 主机字节序
      *port = ntohs(client_addr.sin_port);
      // 返回服务套接字
      return server_sock;
    }
 
    //  向特定服务端发起连接
    void Connect(struct sockaddr_in* addr, const socklen_t* addrlen)
    {
      int ret = connect(_sock,\
          reinterpret_cast<struct sockaddr*>(addr), \
          *addrlen);
      if(ret == -1)
     {
        perror("connect error");
      }
    }
 
    // 服务端结束时, 释放监听套接字
    ~Sock(void)
    {
      if(_sock != -1)
      {
        close(_sock);
      }
    }
 
  public:
    int _sock; // 套接字
  };
}
 
#endif

3.3.2. Log.hpp

日志组件;

#pragma once

#include "Date.hpp"
#include <iostream>
#include <map>
#include <string>
#include <cstdarg>

#define LOG_SIZE 1024

// 日志等级
enum Level
{
  DEBUG, // DEBUG信息
  NORMAL,  // 正常
  WARNING, // 警告
  ERROR, // 错误
  FATAL // 致命
};

const char* pathname = "./log.txt";

void LogMessage(int level, const char* format, ...)
{
// 如果想打印DUBUG信息, 那么需要定义DUBUG_SHOW (命令行定义, -D)
#ifndef DEBUG_SHOW
  if(level == DEBUG)
    return ;
#endif
  std::map<int, std::string> level_map;
  level_map[0] = "DEBUG";
  level_map[1] = "NORAML";
  level_map[2] = "WARNING";
  level_map[3] = "ERROR";
  level_map[4] = "FATAL";

  std::string info;
  va_list ap;
  va_start(ap, format);

  char stdbuffer[LOG_SIZE] = {0};  // 标准部分 (日志等级、日期、时间)
  snprintf(stdbuffer, LOG_SIZE, "[%s],[%s],[%s] ", level_map[level].c_str(), Xq::Date().get_date().c_str(),  Xq::Time().get_time().c_str());
  info += stdbuffer;

  char logbuffer[LOG_SIZE] = {0}; // 用户自定义部分
  vsnprintf(logbuffer, LOG_SIZE, format, ap);
  info += logbuffer;

  printf("%s\n",info.c_str());

  //FILE* fp = fopen(pathname, "a");
  //fprintf(fp, "%s", info.c_str());
  //fclose(fp);
  va_end(ap);
}

3.3.3. Date.hpp

小组件,就一个时间转化功能,也可以用时间戳代替;

#ifndef __DATE_HPP_
#define __DATE_HPP_

#include <iostream>
#include <ctime>

namespace Xq
{
  class Date
  {
  public:
    Date(size_t year = 1970, size_t month = 1, size_t day = 1)
      :_year(year)
       ,_month(month)
       ,_day(day)
      {}

    std::string& get_date()
    {
      size_t num = get_day();
      while(num--)
      {
        operator++();
      }
      char buffer[32] = {0};
      snprintf(buffer, 32, "%ld/%ld/%ld", _year,_month, _day);
      _data = buffer;
      return _data;
    }

  private:
    Date& operator++()
    {
      size_t cur_month_day = month_day[_month];
      if((_month == 2) && ((_year % 400 == 0 )|| (_year % 4 == 0 && _year % 100 != 0)))
        ++cur_month_day;
      ++_day;
      if(_day > cur_month_day)
      {
        _day = 1;
        _month++;
        if(_month > 12)
        {
          _month = 1;
          ++_year;
        }
      }
      return *this;
    }

   // 获得从1970.1.1 到 今天相差的天数
    size_t get_day()
    {
      return (time(nullptr) + 8 * 3600) / (24 * 60 * 60);
    }

  private:
    size_t _year;
    size_t _month;
    size_t _day;
    static int month_day[13];
    std::string _data;
  };

  int Date::month_day[13] = {
    0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31
  };

  class Time
  {

  public:
    Time(size_t hour = 0, size_t min = 0, size_t second = 0)
      :_hour(hour)
       ,_min(min)
       ,_second(second)
    {}

    std::string& get_time()
    {
      size_t second = time(nullptr) + 8 * 3600;
      _hour = get_hour(second);
      _min = get_min(second);
      _second = get_second(second);

      char buffer[32] = {0};
      snprintf(buffer, 32, "%ld:%ld:%ld", _hour, _min, _second);
      _time = buffer;
      return _time;
    }


  private:

    size_t get_hour(time_t second)
    {
      //  不足一天的剩余的秒数
      size_t verplus_second = second % (24 * 60 * 60);
      return verplus_second / (60 * 60);
    }

    size_t get_min(time_t second)
    {
      // 不足一小时的秒数
      size_t verplus_second = second % (24 * 60 * 60) % (60 * 60);
      return verplus_second / 60;
    }

    size_t get_second(time_t second)
    {
      // 不足一分钟的秒数
      return second % (24 * 60 * 60) % (60 * 60) %  60;
    }
    
  private:
    size_t _hour;
    size_t _min;
    size_t _second;
    std::string _time;
  };
}

#endif

3.4. select demo

有了上面的理解,我们就可以写出处理新连接的 select 服务器了,过程如下:

不过在这之前,还要说一下,关于 select 返回值的问题:

如果我们设置了超时时间,即 timeout 这个参数,比如 {5, 0}:

  • 如果select的返回值 = 0,time out,超时了;
  • 如果select的返回值 > 0,on success,返回文件描述符事件就绪的个数;
  • 如果select的返回值 < 0,on error,可以通过 error 查看错误原因;

select 代码如下:

#include "sock.hpp"
#include "Log.hpp"
#include "Date.hpp"


namespace Xq
{
  class Select
  {
  public:
    Select(const uint16_t port = 8080)
    {
      _port = port;
      _sock.Socket();
      _sock.Bind("", _port);
      _sock.Listen();

      LogMessage(DEBUG, "Select server init success");
    }

    void start()
    {
      // 监听套接字
      int listensock = _sock._sock;
      // 我们都知道, 监听套接字主要是用来获取新连接的
      // 而连接本质也是一个数据, 因此对于服务器而言
      // 获取新连接这件事情, 实际上就是一个Input事件(读事件)
      // 因此, 当新连接来了之后, 需要通过accept获取新连接, 并返回服务套接字
      // 可是, 如果新连接没来, 并且还调用了accept, 那么此时服务器就会被阻塞
      // 而我们要编写的select服务器就是一个单进程, 但它能够处理多个连接
      // 因此, select 服务器不能直接调用 accept 获取连接, 因为我们并不清楚
      // "新连接到来"这个事件是否就绪, 因此, 我们需要通过 select 系统调用
      // 让select去等新连接到来, 一旦新连接事件就绪, 上层在去调用 accept 获取连接
      // 此时就不会再被阻塞了.
      
      // 我们在这里以读事件为例
      fd_set readfd;
      while(true)
      {
        // 将readfd 文件描述符集清空, 全部置为0
        FD_ZERO(&readfd);
        // 将listensock 添加到这个读事件集(readfd)中
        FD_SET(listensock, &readfd);

        // 超时时间设置为 {5, 0};
        // 注意这里的time是一个输入输出型参数
        // 因此每次循环都要重新设置
        struct timeval time{5, 0};

        // 调用 select 检查套接字的事件是否就绪
        int fd_num = select(listensock + 1, &readfd, nullptr, nullptr, &time);
        if(fd_num == 0)
        {
          LogMessage(DEBUG, "time out");
        }
        else if(fd_num < 0)
        {
          LogMessage(ERROR, "error: %d, error message: %s", errno, strerror(errno));
          exit(1);
        }
        else 
        {
          // 一定有读事件就绪了, 上层可以来处理了
          LogMessage(DEBUG, "get a new link event, second: %d,mircrosecond: %d", time.tv_sec, time.tv_usec);
          // 处理该事件, 底层连接已就绪
          // 上层可以调用accept获取新链接, 并且不会被阻塞
          HandleEvent(readfd);
        }

      }

    }
  private: 

    void HandleEvent(const fd_set& fdset)
    {
      // 如果监听套接字在这个读事件文件描述符集中
      // 那么返回一个!0值
      if(FD_ISSET(_sock._sock, &fdset))
      {
        std::string client_ip;
        uint16_t client_port = 0;
        // 获取新连接
        // 此时accept一定不会被阻塞
        // 因为listen套接字的读事件就绪
        int serverfd = _sock.Accept(client_ip, &client_port);
        if(serverfd < 0)
        {
          LogMessage(ERROR, "accept error");
        }
        LogMessage(DEBUG, "get a new link, [%s:%d]: %d",\
            client_ip.c_str(), client_port, serverfd);
      }
    }

  private:
    uint16_t _port;
    Xq::Sock _sock;
  };
}

现象如下:

可以清楚的看到,我们现在可以获取新连接并得到服务套接字,那么问题来了,此时,能对这个服务套接字直接处理 (读数据,比如 read) 吗 ?

  1. 不能,因为IO = 等待事件就绪 + 拷贝数据,而此时服务器无法确定这个套接字底层数据是否就绪 (不清楚客户端究竟什么时候发数据,也不清楚数据什么时候到达服务器),如果直接 read或者recv ,那么服务器很有可能会被阻塞;
  2. 同样的思路,因为我们不清楚服务套接字的数据是否就绪,因此我们需要让 select 帮助我们检查数据是否就绪,故我们首先需要将服务套接字添加 (FD_SET) 到文件描述符集中,只要 select 告诉我们服务套接字的读事件 (底层是否有数据) 就绪,那么此时在去调用 read 或者 recv,此时就一定不会被阻塞

因此,现在的问题关键就在于,如何将得到的服务套接字添加到文件描述符集中呢?

只要解决了这个问题,我们的 select 服务器就水到渠成了。

可是我们发现,如果是上面的思路,就不太好将服务套接字添加到文件描述符集中,并且上面的 demo 是有问题的。

我们还是以读事件为例,即 readfds 文件描述符集,比如:

问题一:我们知道, select 第一个参数即 nfds,它代表着当前文件描述符集中的最大文件描述符值,而服务器会通过监听套接字获取越来越多的服务套接字,这些服务套接字也需要添加到 readfds 这个文件描述符集中,因此,注定了 nfds 的值是需要动态更新的,而我们上面如何处理的呢?

如下:

int fd_num = select(listensock + 1, &readfd, nullptr, nullptr, &time);

可以看到,我们是将 nfds 固定为了 listensock + 1,这很明显是不合理的,因为最大文件描述符的值是一直在变化的,这是第一个问题。

问题二:我们也知道, readfds、writefds、exceptfds,它们都是输入输出型参数,即用户和内核都会对它们进行修改,也就注定了输入输出时是不一样的,换言之,用户每次都需要更新 readfds (暂时只讨论读事件),可是,在上面的 demo 中,我们是如何处理的呢?

void start()
{
	// 监听套接字
	int listensock = _sock._sock;
	// 我们在这里以读事件为例
	fd_set readfd;
	while (true)
	{
		// 将readfd 文件描述符集清空, 全部置为0
		FD_ZERO(&readfd);
		// 将listensock 添加到这个读事件集(readfd)中
		FD_SET(listensock, &readfd);
        // 超时时间 {5, 0};
        struct timeval time{ 5, 0 };
		// 调用 select 检查套接字的事件是否就绪
		int fd_num = select(listensock + 1, &readfd, nullptr, nullptr, &time);	
        /* 省略... */
	}
}

可以看到,我们每次轮询时,在 readfds 这个文件描述符集中只重新设置了一个套接字,即监听套接字,那么如果用户关心了其他的服务套接字呢? 假如这个服务套接字的 Input 事件没有就绪,调一次 select,这个服务套接字在这个 readfds 文件描述符集中就消失了。

具体来讲:比如用户告诉内核,让内核关心一下 0 ~ 10 文件描述符的 Input 事件,但是调用select 时,在超时时间内,只有 6 号文件描述符的 Input 事件就绪,因此,此时的 readfds 文件描述符集中只有6号文件描述符了,其他都被内核置为0,而用户没有保存其他的套接字,并且,每次轮询时,readfds 文件描述符集都会被清空,因此下次 select 时,操作系统只会关心监听套接字 (6号文件描述符也消失了),这就出问题了。

因此,我们认为,用户必须在每一次 select 之前,需要对 readfds 文件描述符集进行重新设定。

可是,我们发现,如果用户仅仅记录一个 readfds 文件描述符集,根本无法解决上面的问题,因为用户自身就无法保证之前要关心的文件描述符是哪些。

因此,也就注定了,用户需要单独保存它所关心的文件描述符,以便来支持:

  • 更新文件描述符最大值, nfds;
  • 更新文件描述符集, readfds;

问题三, timeout 本质上也是一个输入输出型参数,因此每次轮询式都需要进行重置,当然,前提是你需要的话。

问题三并不是核心问题,重点是前两个问题。

有了上面的理解,我们才会提出 select 服务器的一般编写模式。

3.5. select 服务器的一般编写模式

select 服务器的一般编写模式:

需要有一个第三方数组,用来保存所有的用户所关心的文件描述符

每次轮询时, 需要在调用 select 之前,处理这两件事情:

  • 通过遍历该数组得到文件描述符最大值,更新 nfds;
  • 通过遍历该数组将所有用户关心的文件描述符添加到相应的文件描述符集中 。

调用 select 之后,处理就绪的IO事件,IO事件的处理方式因套接字不同而不同,如下:

  • 如果是监听套接字,获取新连接,accept,并将获取的服务套接字添加到这个第三方数组中;
  • 如果是服务套接字,拷贝数据,read/recv;
  • 在 read/recv 读取数据时,有一个细节,当对端关闭连接时,服务端需要 close 这个连接,并将相应的套接字在全局数组中去除掉即可。

数组相关处理细节:

  • 那么这个数组多大呢?
    • 数组的大小就是,一个文件描述符集所能同时检测的文件描述符的最大值;
  • 对数组进行初始化。
    • 最开始时,可以将数组的所有元素都设置为非法值,比如 -1;
  • 处理监听套接字。
    • 约定数组第一个元素就是监听套接字。

后续过程,就顺理成章了,还有就是,编码时可以适当的解耦,使代码的可读性高一点,我们的 select 服务器实现如下: 

#pragma once
#include "sock.hpp"
#include "Log.hpp"
#include "Date.hpp"

#define FD_NONE -1
#define NUM 1024

namespace Xq
{
  class Select
  {
  public:
    Select(const uint16_t port = 8080)
    {
      _port = port;
      _sock.Socket();
      _sock.Bind("", _port);
      _sock.Listen();

      // 初始化这个数组
      // 并约定: 0号下标 (即第一个元素) 是监听套接字
      for(size_t i = 0; i < NUM; ++i)
      {
        if(i == 0) _fd_array[i] = _sock._sock;
        else _fd_array[i] = FD_NONE;
      }

      LogMessage(DEBUG, "Select server init success");
    }

    void start()
    {
      // 读事件文件描述符集
      fd_set readfd;
      while(true)
      {
        // 将readfd 文件描述符集清空, 全部置为0
        FD_ZERO(&readfd);

        // 最大文件描述符
        int max_fd = _fd_array[0];

#ifdef DEBUG_SHOW
       debug_print();
#endif

        // 在调用select之前, 需要处理两件事情
        for(size_t i = 0; i < NUM; ++i)
        {
          // 用户不关心的文件描述符, 跳过
          if(_fd_array[i] == FD_NONE)
            continue;
          else
          {
            // thing 1: 将用户关心的文件描述符添加到readfd文件描述符集中
            FD_SET(_fd_array[i], &readfd);
            // thing 2: 更新最大文件描述符
            if(max_fd < _fd_array[i])
              max_fd = _fd_array[i];
          }
        }

        // 调用 select 检查套接字的事件是否就绪
        // 我们采用阻塞式的select
        int fd_num = select(max_fd + 1, &readfd, nullptr, nullptr, nullptr);
        if(fd_num == 0)
        {
          LogMessage(DEBUG, "time out");
        }
        else if(fd_num < 0)
        {
          LogMessage(ERROR, "error: %d, error message: %s", errno, strerror(errno));
          exit(1);
        }
        else 
        {
          // 一定有读事件就绪了, 上层可以来处理了
          LogMessage(DEBUG, "get a new IO event, ready file descriptor: %d", fd_num);
          // 处理这个新的IO事件 (也许是连接就绪的事件--- accept, 也许是底层数据就绪的事件 --- read/recv)
          HandleEvent(readfd);
        }
      }
    }
  private: 

    void HandleEvent(const fd_set& fdset)
    {

      // 遍历数组, 是谁的IO事件就绪了, 也许是监听套接字, 也许是服务套接字
      for(size_t i = 0; i < NUM; ++i)
      {
        // 用户不关心的文件描述符, 跳过
        if(_fd_array[i] == FD_NONE) continue;
        else
        {
          // 如果这个文件描述符在文件描述符集中, 返回!0值
          // 我们需要判断, 用户所关心的文件描述符的IO事件是否就绪
          // 如果 IO event 就绪, 那么这个文件描述符一定在读事件文件描述符集中
          // 如果 IO event 未就绪, 那么这个文件描述符一定不在这个读事件文件描述符集中
          if(FD_ISSET(_fd_array[i], &fdset))
          {
            // 之后, 用户还需要判断是监听套接字, 还是服务套接字呢?
            // 监听套接字, 获取新连接, accept
            if(_fd_array[i] == _sock._sock)
            {
              Accepter();
            }
            // 服务套接字, 拷贝数据, read/recv
            else
            {
              Reader(i);
            }
          }
          else
          {
#ifdef DEBUG_SHOW
            // 用户所关心的文件描述符的Input事件未就绪
            LogMessage(DEBUG, "%d file descriptor IO event not ready", _fd_array[i]);
#endif
          }
        }
      }
    }

    // 监听套接字的Input事件处理方式
    void Accepter()
    {
      std::string client_ip;
      uint16_t client_port = 0;
      // 获取新连接
      // 此时accept一定不会被阻塞
      // 因为listen套接字的读事件就绪
      int serverfd = _sock.Accept(client_ip, &client_port);
      if(serverfd < 0)
      {
        LogMessage(ERROR, "error: %d, error message: %s", errno, strerror(errno));
        return ;
      }
      LogMessage(DEBUG, "get a new link, [%s:%d]: %d",\
          client_ip.c_str(), client_port, serverfd);

      size_t pos = 1;
      for(; pos < NUM; ++pos)
      {
        if(_fd_array[pos] == FD_NONE) break;
      }

      // 走到这里, 也许是遇到了FD_NONE, break, 结束循环, 将服务套接字添加到文件描述符集中
      if(pos != NUM)
          _fd_array[pos] = serverfd;
      // 也许是这个数组已经满了,即 readfd 文件描述符集满了, 那么此时就不能再添加了
      else
      {
        LogMessage(WARNING,"readfds already full, close the new link: %d", serverfd);
        close(serverfd);
      }
      
    }

    // 服务套接字的Input事件处理方式
    void Reader(int pos)
    {
      char buffer[1024] = {0};
      // 因为此时这个服务套接字一定在文件描述符集中
      // 且这个文件描述符的Input事件一定是就绪的
      // 故此次 read/recv 都不会被阻塞
      ssize_t real_size = read(_fd_array[pos], buffer, sizeof buffer - 1);

      // error
      if(real_size < 0)
      {
        LogMessage(ERROR, "errno: %d, errno message: %s", errno, strerror(errno));
        // 1. 先close相应的文件描述符
        close(_fd_array[pos]);
        // 2. 并将这个文件描述符从数组中移除
        _fd_array[pos] = FD_NONE;
      }
      // client close link
      else if(real_size == 0)
      {
        // 因为客户端主动关闭连接, 体现在内核层面就是
        // 客户端主动向服务端发起4次挥手, 对于服务端而言也是一个Input事件
        // 但此时没有正式通信数据, 故调用 read 并返回0, 代表着客户端断开连接
        LogMessage(DEBUG, "client close link, me too..., fd: %d", _fd_array[pos]);
        // 1. 先close相应的文件描述符
        close(_fd_array[pos]);
        // 2. 并将这个文件描述符从数组中移除
        _fd_array[pos] = FD_NONE;
        // done
      }
      // on success
      else
      {
        buffer[real_size - 1] = 0;
        // 请问这里有问题吗?
        // 答案有的, 因为TCP是面向字节流的
        // 因此, 我们直接read根本就无法保证读到的是一个完整报文
        // 故, 我们必须要定制协议
        // 具体实现, 我们在epoll解决
        LogMessage(DEBUG, "client[%d] echo$ %s", _fd_array[pos], buffer);
        LogMessage(DEBUG, "server get a message of client success");
      }
    }

    void debug_print()
    {
      std::cout << "file descriptor: ";
      for(const auto& v : _fd_array)
      {
        if(v != FD_NONE) std::cout << v << " ";
      }
      std::cout << "\n";
    }


  private:
    uint16_t _port;
    Xq::Sock _sock;
    int _fd_array[NUM];
  };
}

3.6. select 的总结

select 的优点:

  • IO效率高:因为 select 服务器可以一次性等待多个套接字就绪,而IO过程 = 等待事件就绪 + 拷贝数据,而 select 会将多个套接字的等待时间进行重叠,换言之,在单位时间内,select 服务器等待的比重是比较低的,因此,它的IO效率就高;
  • 有适合 select 的应用场景:当有大量的连接,但只有少量连接是活跃的。因为 select 服务器是单进程的,因此,对于 select 服务器的维护成本非常低 (不需要维护过多的执行流),哪怕有非常多的连接,select 服务器的成本也微乎其微,即节省资源。

事实上,任何一个多路转接方案 (poll()、epoll()),上面的优点都具备。

select 的缺点:

  • 因为 select 服务器需要维护一个第三方数组,因此,select 服务器会充斥着大量的遍历操作 (时间复杂度O(N));
  • 我们知道 fd_set 是一个固定大小的位图,因此也就决定了 select 服务器所能监测的文件描述符的数量是有上限的;
  • 除开第一个参数,剩下的后四个参数,都是输入输出型参数,每调用一次 select,用户需要对这些参数进行重新设定;
  • 同时,也因为它们是输入输出型参数,即内核和用户都需要对其进行修改,因此,select 会进行频繁的用户到内核,内核到用户的参数数据拷贝;
  • 上面几个问题,也间接导致了 select 服务器的编码比较复杂。

也正因为 select 服务器有上面的缺点,故设计者们在此基础之上,又提出了新的方案,请看下篇文章 IO多路转接之poll

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值