23.多路转接-select

预备知识

  1. 后端服务器最常用的网络IO设计模式其实就是Reactor,也称为反应堆模式,Reactor是单进程,单线程的,但他能够处理多客户端向服务器发起的网络IO请求,正因为他是单执行流,所以他的成本就不高,CPU和内存这样的资源占用率就会低,降低服务器性能的开销,提高服务器性能。

    而多进程多线程方案的服务器,缺点相比于Reactor就很明显了,在高并发的场景下,服务器会面临着大量的连接请求,**每个线程都需要自己的内存空间,堆栈,自己的内核数据结构,所以大量的线程所造成的资源消耗会降低服务器的性能,多线程还会进行线程的上下文切换,也就是执行流级别的切换,每一次切换都需要保存和恢复线程的上下文信息,这会消耗CPU的时间,频繁的上下文切换也会降低服务器的性能。**前面的这些问题都是针对于服务器来说的,对于程序员来说,多执行流的服务器最恶心的就是调试和找bug了,所以多执行流的服务器生态比较差,排查问题更加的困难,服务器不好维护,同时由于多执行流可能同时访问临界资源,所以服务器的安全性也比较低,可能产生资源竞争,数据损坏等问题。

  2. 谈完Reactor这种模式的好处之后,接下来理解一下什么是高效的IO,只有真正理解了IO,我们才能理解Reactor这种模式。
    IO这件事我们并不陌生,在我们自己的电脑内部其实就无时不刻的在进行着IO,因为冯诺依曼体系已经决定了计算机是要无时不刻进行IO的,从存储设备中拿取数据到内存,将处理结果再返回至内存,从网络IO角度来讲,我们的计算机要从网卡这样的硬件中将数据拿到计算机内存,将数据处理完毕之后,可能还要再将数据从网卡发出去,这其实也是IO的过程,所以对于计算机来说IO是非常常见的一件事情。

image-20231024150342896

  1. 但上面对IO的理解还是不够深刻,以前在学习TCP网络套接字编程时,我们就谈到过,IO其实就是进行拷贝,例如send时,其实是把自己buffer缓冲区中的数据拷贝到内核的sk_buff中。recv时,其实是把内核的接收缓冲区中的数据拷贝到自己在应用层定义的buffer中,所以我们当时就认为IO其实就是数据拷贝。
    但我们可以细微的想一想,只要你调用了recv,数据就一定能够拷贝到应用层buffer中吗?会不会sk_buff中没有数据呢?因为可能没有客户端向我的服务器发送数据。同理,只要你调用了send,数据就一定能够拷贝到内核sk_buff中吗?会不会sk_buff中已经堆满了数据,没有剩余空间了呢?
    如果这些情况都存在的话,recv,send这样的接口会怎么做呢?答案是,这些接口一定会等!会等条件就绪的时候,再进行数据拷贝,recv会等sk_buff中有数据,send会等sk_buff中有剩余空间,等到条件就绪的时候,这些IO接口才会进行数据拷贝。所以我们今天要重新定义IO,IO不仅仅是数据拷贝,同时还需要进行等待,等待条件就绪时,这些接口才会进行数据拷贝,所以IO=等+数据拷贝
  2. 其实我们是遇到过等这样的情况的,以前在讲进程间通信的时候,管道通信时,我们就遇到过等待的情况,例如写端不向管道中写数据,此时读端就会阻塞,阻塞的本质其实就是在进行等待,等待写端向管道中写数据,所以读取数据的IO接口在进行等待的情况是比较常见的,但写数据时是不常见的,因为大多数情况下,写事件是直接就绪的,因为内核发送缓冲区中常常是有剩余空间的,TCP有自己的滑动窗口发送数据的策略,基本上写事件不就绪的情况很少见,但读事件不就绪还是很常见的,尤其是在网络环境下进行读取数据,因为数据包在send调用后,数据包会被发送到内核的sk_buff中,什么时候发送,怎么发送这些策略是由TCP来提供的,数据包在发送时,会经历延迟应答,查询路由表确定下一跳路径,在局域网中进行转发数据包,这些过程都是需要时间的,此时对端调用recv接收数据时,在这些时间窗口内,recv不就得等吗?
  3. 而所谓高效的IO,其实就是降低等待的时间比重,因为数据拷贝的时间比重基本上是确定的,他由硬件结构,操作系统优化,编译器优化等条件所决定,或者可以提高带宽,一次性拷贝较多的数据,但这些的做法其实都是固定的,对数据拷贝的效率提升并不大,而影响IO效率最大的因素其实就是等待,只要IO模型等待的时间比重很低,那么这个IO我们就称他是高效的。
    ————————————————
    版权声明:本文为优快云博主「rygttm」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.youkuaiyun.com/erridjsis/article/details/132548582

五种IO模型

举一个小故事

从前有一条小河,河里有许多条鱼,一个叫张三的少年就很喜欢钓鱼,他带着自己的鱼竿就去钓鱼了,但张三这个人很固执,只要鱼没上钩,张三就一直等着,什么都不干,死死的盯着鱼漂,只有鱼漂动了,张三才会动,然后把鱼钓上来,钓上来之后,张三就又会重复之前的动作,一动不动的等待鱼儿上钩。

而此时走过来一个李四,李四这名少年也很喜欢钓鱼,但李四和张三不一样,李四左口袋装着《Linux高性能服务器编程》,右口袋装着一本《算法导论》,左手拿手机,右手拿了一根鱼竿,李四拿了钓鱼凳坐下之后,李四就开始钓鱼了,但李四不像张三一样,固执的死盯着鱼漂看,李四一会看会儿左口袋的书,一会玩会手机,一会儿又看算法导论,一会又看鱼漂,所以李四一直循环着前面的动作,直到循环到看鱼漂时,发现鱼漂已经动了,此时李四就会把鱼儿钓上来,之后继续重复循环前面的动作。

此时又来了一个王五少年,王五就拿着他自己的iphone14pro max和一根鱼竿外加一个铃铛,然后就来钓鱼了,王五把铃铛挂到鱼竿上,等鱼上钩的时候,铃铛就会响,王五根本不看鱼竿,就一直玩自己的iphone,等鱼上钩的时候,铃铛会自动响,王五此时再把鱼儿钓上来就好了,之后王五又继续重复前面的动作,只要铃铛不响,王五就一直玩手机,只有铃铛响了,王五才会把鱼钓上来。

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

