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

目录

前言

一,select函数

1.1.参数一:nfds

[1.2.参数二: readfds, writefds, exceptfds](#1.2.参数二: readfds, writefds, exceptfds)

1.2.1.fd_set类型和相关操作宏

[1.2.2.readfds, writefds, exceptfds](#1.2.2.readfds, writefds, exceptfds)

[1.2.3.怎么理解 readfds, writefds, exceptfds是输入输出型参数](#1.2.3.怎么理解 readfds, writefds, exceptfds是输入输出型参数)

[1.3.参数三: timeout](#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之前的一个好习惯,以确保集合从一个已知的状态开始。

我们看个例子

cpp 复制代码
#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 语言标准库中定义的结构体,用于表示时间间隔或时间点。他的定义如下

cpp 复制代码
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 结构体:

cpp 复制代码
 struct timeval time;  
 time.tv_sec = 123;  
 time.tv_usec = 456789; 

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

cpp 复制代码
 #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会无限等待,直到至少有一个文件描述符就绪。

cpp 复制代码
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会立即返回,用于轮询。这个就是非阻塞轮询。

cpp 复制代码
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将等待直到该时间过去或者有文件描述符就绪。

cpp 复制代码
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

cpp 复制代码
#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

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

SelectServer.hpp

cpp 复制代码
#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

cpp 复制代码
#include"SelectServer.hpp"
#include<memory>

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

2.2.SelectServer.hpp的编写

接下来我们就只剩下

cpp 复制代码
class SelectServer{

void Start()
{
    }
};

没有编写了。

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

cpp 复制代码
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

cpp 复制代码
#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

cpp 复制代码
 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

cpp 复制代码
#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 函数创建一个套接字,并用 bindlisten 函数将它设置为监听状态时,这个套接字就开始等待传入的连接请求(即客户端发起的连接)。但是,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 函数来接受这个连接。

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

cpp 复制代码
   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返回的文件描述符是不一样的。

cpp 复制代码
 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的使用在主逻辑函数中),如何传递呢?

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

cpp 复制代码
    int fds_[def_max_num]; // 辅助数组

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

cpp 复制代码
 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函数

cpp 复制代码
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函数

cpp 复制代码
 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);//处理不了了,直接关闭连接吧
                }
            }
        }
    }

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

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

cpp 复制代码
  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函数

cpp 复制代码
 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函数

cpp 复制代码
   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

cpp 复制代码
#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

cpp 复制代码
#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

cpp 复制代码
#include"SelectServer.hpp"
#include<memory>

int main()
{
    std::unique_ptr<SelectServer> svr(new SelectServer());
    svr->Init();
    svr->Start();
}
相关推荐
伏虎山真人24 分钟前
开源数据库 - mysql - mysql-server-8.4(gtid主主同步+ keepalived热切换)部署方案
数据库·mysql·开源
涔溪1 小时前
HTTP TCP三次握手深入解析
网络·tcp/ip·http
憨子周1 小时前
2M的带宽怎么怎么设置tcp滑动窗口以及连接池
java·网络·网络协议·tcp/ip
三菱-Liu2 小时前
三菱MR-J4-B伺服连接器和信号排列
网络·驱动开发·硬件工程·制造·mr
WeeJot嵌入式2 小时前
网络安全:挑战、策略与未来趋势
网络
FIN技术铺3 小时前
Redis集群模式之Redis Sentinel vs. Redis Cluster
数据库·redis·sentinel
CodingBrother4 小时前
MySQL 中的 `IN`、`EXISTS` 区别与性能分析
数据库·mysql
代码小鑫5 小时前
A027-基于Spring Boot的农事管理系统
java·开发语言·数据库·spring boot·后端·毕业设计
小小不董5 小时前
Oracle OCP认证考试考点详解082系列16
linux·运维·服务器·数据库·oracle·dba
甄臻9245 小时前
Windows下mysql数据库备份策略
数据库·mysql