【网络】高级IO——select版本TCP服务器

目录

前言 

一,select函数

1.1.参数一:nfds

1.2.参数二: readfds, writefds, exceptfds

1.2.1.fd_set类型和相关操作宏

1.2.2.readfds, writefds, exceptfds

1.2.3.怎么理解 readfds, writefds, exceptfds是输入输出型参数

1.3.参数三: timeout

1.3.1.timeval结构体

1.3.1.timeout参数的设定

1.4.返回值 

1.5.select的工作流程

 二,select版TCP服务器

2.1.编写准备

2.2.SelectServer.hpp的编写

2.2.1.为什么要设置辅助数组

2.2.2.select的优缺点 

 2.3.源代码


前言 

我们今天要讲的select,select的原理就像下面的赵六一样。

        赵六去钓鱼,他是个小土豪,赵六手里拿了一堆鱼竿,目测有几百根鱼竿,赵六到达河边,首先就把几百根鱼竿每隔几米插上去,总共插了好几百米的鱼竿,然后赵六就依次遍历这些鱼竿,哪个鱼竿上的鱼漂动了,赵六就把这根鱼竿上的鱼钓上来,然后接下来赵六就又继续重复之前遍历鱼竿的动作进行钓鱼了。

一,select函数

        select是我们学习的第一个多路转接IO接口,我们知道IO是由等待和拷贝两部分组成的。select只负责IO过程中等待的这一步,也就是说,用户可能关心一些sock上的读事件,想要从sock中读取数据,直接读取,可能recv调用会阻塞,等待数据到来,而此时服务器进程就会被阻塞挂起,但服务器挂起就完蛋了,服务器就无法给客户提供服务,可能会产生很多无法预料的不好影响,万一客户正转账呢,服务器突然挂起了,客户的钱没了,但商家这里又没有收到钱,客户找谁说理去啊,所以服务器挂起是一个问题,我们要避免产生这样的问题。

select函数是I/O多路复用的经典实现,其基本原型如下:

select函数的功能

        select的作用就是帮用户关心sock上的读事件,等sock中有数据时,select此时会返回,告知用户你所关心的sock上的读事件已经就绪了,用户你可以调用recv读取sock中的数据了!所以多路转接其实是把IO的过程分开来执行了,用多路复用接口来监视fd上的事件是否就绪,一旦就绪就会立马通知上层,让上层调用对应的接口进行数据的处理,等待和数据拷贝的工作分开执行,这样的IO效率一定是高的,因为像select这样的多路转接接口,一次能够等待多个fd,在返回时,它可以把多个fd中所有就绪的fd全部返回并通知给上层。

        select()函数允许程序监视多个文件描述符,等待所监视的一个或者多个文件描述符变为“准备好”的状态。所谓的”准备好“状态是指:文件描述符不再是阻塞状态,可以用于某类IO操作了,包括可读,可写,发生异常三种。

我们使用select来监视文件描述符时,要向内核传递的信息包括:

  • ​ 1、我们要监视的文件描述符个数
  • ​ 2、每个文件描述符,我们可以监视它的一种或多种状态,包括:可读,可写,发生异常三种。
  • ​ 3、要等待的时间,监视是一个过程,我们希望内核监视多长时间,然后返回给我们监视结果呢?
  • ​ 4、监视结果包括:准备好了的文件描述符个数,对于读,写,异常,分别是哪儿个文件描述符准备好了。

 参数详解

1.1.参数一:nfds

  • nfds: 这个参数是监控的文件描述符集合中最大文件描述符的值加1。在使用select函数时,必须确保这个参数正确设置,以便函数能监视所有相关的文件描述符。

比如说我们的文件描述符有0,1,2,3,4,5,如果我们想要监视所有的文件描述符,我们这个nfds参数就该填6,也就是5+1.

        当程序运行时,程序其实会在select这里进行等待,遍历一次底层的多个fd,看其中哪个fd就绪了,然后就将就绪的fd返回给上层,select的第一个参数nfds代表监视的fd中最大的fd值+1,其实就是select在底层需要遍历所有监视的fd,而这个nfds参数其实就是告知select底层遍历的范围是多大

1.2.参数二: readfds, writefds, exceptfds

1.2.1.fd_set类型和相关操作宏

fd_set是一个通过位图来管理文件描述符集合的数据结构,它允许高效地测试和修改集合中的成员。

  • fd_set类型本质是一个位图,位图的位置 表示 相对应的文件描述符,内容表示该文件描述符是否有效,1代表该位置的文件描述符有效,0则表示该位置的文件描述符无效。
  • 如果将文件描述符2,3设置位图当中,则位图表示的是为1100。
  • fd_set的上限是1024个文件描述符。

由于文件描述符是整数,且通常范围有限(尤其是在UNIX和类UNIX系统中),因此使用位图来表示它们是一种非常有效的空间和时间优化方法。

  1. FD_SET(fd, &set):此宏将文件描述符fd添加到set集合中。它实际上是将set中与fd对应的位设置为1。
  2. FD_CLR(fd, &set):此宏从set集合中移除文件描述符fd。它实际上是将set中与fd对应的位清零。
  3. FD_ISSET(fd, &set):此宏检查文件描述符fd是否已经被加入到set集合中。如果set中与fd对应的位为1,则返回非零值(真),否则返回0(假)。
  4. FD_ZERO(&set):此宏用于清空set集合中的所有文件描述符,即将集合中的所有位都设置为0。这是在使用set之前的一个好习惯,以确保集合从一个已知的状态开始。

我们看个例子

#include <stdio.h>  
#include <stdlib.h>  
#include <unistd.h>  
#include <sys/select.h>  
#include <sys/types.h>  
#include <sys/socket.h>  
  
int main() {  
    // 假设fd是一个已经打开的文件描述符,这里我们用socket作为示例  
    int fd = socket(AF_INET, SOCK_STREAM, 0); // 创建一个socket,实际使用中需要设置地址并连接  
    if (fd == -1) {  
        perror("socket");  
        exit(EXIT_FAILURE);  
    }  
  
    // 创建一个文件描述符集合  
    fd_set readfds;  
  
    // 清空集合  
    FD_ZERO(&readfds);  
  
    // 将文件描述符fd添加到集合中  
    FD_SET(fd, &readfds);  
  
    // 假设我们想要等待这个fd变得可读,最长等待时间为5秒  
    struct timeval tv;  
    tv.tv_sec = 5; // 秒  
    tv.tv_usec = 0; // 微秒  
  
    // 使用select等待文件描述符变得可读  
    int ret = select(fd + 1, &readfds, NULL, NULL, &tv);  
    if (ret == -1) {  
        perror("select");  
        close(fd); // 不要忘记关闭文件描述符  
        exit(EXIT_FAILURE);  
    } else if (ret == 0) {  
        printf("Timeout occurred! No data after 5 seconds.\n");  
        close(fd); // 即使没有数据,也要关闭文件描述符  
    } else {  
        // 检查fd是否就绪  
        if (FD_ISSET(fd, &readfds)) {  
            printf("Data is available now on fd %d.\n", fd);  
            // 在这里处理数据,例如使用read()函数读取数据  
  
            // 假设处理完数据后,我们不再需要等待这个fd  
            // 可以在这里调用FD_CLR来从集合中移除它,但在这个简单的例子中我们直接关闭它  
            close(fd);  
        }  
    }  
  
    return 0;  
}

