多路复用 --- select系统调用

1.接口介绍

IO 等于等待加拷贝,而 select 只干"等待"这一件事,而且能一次等很多个文件描述符,不用你一个个死等。

select头文件 <sys/select.h>

它一共有 5 个参数,后面 4 个都是输入输出型参数,输入输出函数表示输入是设置的值,结果函数调用后,操作系统会返回一个新的值。

第一个参数 nfds :你要等很多文件描述符,比如 3、5、7 这几个,那你就把最大的那个文件描述符 + 1 传进去。比如最大是 7,就传 8。内核只需要检查到这个数字就行。

第五个参数 timeout :用来控制 select 怎么的等待方式。它是一个 struct timeval 结构体,里面有秒和微秒,可以精确到很小的时间。

常用的有三种用法:

  1. 设一个具体时间,比如 5 秒。select 就等 5 秒,这期间有事件就绪就立刻返回;5 秒到了还没就绪,也返回,返回值是 0。而且这个 timeout 是输出型参数,会把剩下没等完的时间更新给你。

  2. 设成 {0, 0},立刻返回,相当于非阻塞。

  3. 传 NULL,就是一直阻塞等,直到有文件描述符就绪才返回。

select 的返回值:

大于 0:表示有多少个文件描述符就绪了;

等于 0:超时了,没就绪也没错;

小于 0:select 本身出错了。

接下来是中间三个参数:读事件、写事件、异常事件,这三个都是输入输出参数,分别对应第二个、第三个、第四个参数。类型都是 fd_set ,其实就是内核给的位图结构。

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

输入的时候:是你告诉内核,我要关心哪些 fd 的读/写/异常事件;

输出的时候:是内核告诉你,你关心的那些 fd 里,哪些已经就绪了。

经过上面我们理解了输入和输出的含义,那么输入和输出应该如何和这个fd_set关联起来呢?例如位图有8位 0000 0000,用户需要内核关心的是0,1,2号文件描述符,已经就绪的文件描述符是2号文件描述符,所以如下

(一) 对于输入来讲,0000 0000 -> 0000 0111,比特位的位置,从左侧低位到右侧高位,表示文件描述符的编号,比特位的内容,0或者1,表示是否需要内核关心,如果为1表示用户需要内核关心,如果为0表示用户不需要内核关心,所以由于用户需要内核关心的是0,1,2号文件描述符,所以低0位置表示文件描述符的编号为0,以此类推低1位置表示文件描述符的编号为1,低2位置表示fd为2,所以我们需要将前低3位设置为1

(二)对于输出来讲,0000 1111 -> 0000 0100,比特位的位置,从左侧低位到右侧高位,表示文件描述符的编号,比特位的内容,0或者1,表示用户关心的哪些文件描述符fd,上面的读事件已经就绪了
所以用 select 必须配套四个位图操作函数:

  1. FD_ZERO:把整个位图清空,全部设成 0;
  2. FD_SET:把某个文件描述符对应的位设成 1,表示我要关心它;
  3. FD_CLR:把某位清 0,取消关心;
  4. FD_ISSET:判断某个文件描述符在位图里是不是 1,也就是看它有没有就绪。

2.TCP服务器(select版本)

main.cpp

cpp 复制代码
#include<memory>
#include"SelectServer.hpp"
int main()
{
    unique_ptr<SelectServer> svr(new SelectServer());
    svr->Init();
    svr->Start();
    return 0;
}

那么在main函数中包含对服务器的调用逻辑,所以我们使用智能指针unique_ptr管理select版本的TCP服务器,那么服务器要首先调用Init进行初始化,接下来之后再调用Start将服务器启动起来

SelectServer

基本框架

那么在SelectServer.hpp就是进行封装select版本的TCP服务器,首先定义默认端口号为8080,然后就开始编写SelectServer类,我们来看一下成员对象,对于一款服务器来讲,要有用于监听连接到来的_listensock,所以这里我们使用include "socket.hpp"中的Sock类定义一个_listensock即可,因为Sock类的成员变量包含sockfd_,并且SelectServer类的成员对象还要包含一个端口号_port用于服务器绑定端口号

接下来在构造函数中使用默认端口号8080初始化成员变量_port即可,那么对于析构函数调用Sock类对象_listensock中的Close关闭套接字即可

那么紧接着就是Init初始化服务器的编写了,依次需要完成套接字的创建,服务器的绑定,将套接字设置为监听状态,那么我们依次调用Sock类对象_listensock中的成员函数Socket完成套接字的创建,调用Bind完成服务器的绑定,调用Listen将_listensock设置为监听状态

cpp 复制代码
#pragma once
#include<iostream>
#include<sys/select.h>
#include<sys/time.h>
#include"socket.hpp"
using namespace std;
uint16_t defaultport=8080;
class SelectServer
{
public:
    SelectServer(uint16_t port=defaultport)
    :_port(port)
    {}
    ~SelectServer()
    {
        listensock_.Close();
    }
    void Init()
    {
        listensock_.Socket();
        listensock_.Listen();
        listensock_.Bind(_port);
    }
    void Start()
    {}
private:
    Sock listensock_;
    uint16_t _port;
};

完善1:

我们写服务器的 Start 函数,目的就是把服务器跑起来。首先要拿到监听套接字的文件描述符,我这里直接用 _listensock.Fd() 取出来,存成 listensock ,后面方便用。

服务器一旦启动,基本就是一直运行、不停接收客户端连接,所以整个逻辑必须放在一个死循环里,我这里直接用 for(;;) 来做无限循环。