然后又来了一个钱七,钱七比赵六还有钱,钱七是上市公司的CEO,钱七有自己的司机,钱七不喜欢钓鱼,但钱七喜欢吃鱼,所以钱七就把自己的司机留在了岸边,并且给了司机一个电话和一个桶,告诉司机,等你把鱼钓满一桶的时候,就给我打电话,然后我就从公司开车过来接你,所以钱七就直接开车回公司开什么股东大会去了,而他的司机就被留在这里继续钓鱼了。

  • 在上面的例子中,你认为谁的钓鱼方式更加高效呢?首先我们认为,如果一个人在不停的钓鱼,时不时的就收鱼竿,把鱼钓上来,等待鱼儿上钩的时间比重却很低,那么这个人在我看来他的钓鱼方式就是高效的。而如果一个人大部分的时间都是在等待,只有那么极少数次在收杆把鱼钓上来,那么这个人的钓鱼方式就是低效的。

  • 而上面的例子中,鱼其实就是数据,鱼竿其实就是文件描述符fd,每个人都是一个进程,但除了钱七的司机,这个司机算是操作系统,河流就是内核缓冲区,鱼漂就是就绪的事件,代表钓鱼这件事情已经就绪了,进程可以对数据做拷贝了。

  • 其实赵六的方式是最高效的,也就是多路转接这种IO模型是最高效的,因为赵六的鱼竿多啊,钓上鱼的几率就大啊,其他人只有一根鱼竿,只能关心这一根鱼竿上的数据,自然就没有赵六的效率高,同理为什么渣男的女朋友多啊,因为广撒网嘛,找到女朋友的概率要比普通的老实人高啊,因为人家一次可以关心那么多的微信账号,哪个女孩发消息了人家就和谁聊天,肯定比你只有一个女孩的微信效率要高。

  • 所以本文章主要来介绍多路转接这种IO模型,同时也会讲解阻塞和非阻塞IO,需要注意的是,实际项目中,最常用的就是阻塞IO,同时大部分的fd默认就是阻塞的,因为这种IO太简单了,越简单的东西往往就越可靠,代码编写也越简单,调试和找bug的难度也就越低,这样的代码可维护性很高,所以他就越常用。

    ————————————————

    版权声明:本文为优快云博主「rygttm」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

    原文链接:https://blog.youkuaiyun.com/erridjsis/article/details/132548582

阻塞IO

阻塞IO: 在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式. (对应张三的例子)

image-20231024151828021

非阻塞IO

非阻塞IO:如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码.(对应李四的例子)

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

image-20231024153028575

信号驱动IO

信号驱动IO:内核将数据准备好的时候,使用SIGIO信号通知应用程序进行IO操作.(对应王五的例子)

image-20231024153149781

IO多路转接

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

image-20231024153305349

异步IO

异步IO:由内核在数据拷贝完成时,通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).

image-20231024153400526

总结:

任何IO过程中,都包含两个步骤.第一是等待,第二是拷贝.而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间.让IO更高效,最核心的办法就是让等待的时间尽量少.

高级IO重要概念

同步通信vs异步通信(synchronous communication/ asynchronous communication)

同步和异步关注的是消息通信机制.

  • 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回.但是一旦调用返回,就得到返回值了;换句话说,就是由调用者主动等待这个调用的结果;

  • 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果;换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果;而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用.

另外,我们回忆在讲多进程多线程的时候,也提到同步和互斥.这里的同步通信和进程之间的同步是完全不想干的概念.

  • 进程/线程同步也是进程/线程之间直接的制约关系

  • 是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系.尤其是在访问临界资源的时候.

阻塞vs非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态.

  • 阻塞调用是指调用结果返回之前,当前线程会被挂起.调用线程只有在得到结果之后才会返回.

  • 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程.

其他高级IO

非阻塞IO,纪录锁,系统V流机制,I/O多路转接(也叫I/O多路复用),readv和writev函数以及存储映射IO(mmap),这些统称为高级IO.

非阻塞IO

每一个打开的文件所对应的文件描述符fd,默认全都是阻塞的,无论是系统文件fd,还是网络套接字sock,都默认是阻塞的IO方式。

recv(设置文件描述符为非阻塞)

recv, recvfrom, recvmsg - receive a message from a socket

#include <sys/types.h>
#include <sys/socket.h>
    
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

// flag的选项,将recv返回的文件描述符设置为非阻塞
// MSG_DONTWAIT 的全拼是 "Message Do Not Wait."
MSG_DONTWAIT (since Linux 2.2)  
Enables  nonblocking  operation;  if the operation would block, the call fails with the error EAGAIN or EWOULDBLOCK (this can also be enabled using the O_NONBLOCK flag with the F_SETFL fcntl(2)).
// 启用非阻塞操作;如果操作会阻塞,则调用失败,并出现 EAGAIN 或 EWOULDBLOCK 错误(也可使用 F_SETFL fcntl(2) 的 O_NONBLOCK 标志启用此功能)。

send(设置文件描述符为非阻塞)

send, sendto, sendmsg - send a message on a socket

#include <sys/types.h>
#include <sys/socket.h>

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

MSG_DONTWAIT (since Linux 2.2)
Enables nonblocking operation; if the operation would block, EAGAIN or EWOULDBLOCK is returned (this can also be enabled using the O_NONBLOCK flag with the F_SETFL fcntl(2)).
// 启用非阻塞操作;如果操作会阻塞,则返回 EAGAIN 或 EWOULDBLOCK(也可通过 F_SETFL fcntl(2) 使用 O_NONBLOCK 标志启用)。

open(设置文件描述符为非阻塞)

open, creat - open and possibly create a file or device

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

O_NONBLOCK 或 O_NDELAY
在可能的情况下,文件以非阻塞模式打开。 无论是 open() 还是对返回的文件描述符进行的任何后续操作,都不会导致调用进程等待。
都不会导致调用进程等待。 有关 FIFO(命名管道)的处理,另请参阅 fifo(7)。 有关 O_NONBLOCK 在与强制文件锁与强制文件锁和文件租约连接时 O_NONBLOCK 的影响,请参阅 fcntl(2)

fcntl(设置文件描述符为非阻塞)

fcntl 的英文全拼是 "File Control

fcntl - manipulate file descriptor

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );

// F_GETFL:获取文件状态标志(file status flags),通常用于获取文件描述符的当前状态标志
// 当这个参数被设置时,第三个参数是被忽略的
F_GETFL (void)
Get the file access mode and the file status flags; arg is ignored.

F_SETFL (int)
Set the file status flags to the value specified by arg.  File access mode (O_RDONLY, O_WRONLY, O_RDWR) and file creation flags (i.e., O_CREAT, O_EXCL,O_NOCTTY, O_TRUNC) in arg are ignored.  On Linux this command can change only the O_APPEND, O_ASYNC, O_DIRECT, O_NOATIME, and O_NONBLOCK flags.

fcntl 函数是一个系统调用,通常用于对文件描述符(fd)进行各种控制操作。它的参数如下:

  • fd:文件描述符,表示要进行操作的文件、套接字或其他文件对象。
  • cmd:一个整数参数,表示要执行的操作类型。这个参数可以采用不同的命令,以确定执行什么样的操作。常见的命令包括:
    • F_GETFL:获取文件状态标志(file status flags),通常用于获取文件描述符的当前状态标志。
    • F_SETFL:设置文件状态标志,用于修改文件描述符的状态标志。
    • 复制一个现有的描述符(cmd=F_DUPFD)
    • 获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).
    • 获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW).