1.2.2.readfds, writefds, exceptfds

这三个参数都是输入输出型参数

readfds, writefds, exceptfds: 这三个参数分别代表读、写和异常监视的文件描述符集合。它们使用fd_set类型表示,这是一种通过位图来管理文件描述符的数据结构。

 readfds

  1. readfds:这是一个指向fd_set的指针,用于指定程序关心的、希望进行读操作的文件描述符集合。如果select函数返回时,某个文件描述符在该集合中被标记为就绪(即可以进行无阻塞的读操作),则可以通过FD_ISSET宏来检查。
  2. readfds是 等待读事件的文件描述符集合,.如果不关心读事件(缓冲区有数据),则可以传NULL值。
  3. 应用进程和内核都可以设置readfds,应用进程设置readfds是为了通知内核去等待readfds中的文件描述符的读事件,而内核设置readfds是为了告诉应用进程哪些读事件生效

 writefds

  1. writefds:同样是一个指向fd_set的指针,但这次它用于指定程序希望进行写操作的文件描述符集合。如果select返回时某个文件描述符在该集合中被标记为就绪(即可以进行无阻塞的写操作),则同样可以通过FD_ISSET宏来检查。
  2. 与readfds类似,writefds是等待写事件(缓冲区中是否有空间)的集合,如果不关心写事件,则可以传值NULL。

exceptfds

  1. exceptfds:这个参数也是指向fd_set的指针,用于指定程序希望监视异常条件的文件描述符集合。这里的“异常”通常指的是网络套接字上的带外数据(out-of-band data)到达,或者其他一些非标准的I/O事件。
  2. 如果内核等待相应的文件描述符发生异常,则将失败的文件描述符设置进exceptfds中,如果不关心错误事件,可以传值NULL。​​​​

使用注意事项

  1. 在调用select之前,必须正确地使用FD_ZERO、FD_SET、FD_CLR等宏来初始化和修改readfds、writefds、exceptfds这三个集合。
  2. nfds参数的值应该设置为这三个集合中最大文件描述符值加1,以确保select能够正确地监视所有相关的文件描述符。
  3. select函数会阻塞调用它的线程(或进程),直到以下条件之一发生:
  • 有一个或多个文件描述符在readfds集合中变得可读。
  • 有一个或多个文件描述符在writefds集合中变得可写。
  • 有一个或多个文件描述符在exceptfds集合中发生了异常条件。
  • 超时时间到达(如果timeout参数非NULL且指定了超时时间)。

select函数返回后,应该使用FD_ISSET宏来检查哪些文件描述符已经就绪,并据此执行相应的I/O操作。

1.2.3.怎么理解 readfds, writefds, exceptfds是输入输出型参数

  1. 输入方面
    • 在调用 select 之前,调用者会设置这三个参数指向的 fd_set 集合,以指定哪些文件描述符(fd)是调用者感兴趣的。具体来说,readfds 集合包含了调用者想要检查是否有数据可读的文件描述符,writefds 集合包含了调用者想要检查是否可以写入数据的文件描述符,而 exceptfds 集合则包含了调用者想要检查是否有异常条件(如带外数据、连接挂断等)的文件描述符。
  2. 输出影响方面
    • 当 select 调用返回时,这三个集合会被 select 函数内部修改,以反映哪些文件描述符在调用期间变得就绪或遇到异常条件。具体来说,如果某个文件描述符在 select 等待期间变得可读、可写或出现异常,那么相应的集合中的该文件描述符的位将被设置(如果它之前没有被设置的话)。但是,这并不意味着 select 在这些集合中添加了新的文件描述符或移除了原有的文件描述符;它只是在修改集合中文件描述符的“就绪”状态位。

1.3.参数三: timeout

1.3.1.timeval结构体

        struct timeval 是一个在多种编程环境中,尤其是在 UNIX 和类 UNIX 系统(包括 Linux)的 C 语言标准库中定义的结构体,用于表示时间间隔或时间点。他的定义如下

struct timeval {
    long tv_sec;  // seconds
    long tv_usec; // microseconds
};

        它通常与需要精确到微秒(microseconds)的时间操作的函数一起使用,比如 select(), gettimeofday(), setitimer(), 和 utimes() 等。

这个结构体包含两个成员:

  1. long tv_sec;:这个成员表示自 Unix 纪元(即 1970 年 1 月 1 日 00:00:00 UTC)以来的秒数。它是一个长整型(long),通常可以存储非常大的数,足以表示从 Unix 纪元到现在的时间(以秒为单位)。
  2. long tv_usec;:这个成员表示秒之后的微秒数。它也是一个长整型(long),但用于存储 0 到 999999 之间的值,表示在 tv_sec 所表示的秒之后,再过去多少微秒。

这两个成员结合起来,就可以精确地表示一个时间点或时间间隔,精确到微秒级别。

例如,如果你想要表示一个从 Unix 纪元开始算起,经过了 123 秒又 456789 微秒的时间点,你可以这样设置 struct timeval 结构体:

 struct timeval time;  
 time.tv_sec = 123;  
 time.tv_usec = 456789; 

这个结构体经常与 gettimeofday() 函数一起使用,以获取当前时间(从 Unix 纪元开始的时间,精确到微秒)。例如:

 #include <sys/time.h>  
 #include <stdio.h>  
   int main() {  
 struct timeval now;  
 gettimeofday(&now, NULL);  
 printf("Current time: %ld.%06ld\n", now.tv_sec, now.tv_usec);  
 return 0;  
 } 

这段代码会输出当前的时间,格式为秒数和微秒数(微秒数前面补零至6位)。

1.3.1.timeout参数的设定

这是一个输入型参数!!

  • timeout: 这是一个指向timeval结构的指针,该结构用于设定select等待I/O事件的超时时间。结构定义如下:

timeout的设定有三种情况:

1.当timeout为NULL时,select会无限等待,直到至少有一个文件描述符就绪。

fd_set fds;  
FD_ZERO(&fds);  
FD_SET(0, &fds); // 假设监听标准输入  
int ret = select(1, &fds, NULL, NULL, NULL); // 无限期等待  
// 检查ret和fds...

2.当timeout设置为0时(即tv_sec和tv_usec都为0),select会立即返回,用于轮询。这个就是非阻塞轮询。

struct timeval tv = {0, 0};  
fd_set fds;  
FD_ZERO(&fds);  
FD_SET(0, &fds); // 假设监听标准输入  
int ret = select(1, &fds, NULL, NULL, &tv); // 立即返回  
// 检查ret和fds...

3.设置具体的时间,select将等待直到该时间过去或者有文件描述符就绪。