这里有个很关键的问题:能不能在循环里直接调用 accept ?绝对不能。

因为 accept 本身会做两件事:一是阻塞等待连接,二是从全连接队列里把连接取出来。如果没有客户端连进来, accept 就会一直卡住,整个服务器就僵在这了。

更重要的是,我们本来想实现同时等待多个文件描述符,但 accept 一次只能等一个,做不到多路复用。所以真正的"等待"工作,应该交给 select , accept 只负责在连接已经就绪的时候,把连接取走就行。

cpp 复制代码
void Start()
    {
        int listensock=listensock_.Fd();
        for(;;)
        {
            //accept//不能直接accept,应该交给select统一去等待,select就绪了,select通知你,再去读取。
        }
    }

接下来讲 select 怎么用。

对监听套接字 listensock_ 来说,有新连接到来,本质就是读事件就绪。最开始我们只有一个监听套接字,所以先简单写死,只让 select 等待这一个文件描述符。

第一个参数是最大文件描述符 + 1,现在只有 listensock ,所以直接传 listensock + 1 。

第二个参数是读事件集合,我用 fd_set 定义一个 rfds ,先用 FD_ZERO 清空,再用 FD_SET 把 listensock 加进去,表示我关心它的读事件。

所以第三、四个参数直接传 nullptr ,因为写事件和异常事件我们暂时不关心,

超时时间我设成 2 秒,用 struct timeval 初始化为 {2, 0} 。

要注意:因为第五个参数是输入输出函数,所以应该设置在for循环里面,每一次select检测后都重新设置时间,不然超时时间 timeval ,第一次用完后会变成 0,不重置的话,下次就变成非阻塞了。

然后调用 select ,返回值我们分三种情况判断:

  1. 返回 0:说明超时了,2 秒内没有任何事件就绪。

  2. 返回 -1:说明 select 调用出错,打印错误信息就行。

  3. 返回 大于 0:说明有文件描述符就绪了,这里就是 listensock 上有新连接来了。

cpp 复制代码
void Start()
    {
        int listensock=listensock_.Fd();
        for(;;)
        {
            fd_set rfds;
            FD_ZERO(&rfds);
            FD_SET(listensock,&rfds);
            struct timeval timeout={2,0};
            int n=select(listensock+1,&rfds,nullptr,nullptr,&timeout);
            switch(n)
            {
                case 0:
                    cout<<"time out,timeout: "<<timeout.tv_sec<<"."<<timeout.tv_usec<<endl;
                    break;
                case -1:
                    cout<<"select error"<<endl;
                    break;
                default:
                    cout<<"get a new link"<<endl;
                    break;
            }
        }
    }

要注意select 的后面几个参数都是输入输出型参数,内核会直接修改它们。

比如读集合 rfds ,如果这次超时返回,里面的比特位会被内核清空,下次再用如果不重新设置, select 就不知道要等谁了。超时时间 timeval 也是一样,第一次用完后会变成 0,不重置的话,下次就变成非阻塞了。

所以每一轮循环,在调用 select 之前,都必须重新初始化:

重新清空、设置读集合 rfds

重新设置超时时间为 2 秒

如果后面文件描述符变多,最大文件描述符也要跟着更新

运行结果:

没有连接的时候,每次到时间了都会打印time out,时间到来但是没有客户端连接

select 这种 IO 多路复用机制有一个很重要的特点:只要你让内核关心的文件描述符上有事件没处理完,内核就会一直"提醒"你。

具体到我们的监听套接字场景:

一旦客户端发起连接,三次握手完成后,全连接队列里就多了一个待处理的连接,此时 listensock 的读事件就会被标记为"就绪"。

如果你只调用 select 检测到了这个事件,却没有调用 accept 把连接从队列里取走,这个就绪状态就会一直保留。

下一次再进循环调用 select 时,内核发现这个读事件还没处理,就会立刻返回(不会再等你设置的 2 秒超时),并且返回值 n 会被设为 1,表示有 1 个文件描述符的事件就绪。

因为我们的代码在死循环里,所以屏幕上就会飞快地反复打印"get a new link",看起来就像死循环刷屏一样。

完善2:

上面我们已经验证了timeval为2.0秒的情况,下面我们将timeval设置为0.0秒,验证是否可以进行非阻塞IO,即将timeval设置为0.0秒,那么此时select调用之后内核查看完用户让关心的文件描述符之后会直接返回,不会阻塞。

cpp 复制代码
void Start()
    {
        int listensock=listensock_.Fd();
        for(;;)
        {
            fd_set rfds;
            FD_ZERO(&rfds);
            FD_SET(listensock,&rfds);
            struct timeval timeout={0,0};
            // struct timeval timeout={2,0};
            int n=select(listensock+1,&rfds,nullptr,nullptr,&timeout);
            switch(n)
            {
                case 0:
                    cout<<"time out,timeout: "<<timeout.tv_sec<<"."<<timeout.tv_usec<<endl;
                    break;
                case -1:
                    cout<<"select error"<<endl;
                    break;
                default:
                    cout<<"get a new link"<<endl;
                    break;
            }
        }
    }

并且由于我们在代码中没有添加任何sleep休眠的代码,所以如果最初的时候,仅仅是启动服务器,此时进行的是非阻塞IO,那么在右侧如果不使用telnet充当客户端连接服务器,那么此时我们应该观察到的现象是一直快速的重复打印time out, timeout: 0.0,因为此时文件描述符listensock上的读事件并没有就绪,并且timeval每次都被设置为0.0进行非阻塞IO,所以一进入select之后,就会立即返回,将n设置为0,告诉用户没有任何文件描述符就绪,然后以此重复。