fcntl 函数还可以有第三个可选参数,根据不同的命令,这个参数的含义和用法会有所不同。这个参数可以是一个整数、结构体或其他适当的类型,用于传递相关信息或配置参数。

实现函数SetNoBlock

基于fcntl,我们实现一个SetNoBlock函数,将文件描述符设置为非阻塞.

void SetNoBlock(int fd)
{
    // F_GETFL:获取文件状态标志(file status flags),通常用于获取文件描述符的当前状态标志
	// 当这个参数被设置时,第三个参数是被忽略的
    // fl 是一个整数变量,用来存储 fcntl 函数的返回值,即文件描述符 fd 的当前状态标志(file status flags)。
    int fl = fcntl(fd, F_GETFL);
    
    // -1被返回,表示fcntl()返回错误
	if (fl < 0) 
    {
        perror("fcntl");
        return;
	}

    // 获取文件描述符成功,通过O_NONBLOCK选项将文件描述符设置为非阻塞
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
  • 使用F_GETFL将当前的文件描述符的属性取出来(这是一个位图).
  • 然后再使用F_SETFL将文件描述符设置回去.设置回去的同时,加上一个O_NONBLOCK参数.

测试SetNoBlock

  • 文件描述符为阻塞状态时
#include "util.hpp"
#include <cstdio>
#include <vector>
#include <functional>
#include <sys/select.h>

int main()
{
    // 先不将其设置为非阻塞的文件描述符
    // 0号文件描述符对应的就是键盘输入的文件描述符
    // SetNonBlock(0);
    char buffer[1024];
    while (true)
    {
        printf(">>> ");
        fflush(stdout);
        ssize_t s = read(0, buffer, sizeof(buffer) - 1);
        if (s > 0)
        {
            // read()读取数据成功
            buffer[s - 1] = 0;
            std::cout << "echo# " << buffer << std::endl;
        }
        else if (s == 0)
        {
            // 热键ctrl+d,代表文件结束符
            // read()将要读取的文件描述符被关闭了
            std::cout << "read end" << std::endl;
            break;
        }
        else
        {}

        // sleep(1);
    }
}

演示结果:read()没有读取到数据,因此在调用到read()函数时被阻塞;

image-20231024231022776

  • 文件描述符为非阻塞状态时
// util.hpp
#pragma once

#include<iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <cerrno>

void SetNonBlock(int fd)
{
    // F_GETFL:获取文件状态标志(file status flags),通常用于获取文件描述符的当前状态标志
	// 当这个参数被设置时,第三个参数是被忽略的
    // fl 是一个整数变量,用来存储 fcntl 函数的返回值,即文件描述符 fd 的当前状态标志(file status flags)。
    int fl = fcntl(fd, F_GETFL);
    
    // -1被返回,表示fcntl()返回错误
	if (fl < 0) 
    {
        perror("fcntl");
        return;
	}

    // 获取文件描述符成功,通过O_NONBLOCK选项将文件描述符设置为非阻塞
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}


// main.cc
#include "util.hpp"
#include <cstdio>
#include <vector>
#include <functional>
#include <sys/select.h>

int main()
{
    // 0号文件描述符对应的就是键盘输入的文件描述符
    SetNonBlock(0);
    char buffer[1024];
    while (true)
    {
        printf(">>> ");
        fflush(stdout);
        ssize_t s = read(0, buffer, sizeof(buffer) - 1);
        if (s > 0)
        {
            // read()读取数据成功
            buffer[s - 1] = 0;
            std::cout << "echo# " << buffer << std::endl;
        }
        else if (s == 0)
        {
            // 热键ctrl+d,代表文件结束符
            // read()将要读取的文件描述符被关闭了
            std::cout << "read end" << std::endl;
            break;
        }
        else
        {}

        sleep(1);
    }
}

演示结果:read()没有读取到数据,read()函数也不会被阻塞,代码继续向下运行;

image-20231025205151761

非阻塞时(进程还可以执行一些其他的任务)

makefile

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

util.hpp

#pragma once

#include<iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <cerrno>

void SetNonBlock(int fd)
{
    // F_GETFL:获取文件状态标志(file status flags),通常用于获取文件描述符的当前状态标志
	// 当这个参数被设置时,第三个参数是被忽略的
    // fl 是一个整数变量,用来存储 fcntl 函数的返回值,即文件描述符 fd 的当前状态标志(file status flags)。
    int fl = fcntl(fd, F_GETFL);
    
    // -1被返回,表示fcntl()返回错误
	if (fl < 0) 
    {
        perror("fcntl");
        return;
	}

    // 获取文件描述符成功,通过O_NONBLOCK选项将文件描述符设置为非阻塞
    fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}

void printLog()
{
    std::cout << "this is a log" << std::endl;
}

void download()
{
    std::cout << "this is a download" << std::endl;
}

void executeSql()
{
    std::cout << "this is a executeSql" << std::endl;
}

main.cc

#include "util.hpp"
#include <cstdio>
#include <vector>
#include <functional>
#include <sys/select.h>

using func_t = std::function<void()>;

#define INIT(v)                  \
    do                           \
    {                            \
        v.push_back(printLog);   \
        v.push_back(download);   \
        v.push_back(executeSql); \
    } while (0)

#define EXEC_OTHER(cbs)            \
    do                             \
    {                              \
        for (auto const &cb : cbs) \
            cb();                  \
    } while (0)

int main()
{
    std::vector<func_t> cbs;
    INIT(cbs);

    // 0号文件描述符对应的就是键盘输入的文件描述符
    SetNonBlock(0);
    char buffer[1024];
    while (true)
    {
        printf(">>> ");
        fflush(stdout);
        ssize_t s = read(0, buffer, sizeof(buffer) - 1);
        if (s > 0)
        {
            // read()读取数据成功
            buffer[s - 1] = 0;
            std::cout << "echo# " << buffer << std::endl;
        }
        else if (s == 0)
        {
            // 热键ctrl+d,代表文件结束符
            // 当返回值为0时,代表read()将要读取的文件描述符被关闭了
            std::cout << "read end" << std::endl;
            break;
        }
        else
        {
            // 当返回值为-1时,代表read()读取错误(分以下两种情况)
            // 当返回值为-1时, errno错误码会被设置
            // 1. 当我不输入的时候,底层没有数据(返回值也是-1),算错误吗?不算错误,只不过以错误的形式返回了
            // 2. 我又如何区分,真的错了,还是底层没有数据?(单纯返回值,无法区分)

            // 如果read()读取错误,是因为读取的文件描述符中没有数据,那么错误码为11
            // 通过如下的打印(前提条件:文件描述符中没有数据),我们可以得知错误码errno、EAGAIN和EWOULDBLOCK为11
            // 错误码为EAGAIN和EWOULDBLOCK,表示当前并没有任何错误,只是read()没有读取到数据,由于是非阻塞,所以直接返回
            // std::cout << "s : " << s << " errno: " << strerror(errno) << std::endl;
            // std::cout << "EAGAIN: " << EAGAIN << " EWOULDBLOCK: " << EWOULDBLOCK << std::endl;

            // EAGAIN和EWOULDBLOCK设置一个即可(效果是一样的)
            if (errno == EAGAIN)
            {
                std::cout << "我没错, 只是没有数据" << std::endl;
                EXEC_OTHER(cbs);
            }
            else if (errno == EINTR)
            {
                // EINTR错误,代表read正在读取时,当前进程被信号中断了
                //(数据还没有被读取完,需要下次在继续读取)
                continue;
            }
            else
            {
                // 其他情况,都代表read()读取错误,具体详查man手册
                // 通过打印错误码errno对应的详细信息,来具体判断是什么错误
                std::cout << "s : " << s << " errno: " << strerror(errno) << std::endl;
                break;
            }
        }

        sleep(1);
    }
}

演示结果:

image-20231025213253664

I/O多路转接之select

初识select

系统提供select函数来实现多路复用输入/输出模型.

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

select函数原型

 select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O multiplexing ( 同步 I/O 多路复用)


/* According to POSIX.1-2001 */
#include <sys/select.h>

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

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

// 参数解释
int nfds:参数nfds是需要监视的最大的文件描述符值+1// select未来关心的时间,只有三类:a.读 b.写 c.异常  --》 对于人格一个fd,都是这三种
fd_set *readfds, fd_set *writefds,fd_set *exceptfds:(这三个参数都为输入输出型参数)
readfds,writefds,exceptfds分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集合及异常文件描述符的集合;

fd_set *writefds:输入时,表示用户告诉内核,你要帮我关心一下,我给你的集合中的所有fd的读事件
     // 举例:0010 0010 
     // 从右向左,第一个比特位代表0号文件描述符,内容为0,表示不关心这个文件描述符;
     // 从右向左,第二个比特位代表1号文件描述符,内容为1,表示关心这个文件描述符;
     // 比特位的位置表示fd的数值,比特位的内容,表示是否关心
    
    输出时,内核告诉用户,你所关心的多个fd中,有哪些文件描述符已经就绪了
     // 举例:0000 0010 
     // 比特位的位置表示fd的数值,比特位的内容,表示那些fd上面对应的事件已经就绪了
     // 从右向左,第二个比特位代表1号文件描述符,内容为1,表示1号文件描述符已经就绪了;
    
    这个参数的本质:让用户和内核之间相互沟通,互相知晓对方要的或者关心的

struct timeval *timeout:
    参数timeout为结构timeval,用来设置select()的等待时间
    
// 参数timeout取值(struct timeval *timeout为输入输出型参数)
struct timeval {
     long    tv_sec;         /* seconds */
     long    tv_usec;        /* microseconds */
};

    nullptr:则表示select()没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件;
	0:仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。   struct timeval timeout = (0,0)也就是非阻塞等待
	特定的时间值:如果在指定的时间段里没有事件发生,select将超时返回。 
        // struct timeval timeout = (5,0), 5秒以内,阻塞式等待,超过5秒,非阻塞返回一次。
        // 在5秒以内,如果有文件描述符已经就绪,那么立马返回
        // 如果在第3秒返回,剩余2秒,我们可以通过输入输出型参数struct timeval *timeout来查看剩余几秒
        // 所以如果循环运行select函数时,timeout的值,我们每次都要重新设置,保证每次都是5秒内阻塞式等待,超过5秒,非阻塞返回一次。
        // 如果没有重新设置,那么在5秒后,没有事件就绪,下次再运行,struct timeval timeout = (0,0),变为了非阻塞等待
        
        
// 返回值
返回值大于0:代表就绪的文件描述符的个数(如果是3,代表等待的多个文件描述符中,有三个文件描述符已经就绪)
返回值等于0:超时返回了
返回值小于0:select调用失败了(通过查看错误码errno来查看具体的错误原因)
        // 错误值可能为:
        EBADF文件描述词为无效的或该文件已关闭
		EINTR此调用被信号所中断
        EINVAL参数n为负值。
        ENOMEM核心内存不足

关于fd_set结构

  • fd_set: 位图结构,表示文件描述符集合
/* 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;


/* The fd_set member is required to be an array of longs.  */
typedef long int __fd_mask;
  • 虽然知道了fd_set是位图结构,但是我们自己不能够直接对其进行操作,而应该使用系统为我们提供的接口
  • 提供了一组操作fd_set的接口,来比较方便的操作位图.
void FD_CLR(int fd, fd_set *set);      //用来清除描述词组set中相关fd的位
int  FD_ISSET(int fd, fd_set *set);    //用来测试描述词组set中相关fd的位是否为真
void FD_SET(int fd, fd_set *set);      //用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set);             //用来清除描述词组set的全部位

setsockopt()

#include <sys/types.h>
#include <sys/socket.h>

int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);