struct timeval tv = {2, 500000}; // 2秒500毫秒  
fd_set fds;  
FD_ZERO(&fds);  
FD_SET(0, &fds); // 假设监听标准输入  
int ret = select(1, &fds, NULL, NULL, &tv); // 等待2.5秒或直到文件描述符就绪  
// 检查ret和fds...

1.4.返回值 

select函数的返回值有三种可能:

  1. 大于0:返回值表示就绪的文件描述符数量,即有多少文件描述符已经准备好进行I/O操作。
  2. 等于0:表示超时,没有文件描述符在指定时间内就绪。
  3. 小于0:发生错误。错误发生时,应使用perror或strerror函数来获取具体的错误信息。

1.5.select的工作流程

        应用进程内核都需要从readfds和writefds获取信息,其中,内核需要从readfds和writefds知道哪些文件描述符需要等待,应用进程需要从readfds和writefds中知道哪些文件描述符的事件就绪.

        如果我们要不断轮询等待文件描述符,则应用进程需要不断的重新设置readfds和writefds因为每一次调用select,内核会修改readfds和writefds,所以我们需要在 应用程序 中 设置一个数组 来保存程序需要等待的文件描述符,保证调用 select 的时候readfds 和 writefds中的将如下:

 二,select版TCP服务器

接下来我们将用select来重新编写一下我们的TCP服务器。

2.1.编写准备

还记得TCP服务器怎么写吗?

为了节约我们的时间,我们复制一下我们之前封装好的Socket.hpp

Socket.hpp

#pragma once  
  
#include <iostream>  
#include <string>  
#include <unistd.h>  
#include <cstring>  
#include <sys/types.h>  
#include <sys/stat.h>  
#include <sys/socket.h>  
#include <arpa/inet.h>  
#include <netinet/in.h>  
 
  
// 定义一些错误代码  
enum  
{  
    SocketErr = 2,    // 套接字创建错误  
    BindErr,          // 绑定错误  
    ListenErr,        // 监听错误  
};  
  
// 监听队列的长度  
const int backlog = 10;  
  
class Sock  //服务器专门使用
{  
public:  
    Sock() : sockfd_(-1) // 初始化时,将sockfd_设为-1,表示未初始化的套接字  
    {  
    }  
    ~Sock()  
    {  
        // 析构函数中可以关闭套接字,但这里选择不在析构函数中关闭,因为有时需要手动管理资源  
    }  
  
    // 创建套接字  
    void Socket()  
    {  
        sockfd_ = socket(AF_INET, SOCK_STREAM, 0);  
        if (sockfd_ < 0)  
        {  
            printf("socket error, %s: %d", strerror(errno), errno); //错误  
            exit(SocketErr); // 发生错误时退出程序  
        } 
        int opt=1;
        setsockopt(sockfd_,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt)); //关闭后快速重启
    }  
  
    // 将套接字绑定到指定的端口上  
    void Bind(uint16_t port)  
    {  
        //让服务器绑定IP地址与端口号
        struct sockaddr_in local;  
        memset(&local, 0, sizeof(local));//清零  
        local.sin_family = AF_INET;  // 网络
        local.sin_port = htons(port);  // 我设置为默认绑定任意可用IP地址
        local.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口  
  
        if (bind(sockfd_, (struct sockaddr *)&local, sizeof(local)) < 0)  //让自己绑定别人
        {  
            printf("bind error, %s: %d", strerror(errno), errno);  
            exit(BindErr);  
        }  
    }  
  
    // 监听端口上的连接请求  
    void Listen()  
    {  
        if (listen(sockfd_, backlog) < 0)  
        {  
            printf("listen error, %s: %d", strerror(errno), errno);  
            exit(ListenErr);  
        }  
    }  
  
    // 接受一个连接请求  
    int Accept(std::string *clientip, uint16_t *clientport)  
    {  
        struct sockaddr_in peer;  
        socklen_t len = sizeof(peer);  
        int newfd = accept(sockfd_, (struct sockaddr*)&peer, &len);  
        
        if(newfd < 0)  
        {  
            printf("accept error, %s: %d", strerror(errno), errno);  
            return -1;  
        }  
        
        char ipstr[64];  
        inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));  
        *clientip = ipstr;  
        *clientport = ntohs(peer.sin_port);  
  
        return newfd; // 返回新的套接字文件描述符  
    }  
  
    // 连接到指定的IP和端口——客户端才会用的  
    bool Connect(const std::string &ip, const uint16_t &port)  
    {  
        struct sockaddr_in peer;//服务器的信息  
        memset(&peer, 0, sizeof(peer));  
        peer.sin_family = AF_INET;  
        peer.sin_port = htons(port);
 
        inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr));  
  
        int n = connect(sockfd_, (struct sockaddr*)&peer, sizeof(peer));  
        if(n == -1)   
        {  
            std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;  
            return false;  
        }  
        return true;  
    }  
  
    // 关闭套接字  
    void Close()  
    {  
        close(sockfd_);  
    }  
  
    // 获取套接字的文件描述符  
    int Fd()  
    {  
        return sockfd_;  
    }  
  
private:  
    int sockfd_; // 套接字文件描述符  
};

首先我们要创建一个SelectServer.hpp,main.cc,makefile

makefile 

select_server:main.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -rf select_server

SelectServer.hpp

#pragma once
#include<iostream>
#include"Socket.hpp"

const uint16_t default_port = 8877;       // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IP

class SelectServer
{
public:
    SelectServer(const uint16_t port = default_port, const std::string ip = default_ip)
        : ip_(ip), port_(port)
    {
    }
    ~SelectServer()
    {
        listensock_.Close();
    }

     bool Init()
    {
        listensock_.Socket();
        listensock_.Bind(port_);
        listensock_.Listen();     

        return true;   
    }

    void Start()
    {

    }

private:
    uint16_t port_;//绑定的端口号
    Sock listensock_;//专门用来listen的
    std::string ip_;  // ip地址
};

main.cc

#include"SelectServer.hpp"
#include<memory>

int main()
{
    std::unique_ptr<SelectServer> svr(new SelectServer());
    svr->Init();
    svr->Start();
}

2.2.SelectServer.hpp的编写

 接下来我们就只剩下

class SelectServer{

void Start()
{
    }
};

没有编写了。

我们可以看看我们之前编写的TCP服务器是怎么编写的。

void Start()
    {
        while(true)
        {
            std::string clientip;
            uint16_t clientport;
 
            int sockfd=listensock_.Accept(&clientip,&clientport);//这里会返回一个新的套接字
            if(socket<0) 
                continue;
 
            //提供服务
            if(fork()==0)
            {
                listensock_.Close();
                //通过sockfd使用提供服务
        
                 std::string inbuf;
                while (1)
                {
                    
                    char buf[1024];
                    // 1.读取客户端发送的信息
                    ssize_t s = read(sockfd, buf, sizeof(buf) - 1);
                    if (s == 0)
                    { // s == 0代表对方发送了空消息,视作客户端主动退出
                        printf("client quit: %s[%d]", clientip.c_str(), clientport);
                        break;
                    }
                    else if (s < 0)
                    {
                        // 出现了读取错误,打印错误后断开连接
                        printf("read err: %s[%d] = %s", clientip.c_str(), clientport, strerror(errno));
                        break;
                    }
                    else // 2.读取成功
                    {
 
                        
                    }
                }
 
                exit(0);//子进程退出
            }
 
            close(sockfd);//
        }
    }