运行结果:

没有连接时

有连接时,不会等待,直接返回,因为我们的代码在死循环里,所以屏幕上就会飞快地反复打印"get a new link",看起来就像死循环刷屏一样。

完善3:

那么如果我们给select的第五个参数传参nullptr,那么此时select的等待方式就是阻塞等待,即阻塞等待用户要内核关心的文件描述符上的事件就绪,阻塞直到文件描述符上的事件就绪才返回

所以如果我们给select的第五个参数传参nullptr,那么由于最开始启动客户端之后并没有使用telnet连接服务器,所以此时会什么都不打印,接下来使用telnet连接服务器,此时文件描述符上的读事件已经就绪了,所以此时select会直接返回,由于我们并没有对连接进行处理,所以此时select会不断的告诉上层去处理连接,所以在屏幕上会快速的一直打印get a new link

cpp 复制代码
void Start()
    {
        int listensock=listensock_.Fd();
        for(;;)
        {
            fd_set rfds;
            FD_ZERO(&rfds);
            FD_SET(listensock,&rfds);
            struct timeval timeout={0,0};
            // struct timeval timeout={2,0};
            int n=select(listensock+1,&rfds,nullptr,nullptr,nullptr);
            switch(n)
            {
                case 0:
                    cout<<"time out,timeout: "<<timeout.tv_sec<<"."<<timeout.tv_usec<<endl;
                    break;
                case -1:
                    cout<<"select error"<<endl;
                    break;
                default:
                    cout<<"get a new link"<<endl;
                    break;
            }
        }
    }

没有连接时

有连接时,不会等待,直接返回,因为我们的代码在死循环里,所以屏幕上就会飞快地反复打印"get a new link",看起来就像死循环刷屏一样。

完善4:

当我们通过 select 检测到监听套接字 listensock 的读事件已经就绪时,就必须对这个事件做处理,不能一直放着不管,否则 select 会不停返回、疯狂刷屏。

所以我们可以专门写一个事件处理函数,叫Accept,用来处理已经就绪的文件描述符。那哪些文件描述符就绪了呢?答案就在 select 返回之后的 rfds 位图里,所以我们要把 rfds 传给这个处理函数。

目前我们只让内核关心 listensock 这一个文件描述符的读事件。之前我们只是简单打印一句"有新连接",并没有真正把连接取上来,接下来就要把这一步补上。

具体做法是:在 Start 函数的死循环里,当 select 返回值大于 0,说明有事件就绪,我们就直接调用 Accept,让它去处理。

Accept 函数编写:我们需要判断, rfds 里面到底是不是我们的监听套接字就绪了。判断方法就是用 FD_ISSET ,检查 listensock 对应的位是不是 1。如果返回真,就说明新连接已经到了,三次握手完成,全连接队列里有连接可以拿了。

这时候我们就可以调用 Accept 接口,把连接从底层取上来,得到一个新的套接字 sock 。

如果 sock 是非法值,就直接返回;合法的话,就说明连接获取成功,我们把客户端的 IP、端口和新文件描述符打印出来就行。

再看 Start 函数的整体逻辑:

先拿到监听套接字,然后进入死循环。

每一轮循环都要重新设置 rfds 位图和超时时间,因为 select 的参数会被内核改掉。

这里我们把 select 的超时设为 nullptr ,意思是一直阻塞,直到有事件来。

  1. 如果返回 0:超时
  2. 如果返回 -1:出错
  3. 大于 0:有描述符就绪,打印提示,然后调用 HandlerEvent 处理连接
cpp 复制代码
void Accept(fd_set&rfds)
    {
        if(FD_ISSET(listensock_.Fd(),&rfds))
        {
            string clientip;
            uint16_t clientport;
            int sock=listensock_.Accept(&clientip,&clientport);
            if(sock<0)
            {
                return;
            }
            lg(Info,"Accept success,%s:%d",clientip.c_str(),clientport);
        }
    }
void Start()
    {
        int listensock=listensock_.Fd();
        for(;;)
        {
            fd_set rfds;
            FD_ZERO(&rfds);
            FD_SET(listensock,&rfds);
            struct timeval timeout={0,0};
            // struct timeval timeout={2,0};
            int n=select(listensock+1,&rfds,nullptr,nullptr,nullptr);
            switch(n)
            {
                case 0:
                    cout<<"time out,timeout: "<<timeout.tv_sec<<"."<<timeout.tv_usec<<endl;
                    break;
                case -1:
                    cout<<"select error"<<endl;
                    break;
                default:
                    cout<<"get a new link!!!"<<endl;
                    Accept(rfds);
                    break;
            }
        }
    }

运行起来的效果也很明显:

服务器刚启动时,没有客户端连接, listensock 读事件没就绪,select 就一直阻塞,界面看起来像卡住不动。

当我们用 telnet 连上来,连接一到, listensock 读事件就绪,select 立刻返回,然后打印"get a new link",再进入 HandlerEvent 把连接 accept 上来,输出客户端信息。处理完之后,服务器又回到循环里,继续阻塞等下一个连接。每来一个客户端,就打印一次信息。

完善5:

现在我们已经成功拿到了客户端连接对应的 sock 文件描述符,但这里有一个很关键的问题:能不能直接调用 read 去读取数据?绝对不能。

因为这个 sock 是服务器和客户端之间的连接套接字,如果客户端连接成功后,暂时不发送任何数据,那么 sock 对应的TCP接收缓冲区里就是空的。这时候如果直接调用 read ,函数会因为没有数据可读而阻塞等待,整个服务器进程都会卡在这一步。