// 返回值
On success, zero is returned.  On error, -1 is returned, and errno is set appropriately.

setsockopt 是一个套接字编程中的系统调用,用于设置套接字选项。它的参数如下:

  • sockfd:这是一个整数,表示要设置选项的套接字描述符。

  • level:这是一个整数,指定选项的层级。不同的层级对应不同的选项,通常取值为 SOL_SOCKET(套接字级别选项)或其他特定协议的层级,如 IPPROTO_TCP(TCP协议层级)。

  • optname:这是一个整数,表示要设置的选项的名称或标志。具体的 optname 取决于 level,不同的层级有不同的选项。例如,对于 SOL_SOCKET 层级,您可以设置选项如 SO_REUSEADDRSO_KEEPALIVE 等。

  • optval:这是一个指向包含选项值的内存区域的指针。选项值的类型和大小取决于 optnamelevel

  • optlen:这是一个表示选项值的大小的整数,通常是 optval 指向的缓冲区的大小(以字节为单位)。

setsockopt 的作用是设置套接字的选项,以控制套接字的行为。不同的选项可以用来控制套接字的属性,如套接字的重用、超时、缓冲区大小等等。这允许程序员在编写网络应用程序时对套接字的行为进行定制化设置。

示例用法:

int optval = 1; // 设置为1表示启用选项
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) == -1) {
    perror("setsockopt");
    // 处理设置选项失败的情况
}

/*
在上面的示例中,`optval` 取值为1,因为在这种情况下,`SO_REUSEADDR` 选项的语义是启用地址重用功能。具体来说,`SO_REUSEADDR` 是一个布尔选项,它控制套接字绑定地址的行为。

设置 `SO_REUSEADDR` 为1表示启用地址重用功能,允许多个套接字在同一地址和端口上绑定。这在某些情况下很有用,特别是在服务器程序需要在某个端口上快速重启时,可以避免出现 "Address already in use" 错误。

将 `optval` 设置为1表示将选项启用,而将其设置为0表示将选项禁用。在不同的情况下,您可以根据需要设置不同的 `optval` 值来控制套接字选项的行为。

总之,`optval` 取1的原因是为了在这个示例中启用 `SO_REUSEADDR` 选项,以允许地址重用,但在其他情况下,根据需求,`optval` 可能会被设置为0或其他合适的值,以控制不同的选项。
*/