我们发现,我们首先进行的就是accept啊!!那我们这里能不能里面进行accept呢?答案是不能的。accept本质就是检测并建立listen上面有没有新连接的到来。

还记得我们最开始讲的例子吗?

        赵六去钓鱼,他是个小土豪,赵六手里拿了一堆鱼竿,目测有几百根鱼竿,赵六到达河边,首先就把几百根鱼竿每隔几米插上去,总共插了好几百米的鱼竿,然后赵六就依次遍历这些鱼竿,哪个鱼竿上的鱼漂动了,赵六就把这根鱼竿上的鱼钓上来,然后接下来赵六就又继续重复之前遍历鱼竿的动作进行钓鱼了。

这个新链接就是鱼啊!!!新连接的到来就相当于鱼咬钩了。所以我们处理新连接的时候就得采用IO多路复用思想。

        如果是一个select服务器进程,则服务器进程会不断的接收有新链接,每个链接对应一个文件描述符如果想要我们的服务器能够同时等待多个链接的数据的到来,我们监听套接字listen_sock读取新链接的时候,我们需要将新链接的文件描述符保存到read_arrys数组中,下次轮询检测的就会将新链接的文件描述符设置进readfds中,如果有链接关闭,则将相对应的文件描述符从read_arrys数组中拿走。

一张图看懂select服务器:

按照上面的思路,我们暂且写出了下面这个

SelectServer.hpp 

#pragma once
#include<iostream>
#include"Socket.hpp"
#include<sys/select.h>
#include<sys/time.h>

const uint16_t default_port = 8877;       // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IP

class SelectServer
{
public:
    SelectServer(const uint16_t port = default_port, const std::string ip = default_ip)
        : ip_(ip), port_(port)
    {
    }
    ~SelectServer()
    {
        listensock_.Close();
    }

     bool Init()
    {
        listensock_.Socket();
        listensock_.Bind(port_);
        listensock_.Listen();     

        return true;   
    }

    void Start()
    {
        int listensock=listensock_.Fd();
        struct timeval timeout ={5,0};
        for(;;)
        {
            fd_set rfds;

            FD_ZERO(&rfds);
            FD_SET(listensock,&rfds);

            int n=select(listensock+1,&rfds,NULL,NULL,&timeout);//刚开始的时候只有1个连接啊!!!
            switch(n)
            {
                case 0:
                    std::cout<<"time out"<<std::endl;
                    break;
                case -1:
                    std::cout<<"select error"<<std::endl;
                    break;
                default:
                    //有事件就绪
                    break;
            }
        }

    }

private:
    uint16_t port_;//绑定的端口号
    Sock listensock_;//专门用来listen的
    std::string ip_;  // ip地址
};

这里需要补充一些知识:

当一个新的连接请求到达监听套接字时,操作系统会接受这个请求(但不在用户空间的应用程序中立即处理它),并将监听套接字的状态标记为可读。这是因为从技术上讲,新的连接请求会导致监听套接字上有一些数据可读——具体来说,是新的连接的信息(例如,客户端的地址和端口号),这些信息将用于后续的 accept 调用。

 当一个新的连接请求到达监听套接字时,操作系统会在底层进行一系列的操作来处理这个请求,并使得在用户空间的应用程序能够检测到这个新连接的存在。这个过程涉及到TCP/IP协议栈的多个层次,但我们可以从高层角度来简化地理解它。

  • 监听套接字上的“可读”数据

        当应用程序通过listen函数将套接字设置为监听状态时,它实际上是在告诉操作系统:“我准备好了,可以开始接受来自这个套接字的新连接了。”但是,listen函数本身并不涉及任何阻塞操作,它只是改变了套接字的状态。

        现在,当一个新的TCP连接请求(通常来自客户端的connect调用)到达时,操作系统会检查是否有相应的监听套接字在监听这个端口。如果有,操作系统会为该新连接创建一个新的套接字(通常称为“已连接套接字”或“子套接字”),并保存与该连接相关的所有信息,包括客户端的地址和端口号。

        然而,从用户空间的应用程序角度来看,这个新创建的套接字并不是直接可见的。相反,监听套接字上的“可读”状态会被触发,以指示有新的连接请求到来。这里的“可读”状态并不是说监听套接字本身有任何用户数据可读(尽管在某些上下文中,套接字被视为文件描述符,可以像文件一样读取数据),而是说它现在“准备好”被accept调用以接收新的连接。

  • accept 调用

        当select或类似的多路复用函数(如pollepoll)指示监听套接字上有数据可读时,应用程序就会知道有一个或多个新的连接请求正在等待被接受。此时,应用程序可以调用accept函数来尝试接受这些连接。

   accept函数会从监听套接字的“等待队列”中取出一个新的连接请求,并基于这个请求创建一个新的套接字(即已连接套接字)。这个新套接字包含了与客户端通信所需的所有信息,包括客户端的地址和端口号。然后,accept将这个新套接字的文件描述符返回给应用程序,以便它可以与客户端进行数据传输。

  • 总结

因此,从技术上讲,监听套接字上的“可读”状态并不是指套接字上有实际的数据可读,而是指有新的连接请求等待被accept函数处理。这种机制允许应用程序在多个连接请求同时到达时有效地管理它们,而无需为每个连接都创建一个单独的线程或进程。

 我们编译运行一下

我们看看

我们回去再看看我们运行情况 

 嗯?什么情况?为什么一直在打印time out?这个是因为timeout参数是个输入输出型参数

  1. 事实上,select函数后四个参数全部是输入输出型参数,兼具用户告诉内核 和 内核告诉用户消息的作用,
  2. 比如timeout参数,输入时,代表用户告知内核select监视等待fd时的方式,nullptr代表select阻塞等待fd就绪,当有fd就绪时,select才会返回,传0代表非阻塞等待fd就绪,即select只会遍历检测一遍底层的fd,不管有没有fd就绪,select都会返回,传大于0的值,代表在该时间范围内select阻塞等待,超出该时间select直接非阻塞返回。
  3. 假设你输入的timeout参数值为5s,如果在第3时select检测到有fd就绪并且返回时,内核会在select调用内部将timeout的值修改为2s,这就是输出型参数的作用,内核告知用户,timeout值为2s,select等待的时间为3s。
  4. 所以对应timeout参数,需要周期性的进行重新设置

 我们现在需要修改一下代码

SelectServer.hpp

 void Start()
    {
        int listensock=listensock_.Fd();
        
        for(;;)
        {
            fd_set rfds;

            FD_ZERO(&rfds);
            FD_SET(listensock,&rfds);

            struct timeval timeout ={5,0};//注意这里

            int n=select(listensock+1,&rfds,NULL,NULL,&timeout);//刚开始的时候只有1个连接啊!!!
            switch(n)
            {
                case 0:
                    std::cout<<"time out"<<std::endl;
                    break;
                case -1:
                    std::cout<<"select error"<<std::endl;
                    break;
                default:
                    //有事件就绪
                    break;
            }
        }

    }

 现在我们再去编译一下