一旦服务器因为等一个客户端的数据而阻塞,后面再来新的客户端想连接,服务器就完全无法响应了。明明服务器资源还很充足,却因为一个阻塞操作,没法继续为其他客户端提供服务,这完全违背了服务器设计的初衷。

我们要始终记住:IO操作 = 等待数据 + 拷贝数据。

服务器不能自己去做"等待"这件事,等待的工作应该交给 select 来完成,因为 select 可以同时等待多个文件描述符。

但现在又有一个问题:新连接的 sock 是在 Accept函数里拿到的,而 select 是在 Start 函数里使用的,怎么把 sock 传给 select ?

因为这两个函数都属于同一个类,所以它们可以共享类的成员变量。思路就很清晰了:用一个成员变量把所有客户端的 sock 统一管理起来。

服务器要同时服务很多客户端,每个客户端对应一个 sock ,所以我们需要一个数据结构来保存这些文件描述符,方便后面交给 select 统一监听。

交给 select 的方式也很简单:遍历这个数据结构,把所有有效的文件描述符通过 FD_SET 添加到 rfds 位图中,再把 rfds 传给 select 。

这个数据结构只需要支持存储和遍历,最简单的就是用数组。

那数组应该开多大?这取决于 select 最多能监听多少文件描述符。 select 使用 fd_set 位图结构,我们可以计算:sizeof(fd_set) * 8 ,一般系统下这个结果是 1024,也就是说 select 最多只能同时监听1024个文件描述符。

所以我们在类里定义一个固定大小的数组:int fd_array[1024]; 并在构造函数里把所有位置初始化为 -1 ,表示该位置空闲。

之后在 Accept中,每当通过 Accept 拿到新的 sock ,就遍历 fd_array ,找到第一个值为 -1 的位置,把 sock 放进去。

如果遍历完都找不到空位,说明服务器已经达到 select 支持的最大连接数,只能关闭新连接,拒绝服务,如果找得到空位,就存放在fd_array数组中,并把数组打印出来。

同时,监听套接字 listensock 也必须放进这个数组,因为新连接的到来也需要 select 一起监听。

所以在服务器启动时,我们先把 listensock 加入数组。

这样一来, Start 函数每次循环都会把数组里所有有效的文件描述符交给 select 统一等待,事件就绪后再去处理,既不会阻塞,又能同时服务多个客户端。

cpp 复制代码
#pragma once
#include<iostream>
#include<sys/select.h>
#include<sys/time.h>
#include"socket.hpp"
using namespace std;
uint16_t defaultport=8080;
const int fd_num_max=(sizeof(fd_set)*8);
int defaultfd=-1;
class SelectServer
{
public:
    SelectServer(uint16_t port=defaultport)
    :_port(port)
    {}
    ~SelectServer()
    {
        listensock_.Close();
    }
    void Init()
    {
        listensock_.Socket();
        listensock_.Bind(_port);
        listensock_.Listen();
    }
    void Accept(fd_set&rfds)
    {
        if(FD_ISSET(listensock_.Fd(),&rfds))
        {
            string clientip;
            uint16_t clientport;
            int sock=listensock_.Accept(&clientip,&clientport);
            if(sock<0)
            {
                return;
            }
            lg(Info,"Accept success,%s:%d",clientip.c_str(),clientport);
            int pos=1;
            for(;pos<fd_num_max;pos++)
            {
                if(fd_array[pos]!=defaultfd)
                {
                    continue;
                }
                else
                {
                    break;
                }
            }
            if(pos==fd_num_max)
            {
                lg(Warning,"server is full,close %d now!",sock);
                close(sock);
            }
            else
            {
                fd_array[pos]=sock;
                PrintFd();
            }
        }
    }
    void PrintFd()
    {
        cout<<"online fd list: ";
        for(int i=0;i<fd_num_max;i++)
        {
            if(fd_array[i]==defaultfd)
            {
                continue;
            }
            cout<<fd_array[i]<<" ";
        }
    }
    void Start()
    {
        int listensock=listensock_.Fd();
        fd_array[0]=listensock;
        for(;;)
        {
            fd_set rfds;
            FD_ZERO(&rfds);
            FD_SET(listensock,&rfds);
            struct timeval timeout={0,0};
            // struct timeval timeout={2,0};
            int n=select(listensock+1,&rfds,nullptr,nullptr,nullptr);
            switch(n)
            {
                case 0:
                    cout<<"time out,timeout: "<<timeout.tv_sec<<"."<<timeout.tv_usec<<endl;
                    break;
                case -1:
                    cout<<"select error"<<endl;
                    break;
                default:
                    cout<<"get a new link!!!"<<endl;
                    Accept(rfds);
                    break;
            }
        }
    }
private:
    Sock listensock_;
    uint16_t _port;
    int fd_array[fd_num_max];
};

运行结果:

所以运行结果如上,左侧小编将服务器运行起来,然后右侧的第一个会话作为客户端使用telnet连接服务器,那么左侧服务器成功的获取到了连接,并且将sock添加到了fd_array数组中,那么在第一个连接到来的时候,解析一下左侧打印的当前在线的文件描述符,3代表是listensock,而这个listensock在调用Start的时候就被小编设置进了fd_array的0号下标中,4代表的是第一个会话作为客户端使用telnet连接服务器对应的文件描述符

为什么文件描述符从3开始,因为一个进程默认会打开3个文件描述符,分别是标准输入0,标准输出1,标准错误2,并且由于文件描述符本质上是数组下标,是连续的,所以说,fd_array中放的第一个文件描述符才是从3开始的