在这个示例中,我们将 SO_REUSEADDR 选项设置为1,从而启用了地址重用功能。这可以让套接字在绑定地址时可以重用之前被关闭的套接字的地址,而不会因为 TIME_WAIT 状态而无法绑定。

select()代码演示

makefile

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

err.hpp

#pragma once

#include <iostream>

enum
{
    USAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR
};

log.hpp

#include <iostream>
#include <string>
#include <cstdarg>
#include <ctime>
#include <unistd.h>

#define DEBUG   0
#define NORMAL  1
#define WARNING 2
#define ERROR   3
#define FATAL   4

const char * to_levelstr(int level)
{
    switch(level)
    {
        case DEBUG : return "DEBUG";
        case NORMAL: return "NORMAL";
        case WARNING: return "WARNING";
        case ERROR: return "ERROR";
        case FATAL: return "FATAL";
        default : return nullptr;
    }
}

void logMessage(int level, const char *format, ...)
{
#define NUM 1024
    char logprefix[NUM];
    snprintf(logprefix, sizeof(logprefix), "[%s][%ld][pid: %d]",
        to_levelstr(level), (long int)time(nullptr), getpid());

    char logcontent[NUM];
    va_list arg;
    va_start(arg, format);
    // 将变量参数列表中的格式化数据写入大小缓冲区
    // 也就是写入到缓冲区logcontent中
    vsnprintf(logcontent, sizeof(logcontent), format, arg);

    std::cout << logprefix << logcontent << std::endl;
}


sock.hpp

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "log.hpp"
#include "err.hpp"

class Sock
{
    // 表示全连接队列中最多有32+1个连接
    // 具体请看tcp相关实验
    const static int backlog = 32;

public:
    static int Socket()
    {
        // 1. 创建socket文件套接字对象
        int sock = socket(AF_INET, SOCK_STREAM, 0);
        if (sock < 0)
        {
            logMessage(FATAL, "create socket error");
            exit(SOCKET_ERR);
        }
        logMessage(NORMAL, "create socket success: %d", sock);

        // int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
        // sockfd:套接字描述符,指定要设置选项的套接字
        // level:选项的级别,通常是 SOL_SOCKET 表示通用套接字选项。
        // 设置 SO_REUSEADDR 选项,允许地址重用
        // optval:一个指向包含选项值的缓冲区的指针。
        // optlen:指定选项值的长度。
        int opt = 1;

        // 我们将 `SO_REUSEADDR` 选项设置为1,从而启用了地址重用功能。
        // 这可以让套接字在绑定地址时可以重用之前被关闭的套接字的地址,
        // 而不会因为 TIME_WAIT 状态而无法绑定。
        setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
        return sock;
    }

    static void Bind(int sock, int port)
    {
        // 2. bind绑定自己的网络信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;
        if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            logMessage(FATAL, "bind socket error");
            exit(BIND_ERR);
        }
        logMessage(NORMAL, "bind socket success");
    }

    static void Listen(int sock)
    {
        // 3. 设置socket 为监听状态
        if (listen(sock, backlog) < 0) 
        {
            logMessage(FATAL, "listen socket error");
            exit(LISTEN_ERR);
        }
        logMessage(NORMAL, "listen socket success");
    }

    // 获取新链接
    static int Accept(int listensock, std::string *clientip, uint16_t *clientport)
    {
        // 在结构体中,存在端口号和IP地址
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        // int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
        // 将获取的套接字的文件描述符返回
        int sock = accept(listensock, (struct sockaddr *)&peer, &len);
        // sock < 0, 获取套接字失败
        if (sock < 0)
            logMessage(ERROR, "accept error, next");
        else
        {
            logMessage(NORMAL, "accept a new link success, get new sock: %d", sock); // ?
            // 获取套接字成功,通过输入输出型参数来查看客户端的端口号和IP地址
            *clientip = inet_ntoa(peer.sin_addr);
            *clientport = ntohs(peer.sin_port);
        }

        return sock;
    }
};

selectServer.hpp

#pragma once

#include <iostream>
#include <string>
#include <functional>
#include "sock.hpp"

namespace select_ns
{
    static const int defaultport = 8081;
    // 保存合法fd的数组的空间大小
    // sizeof(fd_set)是128字节,即sizeof(fd_set) * 8个bit位
    // 因此select最多可以帮我们关心sizeof(fd_set) * 8个文件描述符
    static const int fdnum = sizeof(fd_set) * 8;

    // 默认合法fd的数组中,全部将其初始化为-1
    static const int defaultfd = -1;

    class SelectServer
    {
    public:
        SelectServer(int port = defaultport)
            : _port(port), _listensock(-1), fdarray(nullptr)
        {
        }

        void initServer()
        {
            // 创建的套接字
            _listensock = Sock::Socket();

            // 绑定套接字和端口号(任意IP地址bind,详看tcp套接字)
            Sock::Bind(_listensock, _port);

            // 开始监听套接字
            Sock::Listen(_listensock);

            // 保存合法fd的数组
            fdarray = new int[fdnum];

            // 全部初始化为-1
            for (int i = 0; i < fdnum; i++)
                fdarray[i] = defaultfd;

            // 我们将监听套接字放在数组中,下标为0
            fdarray[0] = _listensock;
        }

        void Print()
        {
            std::cout << "fd list: ";
            for (int i = 0; i < fdnum; i++)
            {
                if (fdarray[i] != defaultfd)
                    std::cout << fdarray[i] << " ";
            }
            std::cout << std::endl;
        }

        void HandlerReadEvent(fd_set &rfds)
        {
            // 首先,要判断_listensock是否在就绪事件的集合里面
            // rfds是一个输入输出型参数,当select返回时,rfds中就是就绪事件的集合
            if (FD_ISSET(_listensock, &rfds))
            {
                // 此时说明_listensock一定是就绪的
                std::string clientip;
                uint16_t clientport = 0;
                // 获取新套接字的文件描述符,并获取客户端的端口号和IP地址
                int sock = Sock::Accept(_listensock, &clientip, &clientport);
                // 如果sock < 0 ,说明获取套接字失败,则重新循环获取
                if (sock < 0)
                    return;
                logMessage(NORMAL, "accept success [%s:%d]", clientip.c_str(), clientport);

                // 此时,一定可以获取新的连接,但是还不可以直接recv或者send
                // 因为获取到的新连接的套接字里面不一定存在数据(如果直接recv,那么recv可能还需要等数据就绪)
                // 所以,我们需要将新的套接字托管给select,让select来帮我们关心事件是否就绪
                // 一般而言,要是使用select托管多个文件描述符,程序员需要自己维护一个所有合法fd的数组
                // 将新的sock托管给select的本质,其实就是将sock添加到fdarray数组中即可

                int i = 0;
                for (; i < fdnum; i++)
                {
                    // 在fdarray寻找一个空的位置(即哪个位置存储的是defaultfd)
                    if (fdarray[i] != defaultfd)
                        continue;
                    else
                        break;
                }

                if (i == fdnum)
                {
                    // 经过上面的循环,如果i == fdnum,说明fdarray整个数组已经存储满了
                    logMessage(WARNING, "server if full, please wait");
                    close(sock);
                }
                else
                {
                    // 代码运行到这里,说明在fdarray找到了空的位置
                    // 那么就将获取的新连接的文件描述符放入到数组中
                    fdarray[i] = sock;
                }
            }

            Print();
        }

