1.接口介绍

IO 等于等待加拷贝,而 select 只干"等待"这一件事,而且能一次等很多个文件描述符,不用你一个个死等。
select头文件 <sys/select.h>
它一共有 5 个参数,后面 4 个都是输入输出型参数,输入输出函数表示输入是设置的值,结果函数调用后,操作系统会返回一个新的值。
第一个参数 nfds :你要等很多文件描述符,比如 3、5、7 这几个,那你就把最大的那个文件描述符 + 1 传进去。比如最大是 7,就传 8。内核只需要检查到这个数字就行。
第五个参数 timeout :用来控制 select 怎么的等待方式。它是一个 struct timeval 结构体,里面有秒和微秒,可以精确到很小的时间。

常用的有三种用法:
-
设一个具体时间,比如 5 秒。select 就等 5 秒,这期间有事件就绪就立刻返回;5 秒到了还没就绪,也返回,返回值是 0。而且这个 timeout 是输出型参数,会把剩下没等完的时间更新给你。
-
设成 {0, 0},立刻返回,相当于非阻塞。
-
传 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 必须配套四个位图操作函数:
- FD_ZERO:把整个位图清空,全部设成 0;
- FD_SET:把某个文件描述符对应的位设成 1,表示我要关心它;
- FD_CLR:把某位清 0,取消关心;
- 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 ,返回值我们分三种情况判断:
返回 0:说明超时了,2 秒内没有任何事件就绪。
返回 -1:说明 select 调用出错,打印错误信息就行。
返回 大于 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 ,意思是一直阻塞,直到有事件来。
- 如果返回 0:超时
- 如果返回 -1:出错
- 大于 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 里已经有很多文件描述符需要我们监听读事件了,所以不能再像之前那样硬编码。
我们需要做两件重要的事:
-
正确设置 select 的第一个参数 nfds,它的值应该是:所有要监听的文件描述符里最大的那个 + 1。所以我们需要遍历 fd_array ,找出里面有效的最大文件描述符 maxfd ,然后 nfds = maxfd + 1 。
-
正确设置读事件位图 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 里,把所有已经就绪的文件描述符都处理掉。
这些就绪的文件描述符一共分两种:
监听套接字 listensock:表示有新客户端要连接,我们需要 Accept 。
普通连接套接字 sock:表示客户端发数据了,我们需要 read 读取。
那怎么判断哪个文件描述符就绪了?
很简单,遍历我们的 fd_array 数组,对每一个有效 fd,用 FD_ISSET 检查它在 rfds 位图里是否被置 1。如果是,就说明这个 fd 的读事件已经就绪,可以直接操作,不会阻塞。
因为这个时候的数据是通过select告诉我们的,这个sock文件描述符已经有数据就绪了,所以此时调用 read 一定不会阻塞,能直接把内核的数据拷贝到用户层的 buffer 里。可以直接调用 read。
接下来处理 read 的返回值,有三种情况:
- 返回值 n > 0,表示成功读到数据。我们把读到的内容末尾补 \0 当成字符串打印。
因为 telnet 会带换行符,我们可以把最后一个字符置 0,去掉换行再输出。
返回值 n == 0,表示客户端主动关闭连接了。这时候服务器也要 close 关闭对应的 sock,并且把 fd_array 里这个位置重新设为默认值 -1。这样下一轮循环,select 就不会再监听这个已经关闭的 fd 了。
返回值 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;