现在就不会变了。 一直为5秒了。

我们可以把timeout参数设置为nullptr参数,这样子代表,会一直阻塞到有新连接到来.

 SelectServer.hpp

#pragma once
#include<iostream>
#include"Socket.hpp"
#include<sys/select.h>
#include<sys/time.h>

const uint16_t default_port = 8877;       // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IP

class SelectServer
{
public:
    SelectServer(const uint16_t port = default_port, const std::string ip = default_ip)
        : ip_(ip), port_(port)
    {
    }
    ~SelectServer()
    {
        listensock_.Close();
    }

     bool Init()
    {
        listensock_.Socket();
        listensock_.Bind(port_);
        listensock_.Listen();     

        return true;   
    }

    void Start()
    {
        int listensock=listensock_.Fd();
        
        for(;;)
        {
            fd_set rfds;

            FD_ZERO(&rfds);
            FD_SET(listensock,&rfds);

            int n=select(listensock+1,&rfds,NULL,NULL,nullptr);
            switch(n)
            {
                case 0:
                    std::cout<<"time out"<<std::endl;
                    break;
                case -1:
                    std::cout<<"select error"<<std::endl;
                    break;
                default:
                    //有事件就绪
                    std::cout<<"get a new link"<<std::endl;
                    break;
            }
        }

    }

private:
    uint16_t port_;//绑定的端口号
    Sock listensock_;//专门用来listen的
    std::string ip_;  // ip地址
};

 我们编译运行一下

我们发现程序怎么一直打印get a new link啊?这是因为我们没有把连接处理。

其实这个是select的特点:如果事件就绪,上层不处理的话,select会一直通知你!!!

如果select告诉我们就绪,接下来的一次读取,我们读取fd的时候,不会被阻塞

接下来我们就要来处理这个连接了!!!

        我们需要澄清一些细节,因为 select 函数本身并不直接“知道”一个监听套接字(listening socket)何时有新的连接请求。然而,它确实能够检测到在监听套接字上有数据可读,这通常意味着一个新的连接请求已经到达。

这里是如何工作的:

  1. 监听套接字:当你使用 socket 函数创建一个套接字,并用 bind 和 listen 函数将它设置为监听状态时,这个套接字就开始等待传入的连接请求(即客户端发起的连接)。但是,listen 函数本身并不阻塞,它只是将套接字设置为接受连接请求的状态。

  2. 使用 select 监视监听套接字:在你的代码中,你将监听套接字的文件描述符 listensock 添加到 select 调用的可读文件描述符集合中。select 函数将阻塞(除非指定了超时时间),直到以下任一情况发生:

    • 可读文件描述符集合中的某个文件描述符变得可读。
    • 可写文件描述符集合中的某个文件描述符变得可写(但在这个例子中没有使用)。
    • 异常文件描述符集合中的某个文件描述符有异常条件(同样,没有使用)。
    • 指定的超时时间到达(如果提供了超时时间)。
  3. 新的连接请求当一个新的连接请求到达监听套接字时,操作系统会接受这个请求(但不在用户空间的应用程序中立即处理它),并将监听套接字的状态标记为可读。这是因为从技术上讲,新的连接请求会导致监听套接字上有一些数据可读——具体来说,是新的连接的信息(例如,客户端的地址和端口号),这些信息将用于后续的 accept 调用。

  4. select 返回:由于监听套接字现在被标记为可读,select 函数会返回,并且返回值会大于 0(表示有文件描述符就绪)。然后,你可以通过 FD_ISSET(listensock, &rfds) 检查监听套接字是否确实在就绪的文件描述符集合中。

  5. 接受连接:如果 FD_ISSET(listensock, &rfds) 返回真,你就可以使用 accept 函数来接受这个新的连接请求。accept 函数将创建一个新的套接字来与客户端通信,并将监听套接字返回到等待新连接请求的状态。

所以,虽然 select 函数本身并不“知道”有新的连接请求,但它能够检测到监听套接字上何时有数据可读(这通常意味着有新的连接请求),并允许你的程序在适当的时候调用 accept 函数来接受这个连接。

为了让代码看起来更好看一点,我们将处理连接这部分封装起来。

   void HandlerEvent(fd_set& rfds)
    {
        // 检查监听套接字是否就绪 
        if(FD_ISSET(listensock_.Fd(),&rfds))
                // 监听套接字上有新的连接请求  
                // 调用accept来接受连接  
        {
            //我们的连接事件就绪了
            std::string clientip;
            uint16_t clientport;
 
            int sockfd=listensock_.Accept(&clientip,&clientport);//这里会返回一个新的套接字
            //请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
            if(sockfd<0)
                return;
        }
    }

注意:

  • FD_ISSET(fd, &set):此宏检查文件描述符fd是否已经被加入到set集合中。如果set中与fd对应的位为1,则返回非零值(真),否则返回0(假)。 

 现在有一个问题,

我们现在可不可以在后面使用read对socked直接进行读数据呢?

答案是不可以。!!!!!

为什么呢?

        因为我们一旦调用read,万一客户端没有发数据过来,服务器进程就会阻塞在read这里!!这样子就会导致HandlerEvent函数调用不会返回,继而导致Start函数的循环阻塞,无法调用select函数监视新加入的连接。