        void start()
        {
            for (;;)
            {
                // 读文件描述符集
                fd_set rfds;
                // 清空文件描述集
                FD_ZERO(&rfds);

                // 寻找所有文件描述符中,编号最大的一个
                int maxfd = fdarray[0];
                for (int i = 0; i < fdnum; i++)
                {
                    // fdarray[i] == defaultfd说明对应下标的数组中并未存储文件描述符
                    // 注:fdarray[0],存储的是_listensock,在服务器初始化时已经做了
                    if (fdarray[i] == defaultfd)
                        continue;

                    // 合法fd全部添加到读文件描述符集合中
                    FD_SET(fdarray[i], &rfds);

                    // 更新所有fd中最大的fd
                    if (maxfd < fdarray[i])
                        maxfd = fdarray[i];
                }

                // 将_listensock 添加到读文件描述符
                FD_SET(_listensock, &rfds);

                // 每间隔1秒钟,select超时返回一次
                // struct timeval timeout,是输入输出型参数,
                // 因此每次循环都需要重新设置
                // struct timeval timeout = {1, 0};

                // struct timeval *timeout = nullptr, 代表select默认为阻塞式等待

                // 非阻塞式写法
                // 让select函数帮我们关心读文件描述符集中的_listensock
                // 我们只关心_listensock的读事件,因此写事件和异常事件设置为nullptr
                // select第一个参数是需要监视的最大的文件描述符值+1;
                int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
                switch (n)
                {
                case 0:
                    // 返回值为0,代表在规定的时间内,没有新的连接就绪;select什么都没有关心到
                    logMessage(NORMAL, "timeout...");
                    break;
                case -1:
                    // 返回值为1,代表select发生错误
                    logMessage(WARNING, "select error, code: %d, err string: %s", errno, strerror(errno));
                    break;
                default:
                    // 代码运行到这里说明有事件就绪了(目前只有一个监听事件就绪了)
                    // 如果这个监听事件不被取走进行处理,那么它就一直处于就绪状态
                    logMessage(NORMAL, "have event ready!");

                    // 取走监听事件进行处理
                    HandlerReadEvent(rfds);
                    break;
                }

                // 阻塞式写法,Accept需要等待_listensock监听到套接字之后,才可以获取
                /*
                std::string clientip;
                uint16_t clientport = 0;
                // 获取新套接字的文件描述符,并获取客户端的端口号和IP地址
                int sock = Sock::Accept(_listensock, &clientip, &clientport);
                // 如果sock < 0 ,说明获取套接字失败,则重新循环获取
                if(sock < 0) continue;
                // 开始进行服务器的处理逻辑
                */
            }
        }

        ~SelectServer()
        {
            if (_listensock < 0)
                close(_listensock);
            if (fdarray)
                delete[] fdarray;
        }

    private:
        // 服务器需要绑定自己的端口号
        int _port;

        // 服务器需要有自己的监听套接字
        int _listensock;

        // 当需要select关心多个文件描述符,需要我们自己维护一个数组,保存所有合法的fd
        int *fdarray;
    };

}

main.cc

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

using namespace std;
using namespace select_ns;

static void usage(std::string proc)
{
    std::cerr << "Usage:\n\t" << proc << " port" << "\n\n";
}

std::string transaction(const std::string &request)
{
    return request;
}

// ./select_server 8081
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        usage(argv[0]);
        exit(USAGE_ERR);
    }

    unique_ptr<SelectServer> svr(new SelectServer(atoi(argv[1])));

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

    svr->initServer();

    svr->start();

    return 0;
}

演示结果

image-20231027120445521

select()演示代码2

makefile

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

err.hpp

#pragma once

#include <iostream>

enum
{
    USAGE_ERR = 1,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR
};

log.hpp

#include <iostream>
#include <string>
#include <cstdarg>
#include <ctime>
#include <unistd.h>

#define DEBUG   0
#define NORMAL  1
#define WARNING 2
#define ERROR   3
#define FATAL   4

const char * to_levelstr(int level)
{
    switch(level)
    {
        case DEBUG : return "DEBUG";
        case NORMAL: return "NORMAL";
        case WARNING: return "WARNING";
        case ERROR: return "ERROR";
        case FATAL: return "FATAL";
        default : return nullptr;
    }
}

void logMessage(int level, const char *format, ...)
{
#define NUM 1024
    char logprefix[NUM];
    snprintf(logprefix, sizeof(logprefix), "[%s][%ld][pid: %d]",
        to_levelstr(level), (long int)time(nullptr), getpid());

    char logcontent[NUM];
    va_list arg;
    va_start(arg, format);
    // 将变量参数列表中的格式化数据写入大小缓冲区
    // 也就是写入到缓冲区logcontent中
    vsnprintf(logcontent, sizeof(logcontent), format, arg);

    std::cout << logprefix << logcontent << std::endl;
}


sock.hpp

#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "log.hpp"
#include "err.hpp"

class Sock
{
    // 表示全连接队列中最多有32+1个连接
    // 具体请看tcp相关实验
    const static int backlog = 32;

public:
    static int Socket()
    {
        // 1. 创建socket文件套接字对象
        int sock = socket(AF_INET, SOCK_STREAM, 0);
        if (sock < 0)
        {
            logMessage(FATAL, "create socket error");
            exit(SOCKET_ERR);
        }
        logMessage(NORMAL, "create socket success: %d", sock);

        // int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
        // sockfd:套接字描述符,指定要设置选项的套接字
        // level:选项的级别,通常是 SOL_SOCKET 表示通用套接字选项。
        // 设置 SO_REUSEADDR 选项,允许地址重用
        // optval:一个指向包含选项值的缓冲区的指针。
        // optlen:指定选项值的长度。
        int opt = 1;

        // 我们将 `SO_REUSEADDR` 选项设置为1,从而启用了地址重用功能。
        // 这可以让套接字在绑定地址时可以重用之前被关闭的套接字的地址,
        // 而不会因为 TIME_WAIT 状态而无法绑定。
        setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
        return sock;
    }