那么与第一个会话作为客户端使用telnet连接服务器同样的道理,左侧第二个会话也进行了添加sock到fd_array数组中,然后打印文件描述符数组fd_array对应的在线文件描述符

完善6:

现在我们已经能通过 select 监听 listensock 的读事件,一旦有新连接到来,就会在事件处理函数里通过 Accept 获取到新的套接字 sock ,并且把它加到我们的文件描述符数组 fd_array 里。

但这里有一个非常关键的问题:我们虽然把客户端的 sock 存进数组了,但是我们并没有让 select 去监听这些 sock 的读事件!

目前代码里的 select 还是写死的,只关心 listensock 这一个文件描述符:

int n = select(listensock + 1, &rfds, nullptr, nullptr, nullptr); 这显然是不够的。

因为现在 fd_array 里已经有很多文件描述符需要我们监听读事件了,所以不能再像之前那样硬编码。

我们需要做两件重要的事:

  1. 正确设置 select 的第一个参数 nfds,它的值应该是:所有要监听的文件描述符里最大的那个 + 1。所以我们需要遍历 fd_array ,找出里面有效的最大文件描述符 maxfd ,然后 nfds = maxfd + 1 。

  2. 正确设置读事件位图 rfds ,现在不能只把 listensock 加进去了,我们需要遍历整个 fd_array ,把里面不是默认值 -1 的有效文件描述符,一个一个用 FD_SET 全部设置到 rfds 里。

这样一来, select 才会真正帮我们同时监听:

监听套接字 listensock 的新连接事件

所有已连接客户端 sock 的数据可读事件

因为文件描述符是动态增加的,最大值 maxfd 也会不断变化,所以每一轮循环都必须重新遍历数组、重新计算 maxfd、重新设置 rfds。

我们可以把 maxfd 初始化为 listensock ,然后在遍历数组时不断更新,这样就能保证每次传给 select 的参数都是正确、完整的。

cpp 复制代码
#pragma once
#include<iostream>
#include<sys/select.h>
#include<sys/time.h>
#include"socket.hpp"
using namespace std;
uint16_t defaultport=8080;
const int fd_num_max=(sizeof(fd_set)*8);
int defaultfd=-1;
class SelectServer
{
public:
    SelectServer(uint16_t port=defaultport)
    :_port(port)
    {
        for(int i=0;i<fd_num_max;i++)
        {
            fd_array[i]=defaultfd;
        }
    }
    ~SelectServer()
    {
        listensock_.Close();
    }
    void Init()
    {
        listensock_.Socket();
        listensock_.Bind(_port);
        listensock_.Listen();
    }
    void Accept(fd_set&rfds)
    {
        if(FD_ISSET(listensock_.Fd(),&rfds))
        {
            string clientip;
            uint16_t clientport;
            int sock=listensock_.Accept(&clientip,&clientport);
            if(sock<0)
            {
                return;
            }
            lg(Info,"Accept success,%s:%d",clientip.c_str(),clientport);
            int pos=1;
            for(;pos<fd_num_max;pos++)
            {
                if(fd_array[pos]!=defaultfd)
                {
                    continue;
                }
                else
                {
                    break;
                }
            }
            if(pos==fd_num_max)
            {
                lg(Warning,"server is full,close %d now!",sock);
                close(sock);
            }
            else
            {
                fd_array[pos]=sock;
                PrintFd();
            }
        }
    }
    void PrintFd()
    {
        cout<<"online fd list: ";
        for(int i=0;i<fd_num_max;i++)
        {
            if(fd_array[i]==defaultfd)
            {
                continue;
            }
            cout<<fd_array[i]<<" ";
        }
        cout<<endl;
    }
    void Start()
    {
        int listensock=listensock_.Fd();
        fd_array[0]=listensock;
        for(;;)
        {
            fd_set rfds;
            FD_ZERO(&rfds);
            int maxfd=fd_array[0];
            for(int i=0;i<fd_num_max;i++)
            {
                if(fd_array[i]!=defaultfd)
                {
                    FD_SET(fd_array[i],&rfds);
                    if(maxfd<fd_array[i])
                    {
                        maxfd=fd_array[i];
                        lg(Info,"max fd update,max fd is : %d",maxfd);
                    }
                }
            }
            struct timeval timeout={0,0};
            // struct timeval timeout={2,0};
            int n=select(maxfd+1,&rfds,nullptr,nullptr,nullptr);
            switch(n)
            {
                case 0:
                    cout<<"time out,timeout: "<<timeout.tv_sec<<"."<<timeout.tv_usec<<endl;
                    break;
                case -1:
                    cout<<"select error"<<endl;
                    break;
                default:
                    cout<<"get a new link!!!"<<endl;
                    Accept(rfds);
                    break;
            }
        }
    }
private:
    Sock listensock_;
    uint16_t _port;
    int fd_array[fd_num_max];
};

运行结果:

每次有客户端连接上来后,都会打印出就绪的连接的文件描述符,同时更新最大的文件描述符ID。

完善7:

现在我们的代码看起来已经挺完善了,但还少了最关键的一步:处理客户端发来的数据。

目前我们的服务器,只是能接收客户端连接,但客户端如果发消息过来,我们是完全没有处理逻辑的。

举个例子,我们用 telnet 连上服务器后,键盘输入内容发给服务器,服务器这边应该要能读到数据并打印出来。当客户端发送数据时,对应的连接套接字 sock 底层缓冲区就会有数据,这就表示 sock 的读事件就绪。

所以我们必须在 Accept 里,把所有已经就绪的文件描述符都处理掉。