注意:socket函数返回的文件描述符和accept返回的文件描述符是不一样的。

 void HandlerEvent(fd_set& rfds)
    {
        //1.判断哪个读事件就绪
        if(FD_ISSET(listensock_.Fd(),&rfds))//
        {
            //我们的连接事件就绪了
            std::string clientip;
            uint16_t clientport;
 
            int sockfd=listensock_.Accept(&clientip,&clientport);//这里会返回一个新的套接字
            //请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
            if(sockfd<0)
                return;
            std::cout<<"accept's fd:"<<sockfd<<std::endl;
        }
    }

    void Start()
    {
        int listensock=listensock_.Fd();
         std::cout<<"socket's fd:"<<listensock<<std::endl;
        
        for(;;)
        {
            fd_set rfds;

            FD_ZERO(&rfds);
            FD_SET(listensock,&rfds);

            int n=select(listensock+1,&rfds,NULL,NULL,nullptr);//注意这里会修改rfds,返回的rfds有效位代表着哪个位置的有效事件就绪了

            switch(n)
            {
                case 0:
                    std::cout<<"time out"<<std::endl;
                    break;
                case -1:
                    std::cout<<"select error"<<std::endl;
                    break;
                default:
                    //有事件就绪
                    std::cout<<"get a new link"<<std::endl;
                    HandlerEvent(rfds);//处理事件
                    break;
            }
        }

我们使用telnet来测试一下

很明显了。

        我们发现服务器只有一个文件描述符是来监听新的连接的,接受新连接的时候是会有新的文件描述符用来进行网络通信的

我们发现

所以在accept函数后面我们不能直接调用read函数,而是将新连接加入到select中。

        可是我们发现,我们的select和我们的accept在不同的函数里面,我们怎么让select来设置我们的文件描述符呢?这个时候我们就要设置一个辅助数组了。

2.2.1.为什么要设置辅助数组

  • 原因一

我们不能在accept这里调用新的select

为什么?

  1. 一般都是在主循环处持续调用select,高效且简洁
  2. 如果使用多个select,会导致代码逻辑复杂化,也难以管理
  3. 所以,需要我们把这个新套接字的fd设置进刚才的select的位图中
  4. 这一过程就相当于在不断增加自己鱼竿的数量

但是,这两个数据在不同的函数中(我们在处理函数中获取新连接,而select的使用在主逻辑函数中),如何传递呢?

因为这两个函数都在类中,所以我们搞一个类内变量 -- 辅助数组

    int fds_[def_max_num]; // 辅助数组

这里补充一个知识点:fd_set有多少个比特位

 std::cout<<"fd_set:"<<sizeof(fd_set)*8<<std::endl;

由于fd_set每个比特位代表一个连接,fd_set有1024位比特位,所以最多可以同时处理1024个连接

  • 原因二

我们知道select的rfds参数是个输入输出型参数,而且应用进程和内核都可以设置readfds,应用进程设置readfds是为了通知内核去等待readfds中的文件描述符的读事件,而内核设置readfds是为了告诉应用进程哪些读事件生效,就像下面这样子。

也就是说每调用一次select函数,参数rfds会变化!!!但是再看看我们的代码,我们可是把select放在一个循环里面,这意味着会多次调用select,这样子下去,只有第一次调用select时监听的是我们想要监听的,后续的rfds都变化了,调用select就不是我们想要监听的了!!! 这就意味着,每调用一次select函数,就要重新设定一次rfds参数!!!

  • 原因三

我们select函数的第一个参数 nfds: 这个参数是监控的文件描述符集合中最大文件描述符的值加1。可是在我们的代码里面,我们却直接填了一个listensock+1,这就固定死了监听范围,可是我们是写服务器,当新连接到来的时候,会产生新的文件描述符,如果select的第一个参数不变的话,我们就不能监听到这些新的文件描述符了。

所以这个select的第一个参数要通过计算来进行动态设置!!!

 我们可以让新增的fd都添加进辅助数组中,然后让select每次动态设置max_fd,以及三个位图(新增操作在"处理函数"中介绍)

  • 可以固定监听套接字(也就是我们创建的第一个套接字)作为数组的第一项,方便我们后续区分[获取新连接] 和 [读写事件]。
  • 因为在过程中,可能会陆陆续续关掉一些文件(断开连接时),所以原本添加进的连续fd,会变成零零星星的,所以需要我们每次都重新整理一下这个数组,把有效的fd统一放在左侧,我们每次在循环开头就处理数组中的值,合法的fd就让它设置进位图中
  • 不仅如此,在这个过程中,我们还可以找到fd中的最大值,来填充select的第一个参数

接下来我们就修改一下Start函数 

void Start()
    {
        int listensock = listensock_.Fd();
        
        fd_arry[0] = listensock; // 将监听套接字加入辅助数组
        for (;;)
        {
            fd_set rfds;//每调用一次select函数rfds需要重新设定
            FD_ZERO(&rfds);

            int maxfd = fd_arry[0]; // 最大有效数组下标

            for (int i = 0; i < fd_num_max; ++i)
            {
                if (fd_arry[i] == default_fd)
                {
                    continue;
                }
                FD_SET(fd_arry[i], &rfds);
                //注意辅助数组第一个元素是listen套接字,所以最开始的时候一定会执行到这里

                if (maxfd<fd_arry[i])//如果有更大的文件描述符,就替换掉maxfd
                {
                    maxfd = fd_arry[i];
                }
            }

            int n = select(maxfd + 1, &rfds, NULL, NULL, nullptr); // 注意这里会修改rfds,返回的rfds有效位代表着哪个位置的有效事件就绪了

            switch (n)
            {
            case 0:
                std::cout << "time out" << std::endl;
                break;
            case -1:
                std::cout << "select error" << std::endl;
                break;
            default:
                // 有事件就绪
                std::cout << "get a new link" << std::endl;
                HandlerEvent(rfds); // 处理事件
                break;
            }
        }
    }

接下来需要修改一下我们的HandlerEvent函数,我们accept新连接后不能直接读取,会阻塞,我们需要将这个新连接加入我们的select函数的范围,这就需要我们借助辅助数组了

当我们识别到有事件就绪,获取连接后获得新套接字fd,之后就该将该fd设置进辅助数组中

  • 需要我们遍历数组,找到空位(值为-1/其他你设定的[数组内的初始值]),然后添加进去
  • 但是要注意位图还有没有空位置(别忘了位图是有上限的)
  • 所以,还需要加个判断

HandlerEvent函数

 void HandlerEvent(fd_set &rfds)
    {
        // 1.判断哪个读事件就绪
        if (FD_ISSET(listensock_.Fd(), &rfds)) //
        {
            // 我们的连接事件就绪了
            std::string clientip;
            uint16_t clientport;

            int sockfd = listensock_.Accept(&clientip, &clientport); // 这里会返回一个新的套接字
            // 请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
            if (sockfd <0)
                return;
            else // 把新fd加入位图
            {
                int i = 1;
                for (; i < fd_num_max; i++)//为什么从1开始,因为我们0号下标对应的是listen套接字,我们不要修改
                {
                    if (fd_arry[i] !=default_fd ) // 没找到空位
                    {
                        continue;;
                    }
                    else{//找到空位,但不能直接添加
                        break;
                    }
                }
                if (i != fd_num_max)//没有满
                {
                    fd_arry[i] = sockfd;//把新连接加入数组
                }
                else  // 满了
                { 
                    close(sockfd);//处理不了了,直接关闭连接吧
                }
            }
        }
    }

一旦有新连接的到来,我们就是只先把连接放到辅助数组里面。

为了方便大家观察,我们写一个测试函数。

  void Printfd()
    {
        std::cout<<"online fd list: ";
        for(int i=0;i<fd_num_max;i++)
        {
            if(fd_arry[i]==default_fd) continue;
            std::cout<<fd_arry[i]<<" ";
        }
        std::cout<<std::endl;
    }

 大家有没有发现,这个辅助数组里面的事件有两类啊!!!!就是[新连接]和[读写事件],如何区分fd集上的事件就绪究竟是[新连接]还是[读写事件]呢?

如何区分fd集上的事件就绪究竟是[新连接]还是[读写事件]呢?

  • 前面我们提到,将监听套接字固定在数组第一项,就是为了区分两者,所以写个判断语句就行

HandlerEvent函数

 void HandlerEvent(fd_set &rfds)
    {
        for (int n = 0; n < fd_num_max; n++)
        {
            int fd = fd_arry[n];
            if (fd == default_fd) // 无效的
                continue;

            if (FD_ISSET(fd, &rfds)) // fd套接字就绪了
            {

                // 1.是listen套接字就绪了
                if (fd == listensock_.Fd()) // 如果是listen套接字就绪了!!!
                {
                    // 我们的连接事件就绪了
                    std::string clientip;
                    uint16_t clientport;

                    int sockfd = listensock_.Accept(&clientip, &clientport); // 这里会返回一个新的套接字
                    // 请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
                    if (sockfd < 0)
                        continue;
                    else // 把新fd加入位图
                    {
                        int i = 1;
                        for (; i < fd_num_max; i++) // 为什么从1开始,因为我们0号下标对应的是listen套接字,我们不要修改
                        {
                            if (fd_arry[i] != default_fd) // 没找到空位
                            {
                                continue;
                            }
                            else
                            { // 找到空位,但不能直接添加
                                break;
                            }
                        }
                        if (i != fd_num_max) // 没有满
                        {
                            fd_arry[i] = sockfd; // 把新连接加入数组
                            Printfd();
                        }
                        else // 满了
                        {
                            close(sockfd); // 处理不了了,直接关闭连接吧
                        }
                    }
                }

                // 2.是通信的套接字就绪了,fd不是listen套接字
                else // 读事件
                {
                    char in_buff[1024];
                    int n = read(fd, in_buff, sizeof(in_buff) - 1);
                    if (n > 0)
                    {
                        in_buff[n] = 0;
                        std::cout << "get message: " << in_buff << std::endl;
                    }
                    else if (n == 0) // 客户端关闭连接
                    {
                        close(fd);//我服务器也要关闭
                        fd_arry[n] = default_fd; // 重置数组内的值
                    }
                    else
                    {
                        close(fd);//我服务器也要关闭
                        fd_arry[n] = default_fd; // 重置数组内的值
                    }
                }
               
            }
        }
    }

 我们做个实验

很完美啊

很好!!

我们也可以把处理过程单拎出来封装成两个函数

  • 就相当于我们把收到的事件根据类型不同,派发给不同的模块进行处理

HandlerEvent函数

   void Accept()
    {
        // 我们的连接事件就绪了
        std::string clientip;
        uint16_t clientport;

        int sockfd = listensock_.Accept(&clientip, &clientport); // 这里会返回一个新的套接字
        // 请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
        if (sockfd < 0)
            return;
        else // 把新fd加入位图
        {
            int i = 1;
            for (; i < fd_num_max; i++) // 为什么从1开始,因为我们0号下标对应的是listen套接字,我们不要修改
            {
                if (fd_arry[i] != default_fd) // 没找到空位
                {
                    continue;
                }
                else
                { // 找到空位,但不能直接添加
                    break;
                }
            }
            if (i != fd_num_max) // 没有满
            {
                fd_arry[i] = sockfd; // 把新连接加入数组
                Printfd();
            }
            else // 满了
            {
                close(sockfd); // 处理不了了,直接关闭连接吧
            }
        }
    }

    void Receiver(int fd, int i)
    {
        char in_buff[1024];
        int n = read(fd, in_buff, sizeof(in_buff) - 1);
        if (n > 0)
        {
            in_buff[n] = 0;
            std::cout << "get message: " << in_buff << std::endl;
        }
        else if (n == 0) // 客户端关闭连接
        {
            close(fd);               // 我服务器也要关闭
            fd_arry[i] = default_fd; // 重置数组内的值
        }
        else
        {
            close(fd);               // 我服务器也要关闭
            fd_arry[i] = default_fd; // 重置数组内的值
        }
    }

    void HandlerEvent(fd_set &rfds)
    {
        for (int n = 0; n < fd_num_max; n++)
        {
            int fd = fd_arry[n];
            if (fd == default_fd) // 无效的
                continue;

            if (FD_ISSET(fd, &rfds)) // fd套接字就绪了
            {
                // 1.是listen套接字就绪了
                if (fd == listensock_.Fd()) // 如果是listen套接字就绪了!!!
                {
                    Accept();
                }
                // 2.是通信的套接字就绪了,fd不是listen套接字
                else // 读事件
                {
                    Receiver(fd,n);
                }
            }
        }
    }

2.2.2.select的优缺点 

        select并不是多路转接中好的一个方案,当然这并不代表他是有问题的,只不过他用起来成本较高,要关注的点也比较多,所以我们说他并不是一个好的方案。

总的来说,select最重要的就是思维方式 -- 我们要将所有等待的过程都交给select

并且优缺点很明显

优点

  1. 确实实现了多路转接,可以等待多个fd
  2. 代码简单明了

缺点

  1. 比如select监视的fd是有上限的,我的云服务器内核版本下最大上限是1024个fd,主要还是因为fd_set他是一个固定大小的位图结构,位图中的数组开辟之后不会在变化了,这是内核的数据结构,除非你修改内核参数,否则不会在变化了,所以一旦select监视的fd数量超过1024,则select会报错。
  2. 除此之外,select大部分的参数都是输入输出型参数,用户和内核都会不断的修改这些参数的值,导致每次调用select前,都需要重新设置fd_set位图中的内容,这在用户层面上会带来很多不必要的遍历+拷贝的成本。
  3. 同时select还需要借助第三方数组来维护用户需要关心的fd,这也是select使用不方便的一种体现。而上面的这些问题,正是其他多路转接接口所存在的意义,poll解决了很多select接口存在的问题。

 2.3.源代码

Socket.hpp

#pragma once  
  
#include <iostream>  
#include <string>  
#include <unistd.h>  
#include <cstring>  
#include <sys/types.h>  
#include <sys/stat.h>  
#include <sys/socket.h>  
#include <arpa/inet.h>  
#include <netinet/in.h>  
 
  
// 定义一些错误代码  
enum  
{  
    SocketErr = 2,    // 套接字创建错误  
    BindErr,          // 绑定错误  
    ListenErr,        // 监听错误  
};  
  
// 监听队列的长度  
const int backlog = 10;  
  
class Sock  //服务器专门使用
{  
public:  
    Sock() : sockfd_(-1) // 初始化时,将sockfd_设为-1,表示未初始化的套接字  
    {  
    }  
    ~Sock()  
    {  
        // 析构函数中可以关闭套接字,但这里选择不在析构函数中关闭,因为有时需要手动管理资源  
    }  
  
    // 创建套接字  
    void Socket()  
    {  
        sockfd_ = socket(AF_INET, SOCK_STREAM, 0);  
        if (sockfd_ < 0)  
        {  
            printf("socket error, %s: %d", strerror(errno), errno); //错误  
            exit(SocketErr); // 发生错误时退出程序  
        } 
        int opt=1;
        setsockopt(sockfd_,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt)); //服务器主动关闭后快速重启
    }  
  
    // 将套接字绑定到指定的端口上  
    void Bind(uint16_t port)  
    {  
        //让服务器绑定IP地址与端口号
        struct sockaddr_in local;  
        memset(&local, 0, sizeof(local));//清零  
        local.sin_family = AF_INET;  // 网络
        local.sin_port = htons(port);  // 我设置为默认绑定任意可用IP地址
        local.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口  
  
        if (bind(sockfd_, (struct sockaddr *)&local, sizeof(local)) < 0)  //让自己绑定别人
        {  
            printf("bind error, %s: %d", strerror(errno), errno);  
            exit(BindErr);  
        }  
    }  
  
    // 监听端口上的连接请求  
    void Listen()  
    {  
        if (listen(sockfd_, backlog) < 0)  
        {  
            printf("listen error, %s: %d", strerror(errno), errno);  
            exit(ListenErr);  
        }  
    }  
  
    // 接受一个连接请求  
    int Accept(std::string *clientip, uint16_t *clientport)  
    {  
        struct sockaddr_in peer;  
        socklen_t len = sizeof(peer);  
        int newfd = accept(sockfd_, (struct sockaddr*)&peer, &len);  
        
        if(newfd < 0)  
        {  
            printf("accept error, %s: %d", strerror(errno), errno);  
            return -1;  
        }  
        
        char ipstr[64];  
        inet_ntop(AF_INET, &peer.sin_addr, ipstr, sizeof(ipstr));  
        *clientip = ipstr;  
        *clientport = ntohs(peer.sin_port);  
  
        return newfd; // 返回新的套接字文件描述符  
    }  
  
    // 连接到指定的IP和端口——客户端才会用的  
    bool Connect(const std::string &ip, const uint16_t &port)  
    {  
        struct sockaddr_in peer;//服务器的信息  
        memset(&peer, 0, sizeof(peer));  
        peer.sin_family = AF_INET;  
        peer.sin_port = htons(port);
 
        inet_pton(AF_INET, ip.c_str(), &(peer.sin_addr));  
  
        int n = connect(sockfd_, (struct sockaddr*)&peer, sizeof(peer));  
        if(n == -1)   
        {  
            std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;  
            return false;  
        }  
        return true;  
    }  
  
    // 关闭套接字  
    void Close()  
    {  
        close(sockfd_);  
    }  
  
    // 获取套接字的文件描述符  
    int Fd()  
    {  
        return sockfd_;  
    }  
  
private:  
    int sockfd_; // 套接字文件描述符  
};