    static void Bind(int sock, int port)
    {
        // 2. bind绑定自己的网络信息
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;
        if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            logMessage(FATAL, "bind socket error");
            exit(BIND_ERR);
        }
        logMessage(NORMAL, "bind socket success");
    }

    static void Listen(int sock)
    {
        // 3. 设置socket 为监听状态
        if (listen(sock, backlog) < 0) 
        {
            logMessage(FATAL, "listen socket error");
            exit(LISTEN_ERR);
        }
        logMessage(NORMAL, "listen socket success");
    }

    // 获取新链接
    static int Accept(int listensock, std::string *clientip, uint16_t *clientport)
    {
        // 在结构体中,存在端口号和IP地址
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        // int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
        // 将获取的套接字的文件描述符返回
        int sock = accept(listensock, (struct sockaddr *)&peer, &len);
        // sock < 0, 获取套接字失败
        if (sock < 0)
            logMessage(ERROR, "accept error, next");
        else
        {
            logMessage(NORMAL, "accept a new link success, get new sock: %d", sock); // ?
            // 获取套接字成功,通过输入输出型参数来查看客户端的端口号和IP地址
            *clientip = inet_ntoa(peer.sin_addr);
            *clientport = ntohs(peer.sin_port);
        }

        return sock;
    }
};

selectServer.hpp

#pragma once

#include <iostream>
#include <string>
#include <functional>
#include "sock.hpp"

namespace select_ns
{
    static const int defaultport = 8081;
    // 保存合法fd的数组的空间大小
    // sizeof(fd_set)是128字节,即sizeof(fd_set) * 8个bit位
    // 因此select最多可以帮我们关心sizeof(fd_set) * 8个文件描述符
    static const int fdnum = sizeof(fd_set) * 8;

    // 默认合法fd的数组中,全部将其初始化为-1
    static const int defaultfd = -1;

    using func_t = std::function<std::string(const std::string &)>;

    class SelectServer
    {
    public:
        SelectServer(func_t f, int port = defaultport)
            : func(f), _port(port), _listensock(-1), fdarray(nullptr)
        {}

        void initServer()
        {
            // 创建的套接字
            _listensock = Sock::Socket();

            // 绑定套接字和端口号(任意IP地址bind,详看tcp套接字)
            Sock::Bind(_listensock, _port);

            // 开始监听套接字
            Sock::Listen(_listensock);

            // 保存合法fd的数组
            fdarray = new int[fdnum];

            // 全部初始化为-1
            for (int i = 0; i < fdnum; i++)
                fdarray[i] = defaultfd;

            // 我们将监听套接字放在数组中,下标为0
            fdarray[0] = _listensock;
        }

        void Print()
        {
            std::cout << "fd list: ";
            for (int i = 0; i < fdnum; i++)
            {
                if (fdarray[i] != defaultfd)
                    std::cout << fdarray[i] << " ";
            }
            std::cout << std::endl;
        }

        void Recver(int sock, int pos)
        {
            logMessage(DEBUG, "in Recver");

            // 1. 读取request
            // 这里在进行读取的时候,不会被阻塞,因为select已经监测到事件就绪了
            // 这里这种方式进行读取是有问题的
            // a.无法保证一定可以将缓冲区的数据读取完
            // b.就算将缓冲区的数据读取完,也无法保证能够完整的读取到一个报文
            // 那么应用层就无法将这个报文数据进行反序列化,转化为结构化数据
            // 后续在补充(目前先这样演示)
            char buffer[1024];
            ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0);
            if (s > 0)
            {
                // s > 0, 读取成功,s代表读取到的字符数量
                buffer[s] = 0;
                logMessage(NORMAL, "client# %s", buffer);
            }
            else if (s == 0)
            {
                // s == 0,代表客户端已经关闭了,读取到的字符数为0
                // 因为我们也要关闭服务端对应的文件描述符
                close(sock);

                // 在数组中,将其删除,select下次就不会再帮我们关心这个文件描述符了
                fdarray[pos] = defaultfd;
                logMessage(NORMAL, "client quit");
                return;
            }
            else
            {
                // s < 0, 代表recv()读取出错
                close(sock);
                fdarray[pos] = defaultfd;
                logMessage(ERROR, "client quit: %s", strerror(errno));
                return;
            }

            logMessage(DEBUG, "out Recver");

            // 2. 处理request
            std::string response = func(buffer);

            // 3. 返回response
            write(sock, response.c_str(), response.size());
        }

        void Accepter(int listensock)
        {
            logMessage(DEBUG, "Accepter in");
            // 此时说明_listensock一定是就绪的
            std::string clientip;
            uint16_t clientport = 0;

            // 获取新套接字的文件描述符,并获取客户端的端口号和IP地址
            int sock = Sock::Accept(listensock, &clientip, &clientport);
            // 如果sock < 0 ,说明获取套接字失败,则重新循环获取
            if (sock < 0)
                return;
            logMessage(NORMAL, "accept success [%s:%d]", clientip.c_str(), clientport);

            // 此时,一定可以获取新的连接,但是还不可以直接recv或者send
            // 因为获取到的新连接的套接字里面不一定存在数据(如果直接recv,那么recv可能还需要等数据就绪)
            // 所以,我们需要将新的套接字托管给select,让select来帮我们关心事件是否就绪
            // 一般而言,要是使用select托管多个文件描述符,程序员需要自己维护一个所有合法fd的数组
            // 将新的sock托管给select的本质,其实就是将sock添加到fdarray数组中即可
            int i = 0;
            for (; i < fdnum; i++)
            {
                // 在fdarray寻找一个空的位置(即哪个位置存储的是defaultfd)
                if (fdarray[i] != defaultfd)
                    continue;
                else
                    break;
            }

            if (i == fdnum)
            {
                // 经过上面的循环,如果i == fdnum,说明fdarray整个数组已经存储满了
                logMessage(WARNING, "server if full, please wait");

                // fdarray整个数组已经存储满了,因此直接关闭新获取的套接字
                close(sock);
            }
            else
            {
                // 代码运行到这里,说明在fdarray找到了空的位置
                // 那么就将获取的新连接的文件描述符放入到数组中
                fdarray[i] = sock;
            }
            Print();
            logMessage(DEBUG, "Accepter out");
        }




        // 1. rfds中,不仅仅是有一个fd是就绪的,可能存在多个
        // 通过循环accept(),来获取多个新连接的文件描述符
        // 2. 我们的select目前只处理了读事件
        void HandlerReadEvent(fd_set &rfds)
        {
            for (int i = 0; i < fdnum; i++)
            {
                // 过滤掉非法的fd
                if (fdarray[i] == defaultfd)
                    continue;

                // 此时都是合法的fd,但是合法的fd不一定就绪了

                // 首先,要判断_listensock是否在就绪事件的集合里面
                // rfds是一个输入输出型参数,当select返回时,rfds中就是就绪事件的集合
                if (FD_ISSET(fdarray[i], &rfds) && fdarray[i] == _listensock)
                {
                    // 如果fdarray[i]是监听套接字,那么调用Accepter获取新连接
                    // 并将新连接的文件描述符放入到fdarray数组中
                    Accepter(_listensock);
                }
                else if (FD_ISSET(fdarray[i], &rfds))
                {
                    // 如果fdarray[i]只是普通的套接字,是普通的读事件,那么进行数据读取
                    Recver(fdarray[i], i);
                }
                else
                {}
            }
        }