这些就绪的文件描述符一共分两种:

  1. 监听套接字 listensock:表示有新客户端要连接,我们需要 Accept 。

  2. 普通连接套接字 sock:表示客户端发数据了,我们需要 read 读取。

那怎么判断哪个文件描述符就绪了?

很简单,遍历我们的 fd_array 数组,对每一个有效 fd,用 FD_ISSET 检查它在 rfds 位图里是否被置 1。如果是,就说明这个 fd 的读事件已经就绪,可以直接操作,不会阻塞。

因为这个时候的数据是通过select告诉我们的,这个sock文件描述符已经有数据就绪了,所以此时调用 read 一定不会阻塞,能直接把内核的数据拷贝到用户层的 buffer 里。可以直接调用 read。

接下来处理 read 的返回值,有三种情况:

  1. 返回值 n > 0,表示成功读到数据。我们把读到的内容末尾补 \0 当成字符串打印。

因为 telnet 会带换行符,我们可以把最后一个字符置 0,去掉换行再输出。

  1. 返回值 n == 0,表示客户端主动关闭连接了。这时候服务器也要 close 关闭对应的 sock,并且把 fd_array 里这个位置重新设为默认值 -1。这样下一轮循环,select 就不会再监听这个已经关闭的 fd 了。

  2. 返回值 n < 0,表示读取出错。同样要打印错误信息,关闭文件描述符,并把数组里的位置置为 -1,把出错的 fd 从监听集合里剔除,避免下次 select 再去等待它。

所以我们可以对select返回的rfds位图中创建一个Dispatcher判断,遍历数组,如果是listensock准备就绪就使用Accept函数对新连接进行处理,如果不是listensock,那么就是其他连接上来的文件描述符有数据发送过来了,可以直接使用read进行读取了。

cpp 复制代码
#pragma once
#include<iostream>
#include<sys/select.h>
#include<sys/time.h>
#include"socket.hpp"
using namespace std;
uint16_t defaultport=8080;
const int fd_num_max=(sizeof(fd_set)*8);
int defaultfd=-1;
class SelectServer
{
public:
    SelectServer(uint16_t port=defaultport)
    :_port(port)
    {
        for(int i=0;i<fd_num_max;i++)
        {
            fd_array[i]=defaultfd;
        }
    }
    ~SelectServer()
    {
        listensock_.Close();
    }
    void Init()
    {
        listensock_.Socket();
        listensock_.Bind(_port);
        listensock_.Listen();
    }
    void Recver(int fd,int pos)
    {
        char buffer[1024];
        ssize_t n=read(fd,buffer,sizeof(buffer)-1);
        if(n>0)
        {
            buffer[n]=0;
            cout<<"get a message: "<<buffer<<endl;
        }
        else if(n==0)
        {
            lg(Info,"client quit,me too,close fd is : %d",fd);
            close(fd);
        }
        else
        {
            lg(Warning ,"recv error: fd is : %d",fd);
            close(fd);
            fd_array[pos]=defaultfd;
        }
    }
    void Dispatcher(fd_set&rfds)
    {
        for(int i=0;i<fd_num_max;i++)
        {
            int fd=fd_array[i];
            if(fd==defaultfd)
            {
                continue;
            }
            if(FD_ISSET(fd,&rfds))
            {
                if(fd==listensock_.Fd())
                {
                    Accept(rfds);
                }
                else
                {
                    Recver(fd,i);
                }
            }
        }
    }
    void Accept(fd_set&rfds)
    {
        if(FD_ISSET(listensock_.Fd(),&rfds))
        {
            string clientip;
            uint16_t clientport;
            int sock=listensock_.Accept(&clientip,&clientport);
            if(sock<0)
            {
                return;
            }
            lg(Info,"Accept success,%s:%d",clientip.c_str(),clientport);
            int pos=1;
            for(;pos<fd_num_max;pos++)
            {
                if(fd_array[pos]!=defaultfd)
                {
                    continue;
                }
                else
                {
                    break;
                }
            }
            if(pos==fd_num_max)
            {
                lg(Warning,"server is full,close %d now!",sock);
                close(sock);
            }
            else
            {
                fd_array[pos]=sock;
                PrintFd();
            }
        }
    }
    void PrintFd()
    {
        cout<<"online fd list: ";
        for(int i=0;i<fd_num_max;i++)
        {
            if(fd_array[i]==defaultfd)
            {
                continue;
            }
            cout<<fd_array[i]<<" ";
        }
        cout<<endl;
    }
    void Start()
    {
        int listensock=listensock_.Fd();
        fd_array[0]=listensock;
        for(;;)
        {
            fd_set rfds;
            FD_ZERO(&rfds);
            int maxfd=fd_array[0];
            for(int i=0;i<fd_num_max;i++)
            {
                if(fd_array[i]!=defaultfd)
                {
                    FD_SET(fd_array[i],&rfds);
                    if(maxfd<fd_array[i])
                    {
                        maxfd=fd_array[i];
                        lg(Info,"max fd update,max fd is : %d",maxfd);
                    }
                }
            }
            struct timeval timeout={0,0};
            // struct timeval timeout={2,0};
            int n=select(maxfd+1,&rfds,nullptr,nullptr,nullptr);
            switch(n)
            {
                case 0:
                    cout<<"time out,timeout: "<<timeout.tv_sec<<"."<<timeout.tv_usec<<endl;
                    break;
                case -1:
                    cout<<"select error"<<endl;
                    break;
                default:
                    cout<<"get a new link!!!"<<endl;
                    Dispatcher(rfds);
                    break;
            }
        }
    }
private:
    Sock listensock_;
    uint16_t _port;
    int fd_array[fd_num_max];
};