SelectServer.hpp

#pragma once
#include <iostream>
#include "Socket.hpp"
#include <sys/select.h>
#include <sys/time.h>

const uint16_t default_port = 8877;       // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IP
const int fd_num_max = sizeof(fd_set) * 8;
const int default_fd = -1;

class SelectServer
{
public:
    SelectServer(const uint16_t port = default_port, const std::string ip = default_ip)
        : ip_(ip), port_(port)
    {
        for (int i = 0; i < fd_num_max; i++)
        {
            fd_arry[i] = -1; // 辅助数组所有元素都是-1;
        }
    }
    ~SelectServer()
    {
        listensock_.Close();
    }

    bool Init()
    {
        listensock_.Socket();
        listensock_.Bind(port_);
        listensock_.Listen();

        return true;
    }

    void Accept()
    {
        // 我们的连接事件就绪了
        std::string clientip;
        uint16_t clientport;

        int sockfd = listensock_.Accept(&clientip, &clientport); // 这里会返回一个新的套接字
        // 请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
        if (sockfd < 0)
            return;
        else // 把新fd加入位图
        {
            int i = 1;
            for (; i < fd_num_max; i++) // 为什么从1开始,因为我们0号下标对应的是listen套接字,我们不要修改
            {
                if (fd_arry[i] != default_fd) // 没找到空位
                {
                    continue;
                }
                else
                { // 找到空位,但不能直接添加
                    break;
                }
            }
            if (i != fd_num_max) // 没有满
            {
                fd_arry[i] = sockfd; // 把新连接加入数组
                Printfd();
            }
            else // 满了
            {
                close(sockfd); // 处理不了了,直接关闭连接吧
            }
        }
    }