        void start()
        {
            for (;;)
            {
                // rfds是文件描述符集
                fd_set rfds;
                // 清空文件描述集
                FD_ZERO(&rfds);

                // 寻找所有文件描述符中,编号最大的一个
                int maxfd = fdarray[0];
                for (int i = 0; i < fdnum; i++)
                {
                    // fdarray[i] == defaultfd说明对应下标的数组中并未存储文件描述符
                    // 注:fdarray[0],存储的是_listensock,在服务器初始化时已经做了
                    if (fdarray[i] == defaultfd)
                        continue;

                    // 此时的fdarray[i]都是合法的fd
                    // 合法fd全部添加到读文件描述符集合中
                    FD_SET(fdarray[i], &rfds);

                    // 更新所有fd中最大的fd
                    if (maxfd < fdarray[i])
                        maxfd = fdarray[i];
                }

                // 将_listensock 添加到读文件描述符
                FD_SET(_listensock, &rfds);

                // 每间隔1秒钟,select超时返回一次
                // struct timeval timeout,是输入输出型参数,
                // 因此每次循环都需要重新设置
                // struct timeval timeout = {1, 0};

                // struct timeval *timeout = nullptr, 代表select默认为阻塞式等待

                // 非阻塞式写法
                // 让select函数帮我们关心读文件描述符集中的_listensock
                // 我们只关心_listensock的读事件,因此写事件和异常事件设置为nullptr
                // select第一个参数是需要监视的最大的文件描述符值+1;
                int n = select(maxfd + 1, &rfds, nullptr, nullptr, nullptr);
                switch (n)
                {
                case 0:
                    // 返回值为0,代表在规定的时间内,没有新的连接就绪;select什么都没有关心到
                    logMessage(NORMAL, "timeout...");
                    break;
                case -1:
                    // 返回值为1,代表select发生错误
                    logMessage(WARNING, "select error, code: %d, err string: %s", errno, strerror(errno));
                    break;
                default:
                    // 代码运行到这里说明有事件就绪了(目前只有一个监听事件就绪了)
                    // 如果这个监听事件不被取走进行处理,那么它就一直处于就绪状态
                    logMessage(NORMAL, "have event ready!");

                    // 取走监听事件进行处理
                    HandlerReadEvent(rfds);
                    break;
                }

                // 阻塞式写法,Accept需要等待_listensock监听到套接字之后,才可以获取
                /*
                std::string clientip;
                uint16_t clientport = 0;
                // 获取新套接字的文件描述符,并获取客户端的端口号和IP地址
                int sock = Sock::Accept(_listensock, &clientip, &clientport);
                // 如果sock < 0 ,说明获取套接字失败,则重新循环获取
                if(sock < 0) continue;
                // 开始进行服务器的处理逻辑
                */
            }
        }

        ~SelectServer()
        {
            if (_listensock < 0)
                close(_listensock);
            if (fdarray)
                delete[] fdarray;
        }

    private:
        // 服务器需要绑定自己的端口号
        int _port;

        // 服务器需要有自己的监听套接字
        int _listensock;

        // 当需要select关心多个文件描述符,需要我们自己维护一个数组,保存所有合法的fd
        int *fdarray;

        // 回调函数
        func_t func;
    };

}

main.cc

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

using namespace std;
using namespace select_ns;

static void usage(std::string proc)
{
    std::cerr << "Usage:\n\t" << proc << " port" << "\n\n";
}

// 回调函数
std::string transaction(const std::string &request)
{
    return request;
}

// ./select_server 8081
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        usage(argv[0]);
        exit(USAGE_ERR);
    }

    unique_ptr<SelectServer> svr(new SelectServer(transaction,atoi(argv[1])));

    // std::cout << "test: " << sizeof(fd_set) * 8 << std::endl;
    
    svr->initServer();

    svr->start();

    return 0;
}

演示结果

image-20231027212756545

select的特点

  • 可监控的文件描述符个数取决与sizeof(fd_set)的值.我这边服务器上sizeof(fd_set)=128,每bit表示一个文件描述符,则我服务器上支持的最大文件描述符是128*8=1024.

  • 将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd

    • 一是用于再select返回后,array作为源数据和fd_set进行FD_ISSET判断。
    • 二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。

select缺点

  • 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便.
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  • select支持的文件描述符数量太小.

简单来说就是:

1.select能同时等待的文件fd是有上限的,除非重新改内核,否则无法解决

2.必须借助第三方数组,来维护合法的fd

3.select的大部分参数是输入输出型的,调用select前,要重新设置所有的fd,调用之后,我们还有检查更新所有的fd,这带来的就是遍历的成本 – 用户

4.select为什么第一个参数是最大fd+1呢? 确定遍历范围 – 内核层面

5.select 采用位图,用户->内核, 内核->用户,来回的进行数据拷贝,拷贝成本的问题
int *fdarray;

    // 回调函数
    func_t func;
};

}


## main.cc

```c
#include "selectServer.hpp"
#include <memory>

using namespace std;
using namespace select_ns;

static void usage(std::string proc)
{
    std::cerr << "Usage:\n\t" << proc << " port" << "\n\n";
}

// 回调函数
std::string transaction(const std::string &request)
{
    return request;
}

// ./select_server 8081
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        usage(argv[0]);
        exit(USAGE_ERR);
    }

    unique_ptr<SelectServer> svr(new SelectServer(transaction,atoi(argv[1])));

    // std::cout << "test: " << sizeof(fd_set) * 8 << std::endl;
    
    svr->initServer();

    svr->start();

    return 0;
}

演示结果

image-20231027212756545

select的特点

  • 可监控的文件描述符个数取决与sizeof(fd_set)的值.我这边服务器上sizeof(fd_set)=128,每bit表示一个文件描述符,则我服务器上支持的最大文件描述符是128*8=1024.

  • 将fd加入select监控集的同时,还要再使用一个数据结构array保存放到select监控集中的fd

    • 一是用于再select返回后,array作为源数据和fd_set进行FD_ISSET判断。
    • 二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入(FD_ZERO最先),扫描array的同时取得fd最大值maxfd,用于select的第一个参数。

select缺点

  • 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便.
  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  • 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  • select支持的文件描述符数量太小.

简单来说就是:

1.select能同时等待的文件fd是有上限的,除非重新改内核,否则无法解决

2.必须借助第三方数组,来维护合法的fd

3.select的大部分参数是输入输出型的,调用select前,要重新设置所有的fd,调用之后,我们还有检查更新所有的fd,这带来的就是遍历的成本 – 用户

4.select为什么第一个参数是最大fd+1呢? 确定遍历范围 – 内核层面

5.select 采用位图,用户->内核, 内核->用户,来回的进行数据拷贝,拷贝成本的问题

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值