运行结果:

之后,无论是监听套接字的有连接就绪还是连接的读事件就绪,select都可以区分并进行对应的操作了。

3.select缺点

第一,select 能监听的文件描述符数量有限。因为它依赖 fd_set 这种位图结构,默认最多只能管 1024 个 fd,而且不能动态扩容,想支持更多连接根本做不到。

第二,select 的参数大多是输入输出型,数据拷贝开销大。每次调用 select,都要把用户关心的 fd 从用户区拷贝到内核区;等内核检测完,又要把就绪的 fd 拷贝回用户区。频繁调用就会带来频繁拷贝,效率不高。

第三,每次调用 select 都要重新设置关心的文件描述符。因为参数会被内核改掉,所以循环里每次都得重新初始化位图、重新加 fd。

第四,用户态必须自己维护 fd 数组,而且到处都要遍历。比如加 fd 要遍历、设置位图要遍历、判断哪个 fd 就绪还要遍历。内核那边检测事件时,同样也要遍历文件描述符表,遍历越多,效率越低。

所以,select 虽然是 IO 多路复用的经典方案,理论上监听越多 fd,效率越高,但实际上因为上面这几个问题,连接数上去之后,性能反而会慢慢下降。尤其是数量限制和重复设置参数这两个问题,几乎是硬伤。

4.源码

main.cpp

cpp 复制代码
#include<memory>
#include"SelectServer.hpp"
int main()
{
    unique_ptr<SelectServer> svr(new SelectServer());
    svr->Init();
    svr->Start();
    return 0;
}

makefile

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

SelectServer.hpp

cpp 复制代码
#pragma once
#include<iostream>
#include<sys/select.h>
#include<sys/time.h>
#include"socket.hpp"
using namespace std;
uint16_t defaultport=8080;
const int fd_num_max=(sizeof(fd_set)*8);
int defaultfd=-1;
class SelectServer
{
public:
    SelectServer(uint16_t port=defaultport)
    :_port(port)
    {
        for(int i=0;i<fd_num_max;i++)
        {
            fd_array[i]=defaultfd;
        }
    }
    ~SelectServer()
    {
        listensock_.Close();
    }
    void Init()
    {
        listensock_.Socket();
        listensock_.Bind(_port);
        listensock_.Listen();
    }
    void Recver(int fd,int pos)
    {
        char buffer[1024];
        ssize_t n=read(fd,buffer,sizeof(buffer)-1);
        if(n>0)
        {
            buffer[n]=0;
            cout<<"get a message: "<<buffer<<endl;
        }
        else if(n==0)
        {
            lg(Info,"client quit,me too,close fd is : %d",fd);
            close(fd);
        }
        else
        {
            lg(Warning ,"recv error: fd is : %d",fd);
            close(fd);
            fd_array[pos]=defaultfd;
        }
    }
    void Dispatcher(fd_set&rfds)
    {
        for(int i=0;i<fd_num_max;i++)
        {
            int fd=fd_array[i];
            if(fd==defaultfd)
            {
                continue;
            }
            if(FD_ISSET(fd,&rfds))
            {
                if(fd==listensock_.Fd())
                {
                    Accept(rfds);
                }
                else
                {
                    Recver(fd,i);
                }
            }
        }
    }
    void Accept(fd_set&rfds)
    {
        if(FD_ISSET(listensock_.Fd(),&rfds))
        {
            string clientip;
            uint16_t clientport;
            int sock=listensock_.Accept(&clientip,&clientport);
            if(sock<0)
            {
                return;
            }
            lg(Info,"Accept success,%s:%d",clientip.c_str(),clientport);
            int pos=1;
            for(;pos<fd_num_max;pos++)
            {
                if(fd_array[pos]!=defaultfd)
                {
                    continue;
                }
                else
                {
                    break;
                }
            }
            if(pos==fd_num_max)
            {
                lg(Warning,"server is full,close %d now!",sock);
                close(sock);
            }
            else
            {
                fd_array[pos]=sock;
                PrintFd();
            }
        }
    }
    void PrintFd()
    {
        cout<<"online fd list: ";
        for(int i=0;i<fd_num_max;i++)
        {
            if(fd_array[i]==defaultfd)
            {
                continue;
            }
            cout<<fd_array[i]<<" ";
        }
        cout<<endl;
    }
    void Start()
    {
        int listensock=listensock_.Fd();
        fd_array[0]=listensock;
        for(;;)
        {
            fd_set rfds;
            FD_ZERO(&rfds);
            int maxfd=fd_array[0];
            for(int i=0;i<fd_num_max;i++)
            {
                if(fd_array[i]!=defaultfd)
                {
                    FD_SET(fd_array[i],&rfds);
                    if(maxfd<fd_array[i])
                    {
                        maxfd=fd_array[i];
                        lg(Info,"max fd update,max fd is : %d",maxfd);
                    }
                }
            }
            struct timeval timeout={0,0};
            // struct timeval timeout={2,0};
            int n=select(maxfd+1,&rfds,nullptr,nullptr,nullptr);
            switch(n)
            {
                case 0:
                    cout<<"time out,timeout: "<<timeout.tv_sec<<"."<<timeout.tv_usec<<endl;
                    break;
                case -1:
                    cout<<"select error"<<endl;
                    break;
                default:
                    cout<<"get a new link!!!"<<endl;
                    Dispatcher(rfds);
                    break;
            }
        }
    }
private:
    Sock listensock_;
    uint16_t _port;
    int fd_array[fd_num_max];
};

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>
#include "log.hpp"
using namespace std;
enum
{
    SocketErr = 2,
    BindErr,
    ListenErr,
};
const int backlog = 10;
class Sock
{
public:
    Sock()
    {
    }
    ~Sock()
    {
    }

public:
    void Socket()
    {
        sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
        if (sockfd_ < 0)
        {
            lg(Fatal, "sockfd error ,%s:%d", strerror(errno), errno);
            exit(SocketErr);
        }
        int opt = 1;
        setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt)); // 主动断开连接的一方,在四处挥手的结束后,会进入time_wait状态,需要等待一段时间才会释放
    }
    void Bind(uint16_t port)
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = INADDR_ANY;
        local.sin_port = htons(port);
        int n = bind(sockfd_, (struct sockaddr *)(&local), sizeof(local));
        if (n < 0)
        {
            lg(Fatal, "bind error,%s:%d", strerror(errno), errno);
            exit(BindErr);
        }
    }
    void Listen()
    {
        if (listen(sockfd_, backlog) < 0)//listen第二个参数+1表示全连接队列的最大连接数
        {
            lg(Fatal, "listen error,%s:%d", strerror(errno), errno);
        }
    }
    int Accept(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)
        {
            lg(Warning,"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;
    }
    bool Connect(const 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)
        {
            cerr<<"connect to"<<ip<<" : "<<port<<" error "<<endl;
            return false;
        }
        return true;
    }
    void Close()
    {
        close(sockfd_);
    }
    int Fd()
    {
        return sockfd_;
    }