    void Receiver(int fd, int i)
    {
        char in_buff[1024];
        int n = read(fd, in_buff, sizeof(in_buff) - 1);
        if (n > 0)
        {
            in_buff[n] = 0;
            std::cout << "get message: " << in_buff << std::endl;
        }
        else if (n == 0) // 客户端关闭连接
        {
            close(fd);               // 我服务器也要关闭
            fd_arry[i] = default_fd; // 重置数组内的值
        }
        else
        {
            close(fd);               // 我服务器也要关闭
            fd_arry[i] = default_fd; // 重置数组内的值
        }
    }

    void HandlerEvent(fd_set &rfds)
    {
        for (int n = 0; n < fd_num_max; n++)
        {
            int fd = fd_arry[n];
            if (fd == default_fd) // 无效的
                continue;

            if (FD_ISSET(fd, &rfds)) // fd套接字就绪了
            {
                // 1.是listen套接字就绪了
                if (fd == listensock_.Fd()) // 如果是listen套接字就绪了!!!
                {
                    Accept();
                }
                // 2.是通信的套接字就绪了,fd不是listen套接字
                else // 读事件
                {
                    Receiver(fd,n);
                }
            }
        }
    }
    void Printfd()
    {
        std::cout << "online fd list: ";
        for (int i = 0; i < fd_num_max; i++)
        {
            if (fd_arry[i] == default_fd)
                continue;
            else
            {
                std::cout << fd_arry[i] << " ";
            }
        }
        std::cout << std::endl;
    }

    void Start()
    {
        int listensock = listensock_.Fd();

        fd_arry[0] = listensock; // 将监听套接字加入辅助数组
        for (;;)
        {
            fd_set rfds; // 每调用一次select函数rfds需要重新设定
            FD_ZERO(&rfds);

            int maxfd = fd_arry[0]; // 最大有效数组下标

            for (int i = 0; i < fd_num_max; ++i)
            {
                if (fd_arry[i] == default_fd)
                {
                    continue;
                }
                FD_SET(fd_arry[i], &rfds);
                // 注意辅助数组第一个元素是listen套接字,所以最开始的时候一定会执行到这里

                if (maxfd < fd_arry[i]) // 如果有更大的文件描述符,就替换掉maxfd
                {
                    maxfd = fd_arry[i];
                    std::cout << "max_fd:" << maxfd << std::endl;
                }
            }

            int n = select(maxfd + 1, &rfds, NULL, NULL, nullptr); // 注意这里会修改rfds,返回的rfds有效位代表着哪个位置的有效事件就绪了

            switch (n)
            {
            case 0:
                std::cout << "time out" << std::endl;
                break;
            case -1:
                std::cout << "select error" << std::endl;
                break;
            default:
                // 有事件就绪
                std::cout << "get a new link" << std::endl;
                HandlerEvent(rfds); // 处理事件
                break;
            }
        }
    }

private:
    uint16_t port_;          // 绑定的端口号
    Sock listensock_;        // 专门用来listen的
    std::string ip_;         // ip地址
    int fd_arry[fd_num_max]; // 辅助数组——方便文件描述符在不同函数间传递
};

main.cc

#include"SelectServer.hpp"
#include<memory>

int main()
{
    std::unique_ptr<SelectServer> svr(new SelectServer());
    svr->Init();
    svr->Start();
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值