private:
    int sockfd_;
};

log.hpp

cpp 复制代码
#pragma once
#include <iostream>
#include <time.h>
#include <string.h>
#include <stdarg.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include<unistd.h>

#define SIZE 1024
#define Info 0
#define Debug 1
#define Warning 2
#define Error 3
#define Fatal 4

#define Screen 1
#define Onefile 2
#define Classfile 3
#define Logfile "log.txt"
using namespace std;
class Log
{
public:
    Log()
    {
        printmethod = Screen;
        path = "./log/";
    }
    void Enable(int enable)
    {
        printmethod = enable;
    }
    string Leveltostring(int level)
    {
        switch (level)
        {
        case Info:
            return "Info";
        case Debug:
            return "Debug";
        case Warning:
            return "Warning";
        case Fatal:
            return "Fatal";
        default:
            return "None";
        }
    }
    void printlog(int level, const string &logtxt)
    {
        switch (printmethod)
        {
        case Screen:
            cout << logtxt << endl;
        case Onefile:
        {
            printOnefile(Logfile, logtxt);
            break;
        }
        case Classfile:
        {
            printClassfile(level, logtxt);
            break;
        }
        default:
            break;
        }
    }
    void operator()(int level, const char *format, ...)
    {
        time_t t = time(NULL);
        struct tm *ctime = localtime(&t);
        char leftbuffer[SIZE];
        snprintf(leftbuffer, sizeof(leftbuffer), "%s-%d-%d-%d-%d-%d-%d", Leveltostring(level).c_str(),
                 ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday, ctime->tm_hour, ctime->tm_min, ctime->tm_sec);
        va_list s;
        va_start(s, format);
        char rightbuffer[SIZE];
        vsnprintf(rightbuffer, sizeof(rightbuffer), format, s);
        va_end(s);
        char logtxt[SIZE * 2];
        snprintf(logtxt, sizeof(logtxt), "%s-%s\n", leftbuffer, rightbuffer);
        printlog(level, logtxt);
    }
    void printOnefile(const string &logname, const string &logtxt)
    {
        string _logname = path+logname;
        int fd = open(_logname.c_str(), O_WRONLY|O_CREAT|O_APPEND,0666);
        if(fd<0)
        {
            return;
        }
        write(fd,logtxt.c_str(),logtxt.size());
        close(fd);
    }
    void printClassfile(int level, const string &logtxt)
    {
        string filename=Logfile;
        filename+='.';
        filename+=Leveltostring(level);
        printOnefile(filename,logtxt);
    }

private:
    int printmethod;
    string path;
};
Log lg;
相关推荐
杨云龙UP2 小时前
mysqldump逻辑备份文件恢复总结:全库恢复、单库恢复,一篇讲明白
linux·运维·服务器·数据库·mysql·adb
舰长1152 小时前
linux系统服务器加固1、中风险 未设置登录失败处理功能和登录连接超时处理功能。2、中风险 未限制默认账户的访问权限。3、中风险 未实现管理用户的权限分离。
linux·运维·服务器
ybwycx2 小时前
mysql重置root密码(适用于5.7和8.0)
数据库·mysql·adb
mounter6252 小时前
Linux 7.0 重磅更新:详解 nullfs 如何重塑根文件系统挂载与内核线程隔离
linux·运维·服务器·kernel
色空大师3 小时前
【网站搭建实操(一)环境部署】
java·linux·数据库·mysql·网站搭建
亚历克斯神3 小时前
Flutter for OpenHarmony: Flutter 三方库 mutex 为鸿蒙异步任务提供可靠的临界资源互斥锁(并发安全基石)
android·数据库·安全·flutter·华为·harmonyos
-Da-3 小时前
Unix哲学:一切皆文件与网络通信的统一抽象
服务器·unix
IAUTOMOBILE3 小时前
用Python批量处理Excel和CSV文件
jvm·数据库·python
常利兵4 小时前
Spring项目新姿势:Lambda封装Service调用,告别繁琐注入!
java·数据库·spring