文章目录
- 多路转接IO模型
- I/O多路转接-select
- select初识
- select函数
- select的核心功能
- fd_set的执行流程
- select就绪条件
- 基于select的服务器
- Sock.hpp
- Select_server.cpp
- 存在的问题
- select的优点
- select的缺点
- select的适用场景
多路转接IO模型
Linux下多路转接的方案常见的有三种:select
、poll
、epoll
,select出现是最早的,使用也是最繁琐的
I/O多路转接-select
select初识
select是系统提供的一个多路转接接口
- select系统调用可以让我们的程序同时监视多个文件描述符的上的事件是否就绪,
- select的核心工作就是等,当监视的多个文件描述符中有一个或多个事件就绪时,select才会成功返回并将对应文件描述符的就绪事件告知调用者
- 注意:select只负责一件事,那就是等待的过程,我们想要什么样的等待结果呢?(读写文件描述符时的情况)
- 读就绪,写就绪,异常就绪
问:select有没有所谓的读取和写入数据的功能
没有!select只负责等待
select和
read/write
,send/recv
的区别
-
select
函数没有读写的功能,只负责IO当中等的环节,一旦条件就绪会通知上层进行读取和写入 -
read/write
,send/recv
等IO接口本身也具有等待的功能,不过只能等待1个文件描述符条件就绪,而select
可以同时等多个文件描述符就绪
select函数
函数原型
#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);
参数说明:
- nfds:输入型参数, 需要监视的文件描述符中,最大的文件描述符值+1
- readfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的读事件是否就绪,返回时内核告知用户哪些文件描述符的读事件已经就绪
- writefds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的写事件是否就绪,返回时内核告知用户哪些文件描述符的写事件已经就绪
- exceptfds:输入输出型参数,调用时用户告知内核需要监视哪些文件描述符的异常事件是否就绪,返回时内核告知用户哪些文件描述符的异常事件已经就绪,
- timeout:输入输出型参数,调用时由用户设置select的等待时间,返回时表示timeout的剩余时间
关于timeout参数的说明
参数timeout的取值:
- NULL/nullptr:标识select调用后进行阻塞等待,直到被监视的某个文件描述符上的某个事件就绪
- {0,0}:selec调用后进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回
- {秒,微妙} 特定的时间值:select调用后,在指定的时间内进行阻塞等待,如果被监视的文件描述符上一直没有事件就绪,则在该时间后select进行超时返回,如果在指定的时间内有事件就绪了,就不再等待,直接返回,timeout的值就是剩余的时间
返回值说明:
- 如果函数调用成功,则返回有事件就绪的文件描述符个数
- 如果timeout时间耗尽,则返回0(为0表示超时并且没有任何事件就绪)
- 如果函数调用失败,则返回-1表示出错,同时错误码会被设置
- select调用失败时,错误码可能被设置为:
EBADF
:文件描述符为无效的或该文件已关闭,EINTR
:此调用被信号所中断,EINVAL
:参数nfds为负值 ,ENOMEM
:核心内存不足
- select调用失败时,错误码可能被设置为:
对于阻塞式等待而言:select函数的返回值只会>0或者<0
select的核心功能
- 用户告知内核,你要帮我关心指定的fd上的读写事件是否就绪;
- 内核告知用户,你所关心的指定的fd中那些fd上的读写事件已经就绪,
用户如何告知内核,指定的多个文件描述符呢? 就是通过fd_set结构实现
关于select其中三个参数的类型:fd_set结构
fd_set结构与我们之前在信号中的sigset_t结构类似,fd_set本质也是一个位图,用位图中对应的位来表示要监视的文件描述符
- 位图相当于数据,下标表示文件描述符的编号
- 位图每个比特位值为1或0,分别表示需要被监视和不需监视
readfds
、writefds
、execptfds
这些参数是输入输出型参数:
-
使用三个fd位图结构参数
readfds
、writefds
、execptfds
分别表示只关心读、写、异常的事件的就绪 -
输入时,用户告诉内核,你要帮我关心的fd的集合, 输出时,内核告诉用户,哪些fd的事件已经就绪,事件就绪的fd会被置1,其他被置0
**调用select函数之前就需要用fd_set结构定义出对应的文件描述符集,然后将需要监视的文件描述符添加到文件描述符集当中,**这个添加的过程本质就是在进行位操作,但是这个位操作不需要用户自己进行,系统提供了一组专门的接口,用于对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的全部位
定义出一个fd_set结构变量之后,需要调用FD_ZERO函数初始化
需要注意的是:fd_set
是个位图结构,只要是变量就一定有大小,说明select
能等待的fd个数也是有限的
调用select函数时传入的readfds、writefds以及exceptfds都是fd_set结构的,fd_set结构本质是一个位图,它用每一个比特位来标记一个文件描述符,因此select可监控的文件描述符个数是取决于fd_set类型的比特位个数的
验证当前能等待的文件描述符个数是多少个
int main()
{
cout << sizeof(fd_set)*8<<endl; //1024
return 0;
}
sizeof(fd_set) 得到的是fd_set类型/该类型所创建的变量所占的字节数,因为每一个位描述一个文件描述符,所以最多能等待的文件描述符的个数是sizeof(fd_set)*8个
fd_set的执行流程
例子
- 创建fd_set类型变量set, 调用位图的操作函数D_ZERO(&set)初始化, 则set用位表示是0000,0000
- 若我们想要关心fd=5文件描述符读取是否就绪, 则执行FD_SET(fd, &set); 后set变为0001,0000
- 若再想要关心fd=2, fd=1文件描述符读取是否就绪, 执行FD_SET(fd, &set) 则set变为0001,0011 ->这个是用户告知内核的,你需要帮我关注哪些文件描述符读取是否就绪
- 调用select(6,&set,nullptr,nullptr,0),进行阻塞等待 (我们此时需要监视的文件描述符中,最大的文件描述符值是5,所以第一个参数就是5+1=6, 而我们不关心写,异常事件的就绪,所以置为nullptr, 我们要进行阻塞等待,所以最后一个参数设为0)
- 若 fd=1,fd=2 上的读取事件就绪了,则select返回,此时set变为 0000,0011 -》 这个是内核告知用户的,你关注的哪个文件描述符已经就绪了 fd=5被清空
问:因为是输入输出型参数, 所以位图会被修改,下次如果我们还想关心5号描述符的读取事件是否就绪,该如何设置?
因为 select 用输入输出型参数表示不同的含义,意味着之后每一次调用都要对 fd_set 重新设置内容,用户必须将需要等待的fd用数组或其他容器保存起来
timeval结构
传入select函数的最后一个参数timeout,就是一个指向timeval结构的指针**,timeval结构用于描述一段时间长度,该结构当中包含两个成员,其中tv_sec表示的是秒,tv_usec表示的是微秒**
#include <sys/time.h>
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
select就绪条件
读就绪
- socket内核中,接收缓冲区中的字节数,大于等于低水位标记
SO_RCVLOWAT
,此时可以无阻塞的读取该文件描述符,并且返回值大于0, - socket TCP通信中,对端关闭连接,此时对该socket读,则返回0,
- 监听的socket上有新的连接请求,
- socket上有未处理的错误,
写就绪
- socket内核中,发送缓冲区中的可用字节数,大于等于低水位标记
SO_SNDLOWAT
,此时可以无阻塞的写,并且返回值大于0, - socket的写操作被关闭(close或者shutdown),对一个写操作被关闭的socket进行写操作,会触发SIGPIPE信号,
- socket使用非阻塞connect连接成功或失败之后就认为写事件就绪了
- socket上有未读取的错误,
异常就绪
- socket上收到带外数据,
注:带外数据和TCP的紧急模式相关,TCP报头当中的URG标志位和16位紧急指针搭配使用,就能够发送/接收带外数据
基于select的服务器
首先我们先对服务器需要的动作: 套接字的创建、绑定和监听,获取新连接封装在一个类中,方便我们后序直接使用
Sock.hpp
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
using namespace std;
class Sock
{
public:
static int Socket() //创建套接字
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
cerr << "socket error" << endl;
exit(2);
}
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
static void Bind(int sock, uint16_t port) //端口号和套接字绑定
{
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)
{
cerr << "bind error!" << endl;
exit(3);
}
}
static void Listen(int sock)//监听
{
if (listen(sock, 5) < 0)
{
cerr << "listen error !" << endl;
exit(4);
}
}
static int Accept(int sock)//从sock套接字中获取新连接
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int fd = accept(sock, (struct sockaddr *)&peer, &len);
if(fd >= 0){
return fd;
}
return -1;
}
};
如果我们要实现一个简单的select服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么这个select服务器的工作流程应该是这样的: (我们下面只关心读取是否就绪的情况)
1)首先需要注意的是:因为传入select函数的readfds、writefds和exceptfds都是输入输出型参数,当select函数返回时这些参数当中的值已经被修改了,因此每次调用select函数时都需要对其进行重新设置,timeout也是类似的道理
- 因为每次调用select函数之前都需要对readfds进行重新设置,所以需要定义一个fd_array数组保存与客户端已经建立的若干连接的文件描述符的值和监听套接字
- fd_array数组当中的文件描述符的值就是需要让select监视读事件是否就绪的文件描述符
- 我们的select服务器只是读取客户端发来的数据,因此只需要让select帮我们监视特定文件描述符的读事件,如果要同时让select帮我们监视特定文件描述符的读事件和写事件,则需要分别定义readfds和writefds,并定义两个数组分别保存需要被监视读事件和写事件的文件描述符,便于每次调用select函数前对readfds和writefds进行重新设置
2)初始化服务器,完成套接字的创建,绑定和监听
- fd_array数组用于保存监听套接字和已经与客户端建立连接的套接字,刚开始时就将监听套接字添加到fd_array数组当中
- 服务器刚开始运行时,fd_array数组当中只有监听套接字,因此select第一次调用时只需要监视监听套接字的读事件是否就绪,但每次调用accept获取到新连接后,都会将新连接对应的套接字添加到fd_array当中,后续select调用时就需要监视监听套接字和若干连接套接字的读事件是否就绪
3)服务器开始循环调用select函数,检测读事件是否就绪,如果就绪则执行对应的操作
4)每次调用select函数之前,都需要定义一个读文件描述符集readfds,并将fd_array当中的文件描述符依次设置进readfds当中,表示让select帮我们监视这些文件描述符的读事件是否就绪
**注意:**由于调用select时还需要传入被监视的文件描述符中最大文件描述符值+1,因此每次在遍历fd_array对readfds进行重新设置时,还需要记录最大文件描述符值
5)当select检测到数据就绪时会将读事件就绪的文件描述符设置进readfds当中,此时我们就能够得知哪些文件描述符的读事件就绪了,并对这些文件描述符进行对应的操作
-
如果读事件就绪的是监听套接字,则调用accept函数从底层全连接队列获取已经建立好的连接,并将该连接对应的套接字添加到fd_array数组当中
-
如果读事件就绪的是与客户端建立连接的套接字,则调用read函数读取客户端发来的数据并进行打印输出
-
服务器与客户端建立连接的套接字读事件就绪,也可能是因为客户端将连接关闭了,此时服务器应该调用close关闭该套接字,并将该套接字从fd_array数组当中清除,因为下一次不需要再监视该文件描述符的读事件了
具体如下:
服务器初始化完毕后(创建套接字,绑定,监听)就应该周期性的执行某种动作了,而select服务器要做的就是不断调用select函数,当事件就绪时对应执行某种动作即可,
- 首先,在select服务器开始死循环调用select函数之前,需要先定义一个fd_array数组,先把数组中所有的位置初始化为无效(假设-1是无效的),并将监听套接字添加到该数组当中,fd_array数组当中保存的就是需要被select监视读事件是否就绪的文件描述符,
- 此后,select服务器就不断调用select函数监视读事件是否就绪,每次调用select函数之前都需要重新设置readfds,具体设置过程就是遍历fd_array数组,将fd_array数组当中的有效的文件描述符(值不是-1的)添加到readfds当中,并同时记录最大的文件描述符值maxfd,因为后续调用select函数时需要将maxfd+1作为第一个参数传入,
- 当select函数返回后,如果返回值为0,则说明timeout时间耗尽,此时直接准备进行下一次select调用即可,如果select的返回值为-1,则说明select调用失败,此时也让服务器准备进行下一次select调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用select函数,
- 如果select的返回值大于0,则说明select函数调用成功,此时已经有文件描述符的读事件就绪,接下来就应该对就绪事件进行处理,
#include <iostream>
#include <string>
#include <sys/select.h>
#include "Sock.hpp"
#define NUM ((sizeof(fd_set))*8) //最大的位数 <==>最多可以等待的文件描述符个数
int fd_array[NUM]; //数组内容fd_array[i] >=0,认为是合法的fd,其值就是有效的文件描述符,如果是-1,该位置无效
static void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
//之后我们是这样启动程序的: ./select_server port
int main(int argc,char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port =(uint16_t)atoi(argv[1]);
int listen_sock = Sock::Socket();
Sock::Bind(listen_sock, port);//套接字和端口号绑定
Sock::Listen(listen_sock);//监听
//先将数组中的所有位置设置为无效
for(int i = 0;i<NUM;i++)
{
fd_array[i] = -1;
}
fd_array[0] = listen_sock;//将监听套接字添加到fd_array数组中的第0个位置
fd_set readfds;//读文件描述符集
//fd_array是用来保存具体的文件描述符 rreadfds是为了记录当前我们关心哪些文件描述符的读取就绪
//事件循环
for(;;)
{
FD_ZERO(&readfds);//每一次都要对位图重新设置,先清空
int max_fd = fd_array[0];//用于找当前最大的文件描述符
for (int i = 0; i < NUM; i++)
{
if (fd_array[i] == -1) //这个位置不是合法的文件描述符
continue;
//当前是合法的fd
FD_SET(fd_array[i], &readfds); //将有效位置的文件描述符添加到readfds,当前文件描述符的读取要被关注
if (max_fd < fd_array[i])
{
max_fd = fd_array[i]; //更新最大fd
}
}
// 我们的服务器上的所有的fd(包括listen_sock),都要交给select进行检测!! select一次可以等待多个文件描述符
//recv,read,write,send,accept : 只负责自己最核心的工作:即真正的读写
//对于listen_sock就是真正的accept,把链接从底层获取上来
int n = select(max_fd + 1, &readfds, nullptr, nullptr, nullptr);//timeout为空:阻塞等待
switch(n)
{
case -1: //select调用失败
std::cerr << "select error" << std::endl;
break;
case 0://超时 超时不算错误!
std::cout << "select timeout" << std::endl;
break;
default://有文件描述符就绪了
std::cout << "有fd对应的事件就绪啦!" << std::endl;
//....
break;
}
}
return 0;
}
由于当前服务器调用select函数时直接将timeout设置为了nullptr,因此select函数调用后会进行阻塞等待,而服务器在第一次调用select函数时只让select监视监听套接字的读事件,所以运行服务器后如果没有客户端发来连接请求,那么读事件就不会就绪,而服务器则会一直在第一次调用的select函数中进行阻塞等待
当我们借助telnet工具向select服务器发起连接请求后,select函数就会立马检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出,因为当前程序并没有对就绪事件进行处理,此后每次select函数一调用就会检测到读事件就绪并成功返回,因此会看到屏幕不断打印输出提示语句
测试timeout参数
如果服务器在调用select函数时将timeout的值设置为0,那么select函数调用后就会进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回,
此时如果select监视的文件描述符上有事件就绪,那么select函数的返回值就是大于0的,如果select监视的文件描述符上没有事件就绪,那么select的返回值就是等于0的
struct timeval timeout = {0, 0}; // 等待0s
int n = select(max_fd + 1, &readfds, nullptr, nullptr, &timeout);
switch(n)
{
case -1: //select调用失败
std::cerr << "select error" << std::endl;
break;
case 0://超时 超时不算错误!
std::cout << "select timeout" << std::endl;
break;
default://有文件描述符就绪了
std::cout << "有fd对应的事件就绪啦!" << std::endl;
//....
break;
}
运行服务器后如果没有客户端发来连接请求,那么select服务器就会一直调用select函数进行轮询检测,但每次检测时读事件都不就绪,因此每次select函数的返回值都是0,因此就会不断打印“select timeout”提示语句
当有客户端发来连接请求后,select在某次轮询检测时就会检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出
如果服务器在调用select函数时将timeout的值设置为特定的时间值,比如我们这里将timeout的值设置为5秒,那么select函数调用后的5秒内会进行阻塞等待,如果5秒后依旧没有读事件就绪,那么select函数将会进行超时返回,
我们可以将select函数超时返回和成功返回时timeout的值进行打印,以验证timeout是一个输入输出型参数,
struct timeval timeout = {5, 0}; // 等待5s
int n = select(max_fd + 1, &readfds, nullptr, nullptr, &timeout);// 等待5s.5s内有事件就绪就返回,5s后没有事件就绪就返回0,表示时间耗尽
switch(n)
{
case -1: //select调用失败
std::cerr << "select error" << std::endl;
break;
case 0://超时 超时不算错误!
std::cout << "timeout: " << timeout.tv_sec << std::endl;
break;
default://有文件描述符就绪了
std::cout << "有fd对应的事件就绪啦! timeout:" << timeout.tv_sec << std::endl;
//....
break;
}
运行服务器后如果没有客户端发来连接请求,那么每次select函数调用5秒后都会进行超时返回,并且每次打印输出timeout的值都是0,也就意味着timeout的时间是被耗尽了的
当有客户端发来连接请求后,在某次调用select函数时就会检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出
因为当前程序并没有对就绪事件进行处理,因此在第一次select检测到读事件就绪后,之后每次select函数一调用就会检测到读事件就绪并成功返回,因此会看到屏幕不断打印输出提示语句,并且后续打印输出timeout的值都是4,表示本次select检测到读事件就绪时timeout的剩余时间为4秒,
因为timeout和readfds、writefds与exceptfds一样,它们都是输入输出型参数,因此如果要使用timeout参数,那么在每次调用select函数之前也都需要对timeout的值进行重新设置,
事件处理
当select检测到有文件描述符的读事件就绪并成功返回后,接下来就应该对就绪事件进行处理了,
- 由于我们是不知道哪个文件描述符的时间就绪了,所以在进行事件处理时需要遍历fd_array数组当中的文件描述符,依次判断各个文件描述符对应的读事件是否就绪,如果就绪则需要进行事件处理
注意:当一个文件描述符的读事件就绪后,还需要进一步判断该文件描述符是否是监听套接字
-
如果是监听套接字的读事件就绪,那么就应该调用accept函数将底层的连接获取上来
- 但是光光调用accept将连接获取上来还不够,为了下一次调用select函数时能够让select帮我们监视新连接的读事件是否就绪,在连接获取上来后还应该将该连接对应的文件描述符添加到fd_array数组当中,这样在下一次调用select函数前对readfds重新设置时就能将该文件描述符添加进去了
-
如果是与客户端建立的连接对应的读事件就绪,那么就应该调用read函数读取客户端发来的数据
-
ssize_t s = recv(fd_array[i], recv_buffer, sizeof(recv_buffer) - 1, 0)
//这里虽然最后一个参数设为0是阻塞等待,但是实际上是不会阻塞的,因为读取事件已经就绪了, 这里少读取一个是因为我们把读到的内容当成是字符串,在最后位置放\0 -
如果读取成功则将读到的数据在服务器端进行打印, (s>0)
- 如果调用read函数读取失败(s<0)或者客户端关闭了连接(s == 0),那么select服务器也应该调用close函数关闭对应的连接
- 只关闭连接也是不够的,还应该将该连接对应的文件描述符从fd_array数组当中清除,否则后续调用的select函数还会帮我们监视该连接的读事件是否就绪,但实际已经不需要了
- 如果调用read函数读取失败(s<0)或者客户端关闭了连接(s == 0),那么select服务器也应该调用close函数关闭对应的连接
如何将对应的文件描述符从fd_array[i]从数组当中清除
实际非常简单,只需要把这个位置的值置为-1即可
需要注意的是:
- 1.当调用accept函数从底层获取上来连接后,不能立即调用read函数读取该连接当中的数据
- 因为此时新连接当中的数据可能并没有就绪,如果直接调用read函数可能需要进行阻塞等待,我们应该将这个等待过程交给select函数来完成
- 因此在获取完连接后直接将该连接对应的文件描述符添加到fd_array数组当中就行了,当该连接的读事件就绪时select函数会告知我们,此时因为读事件就绪了,那个时候我们再进行数据读取就不会被阻塞住了
- 2.添加文件描述符到fd_array数组当中,本质就是遍历fd_array数组,找到一个没有被使用的位置将该文件描述符添加进去即可
- 但有可能fd_array数组中全部的位置都已经被占用了,那么文件描述符就会添加失败,此时就只能将刚刚获取上来的连接对应的套接字进行关闭,因为此时服务器已经没有能力处理这个连接了,
std::cout << "有fd对应的事件就绪啦!" << std::endl;
//由于我们不知道是哪一个文件描述符对应的事件就绪了
//所以需要遍历fd_array数组依次判断各个文件描述符对应的读事件是否就绪
for(int i = 0;i<NUM;i++)
{
if(fd_array[i] == -1)//跳过无效的位置
continue;
//来到这,当前的fd都是有效的fd,但是合法的fd不一定是就绪的fd
//所以需要根据输入输出型参数readfds当中确定哪一个有效的fd就绪了
//(当前的这个readfds位图是内核返回给用户的:你所关心的文件描述符就绪的情况)
//FD_ISSET:检测一个文件描述符是否在该集合里被设置
//检测当前fd_array[i]对应的fd是否在readfds被设置,如果被设置,那说明该文件描述符读取事件就绪了
if(FD_ISSET(fd_array[i], &readfds))
{
std::cout << "sock: " << fd_array[i] << " 上面有了读事件,可以读取了" << std::endl;
//当前fd_array[i]的读取事件就绪了
if (fd_array[i] == listen_sock) //如果此时是监听套接字读取就绪了->此时应该accpet
{
std::cout << "listen_sock: " << listen_sock << " 有了新的链接到来" << std::endl;
int sock = Sock::Accept(listen_sock);
if (sock >= 0) //获取新连接成功
{
std::cout << "listen_sock: " << listen_sock << " 获取新的链接成功" << std::endl;
//获取链接成功是否可以recv/read了呢?绝对不能!
//新链接到来,不意味着有数据到来!!什么时候数据到来呢?不知道
//如果数据没到,而我们进行了recv就得阻塞等待挂起了
///可是谁可以最清楚的知道那些fd读取是否就绪了呢?select!
//但是现在无法直接将fd设置进select,但是,好在我们有fd_array[]!
//我们可以把当前的文件描述符设置到fd_array[]
//下一轮循环就会把这个fd放到位图中,下次就可以检测这个文件描述符读取是否就绪
//找fd_array数组没有被使用的位置
int pos = 1;
for (; pos < NUM; pos++)
{
if (fd_array[pos] == -1)
break;
}
// case1. 找到了一个位置没有被使用,把当前文件描述符放到pos位置
if (pos < NUM)
{
std::cout << "新链接: " << sock << " 已经被添加到了数组[" << pos << "]的位置" << std::endl;
fd_array[pos] = sock;
}
else// case2. 找完了所有的fd_array[],都没有找到没有被使用位置
{
// 说明服务器已经满载,没法处理新的请求了
std::cout << "服务器已经满载了,关闭新的链接" << std::endl;
close(sock);
}
}
}
else // 普通的文件描述符fd_array[i]的读事件就绪啦!
{
// 此时可以进行读取啦,recv/read
// 可是,本次读取就一定能读完吗?不一定,因为TCP存在流量控制,拥塞控制等处理手段
//假设读完也可能存在数据包粘包问题,例如:读取到了1.5个数据包
// 但是,我们今天没法解决!我们今天没有场景!仅仅用来测试
std::cout << "sock: " << fd_array[i] << " 上面有普通读取" << std::endl;
char recv_buffer[1024] = {0};
//此时读取不会阻塞,因为读取事件已经就绪了
ssize_t s = recv(fd_array[i], recv_buffer, sizeof(recv_buffer) - 1, 0);
if (s > 0)
{
recv_buffer[s] = '\0';
std::cout << "client[ " << fd_array[i] << "]# " << recv_buffer << std::endl;
}
else if (s == 0) //对端关闭了链接
{
std::cout << "sock: " << fd_array[i] << "关闭了, client退出啦!" << std::endl;
//对端关闭了链接,我们也要把这个链接关掉
close(fd_array[i]);//关闭链接
//在数组中去掉这个文件描述符
std::cout << "已经在数组下标fd_array[" << i << "]"<< "中,去掉了sock: " << fd_array[i] << std::endl;
fd_array[i] = -1;
}
else //读取失败
{
close(fd_array[i]);
std::cout << "已经在数组下标fd_array[" << i << "]"<< "中,去掉了sock: " << fd_array[i] << std::endl;
fd_array[i] = -1;
}
}
}
}
select服务器测试
虽然当前的select服务器是一个单进程的服务器,但它却可以同时为多个客户端提供服务,根本原因就是因为select函数调用后会告知select服务器是哪个客户端对应的连接事件就绪了,此时select服务器就可以读取对应客户端发来的数据,读取完后又会调用select函数等待某个客户端连接的读事件就绪
当服务器检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从fd_array数组当中清除
Select_server.cpp
#include <iostream>
#include <string>
#include <sys/select.h>
#include "Sock.hpp"
#define NUM ((sizeof(fd_set))*8) //最大的位数 <==>最多可以等待的文件描述符个数
int fd_array[NUM]; //数组内容fd_array[i] >=0,认为是合法的fd,其值就是有效的文件描述符,如果是-1,该位置无效
static void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " port" << std::endl;
}
//之后我们是这样启动程序的: ./select_server port
int main(int argc,char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
uint16_t port =(uint16_t)atoi(argv[1]);
int listen_sock = Sock::Socket();
Sock::Bind(listen_sock, port);//套接字和端口号绑定
Sock::Listen(listen_sock);//监听
//先将数组中的所有位置设置为无效
for(int i = 0;i<NUM;i++)
{
fd_array[i] = -1;
}
fd_array[0] = listen_sock;//将监听套接字添加到fd_array数组中的第0个位置
fd_set readfds;//读文件描述符集
//fd_array是用来保存具体的文件描述符 rreadfds是为了记录当前我们关心哪些文件描述符的读取就绪
//事件循环
for(;;)
{
FD_ZERO(&readfds);//每一次都要对位图重新设置,先清空
int max_fd = fd_array[0];//找当前最大的文件描述符
for (int i = 0; i < NUM; i++)
{
if (fd_array[i] == -1) //这个位置不是合法的文件描述符
continue;
//当前是合法的fd
FD_SET(fd_array[i], &readfds); //将有效位置的文件描述符添加到readfds,当前文件描述符的读取要被关注
if (max_fd < fd_array[i])
{
max_fd = fd_array[i]; //更新最大fd
}
}
// 我们的服务器上的所有的fd(包括listen_sock),都要交给select进行检测!! select一次可以等待多个文件描述符
// recv,read,write,send,accept : 只负责自己最核心的工作:即真正的读写
//对于listen_sock就是真正的accept,把链接从底层获取上来
int n = select(max_fd + 1, &readfds, nullptr, nullptr, nullptr); //timeout为空:阻塞等待
switch(n)
{
case -1: //select调用失败
std::cerr << "select error" << std::endl;
break;
case 0://超时 超时不算错误!
std::cout << "select timeout" << std::endl;
break;
default://有文件描述符就绪了
std::cout << "有fd对应的事件就绪啦!" << std::endl;
//由于我们不知道是哪一个文件描述符对应的事件就绪了
//所以需要遍历fd_array数组依次判断各个文件描述符对应的读事件是否就绪
for(int i = 0;i<NUM;i++)
{
if(fd_array[i] == -1)//跳过无效的位置
continue;
//来到这,当前的fd都是有效的fd,但是合法的fd不一定是就绪的fd
//所以需要根据输入输出型参数readfds当中确定哪一个有效的fd就绪了
//(当前的这个readfds位图是内核返回给用户的:你所关心的文件描述符就绪的情况)
//FD_ISSET:检测一个文件描述符是否在该集合里被设置
//检测当前fd_array[i]对应的fd是否在readfds被设置,如果被设置,那说明该文件描述符读取事件就绪了
if(FD_ISSET(fd_array[i], &readfds))
{
std::cout << "sock: " << fd_array[i] << " 上面有了读事件,可以读取了" << std::endl;
//当前fd_array[i]的读取事件就绪了
if (fd_array[i] == listen_sock) //如果此时是监听套接字读取就绪了->此时应该accpet
{
std::cout << "listen_sock: " << listen_sock << " 有了新的链接到来" << std::endl;
int sock = Sock::Accept(listen_sock);
if (sock >= 0) //获取新连接成功
{
std::cout << "listen_sock: " << listen_sock << " 获取新的链接成功" << std::endl;
//获取链接成功是否可以recv/read了呢?绝对不能!
//新链接到来,不意味着有数据到来!!什么时候数据到来呢?不知道
//如果数据没到,而我们进行了recv就得阻塞等待挂起了
///可是谁可以最清楚的知道那些fd读取是否就绪了呢?select!
//但是现在无法直接将fd设置进select,但是,好在我们有fd_array[]!
//我们可以把当前的文件描述符设置到fd_array[]
//下一轮循环就会把这个fd放到位图中,下次就可以检测这个文件描述符读取是否就绪
//找fd_array数组没有被使用的位置
int pos = 1;
for (; pos < NUM; pos++)
{
if (fd_array[pos] == -1)
break;
}
// case1. 找到了一个位置没有被使用,把当前文件描述符放到pos位置
if (pos < NUM)
{
std::cout << "新链接: " << sock << " 已经被添加到了数组[" << pos << "]的位置" << std::endl;
fd_array[pos] = sock;
}
else// case2. 找完了所有的fd_array[],都没有找到没有被使用位置
{
// 说明服务器已经满载,没法处理新的请求了
std::cout << "服务器已经满载了,关闭新的链接" << std::endl;
close(sock);
}
}
}
else // 普通的文件描述符fd_array[i]的读事件就绪啦!
{
// 此时可以进行读取啦,recv/read
// 可是,本次读取就一定能读完吗?不一定,因为TCP存在流量控制,拥塞控制等处理手段
//假设读完也可能存在数据包粘包问题,例如:读取到了1.5个数据包
// 但是,我们今天没法解决!我们今天没有场景!仅仅用来测试
std::cout << "sock: " << fd_array[i] << " 上面有普通读取" << std::endl;
char recv_buffer[1024] = {0};
//此时读取不会阻塞,因为读取事件已经就绪了
ssize_t s = recv(fd_array[i], recv_buffer, sizeof(recv_buffer) - 1, 0);
if (s > 0)
{
recv_buffer[s] = '\0';
std::cout << "client[ " << fd_array[i] << "]# " << recv_buffer << std::endl;
}
else if (s == 0) //对端关闭了链接
{
std::cout << "sock: " << fd_array[i] << "关闭了, client退出啦!" << std::endl;
//对端关闭了链接,我们也要把这个链接关掉
close(fd_array[i]);//关闭链接
//在数组中去掉这个文件描述符
std::cout << "已经在数组下标fd_array[" << i << "]"<< "中,去掉了sock: " << fd_array[i] << std::endl;
fd_array[i] = -1;
}
else //读取失败
{
close(fd_array[i]);
std::cout << "已经在数组下标fd_array[" << i << "]"<< "中,去掉了sock: " << fd_array[i] << std::endl;
fd_array[i] = -1;
}
}
}
}
break;
}
}
return 0;
}
存在的问题
当前的select服务器实际还存在一些问题:
- 服务器没有对客户端发进行响应,select服务器如果要向客户端发送数据,不能直接调用write函数,因为调用write函数时实际也分为“等”和“拷贝”两步,我们也应该将“等”的这个过程交给select函数,因此在每次调用select函数之前,除了需要重新设置readfds还需要重新设置writefds,并且还需要一个数组来保存需要被监视写事件是否就绪的文件描述符,当某一文件描述符的写事件就绪时我们才能够调用write函数向客户端发送数据,
- 没有定制协议,代码中读取数据时并没有按照某种规则进行读取,此时就可能造成粘包问题,根本原因就是因为我们没有定制协议,比如HTTP协议规定在读取底层数据时读取到空行就表明读完了一个HTTP报头,此时再根据HTTP报头当中的Content-Length属性得知正文的长度,最终就能够读取到一个完整的HTTP报文,HTTP协议通过这种方式就避免了粘包问题,
- 没有对应的输入输出缓冲区,代码中直接将读取的数据存储到了字符数组buffer当中,这是不严谨的,因为本次数据读取可能并没有读取到一个完整的报文,此时服务器就不能进行数据的分析处理,应该将读取到的数据存储到一个输入缓冲区当中,当读取到一个完整的报文后再让服务器进行处理,此外,如果服务器要能够对客户端进行响应,那么服务器的响应数据也不应该直接调用write函数发送给客户端,应该先存储到一个输出缓冲区当中,因为响应数据可能很庞大,无法一次发送完毕,可能需要进行分批发送,
select的优点
- 可以同时等待多个文件描述符,并且只负责等待,实际的IO操作由accept、read、write等接口来完成,这些接口在进行IO操作时不会被阻塞,
- select同时等待多个文件描述符,因此可以将“等”的时间重叠,提高了IO的效率,
当然,这也是所有多路转接接口的优点,
select的缺点
- 每次调用select,都需要手动设置fd集合,表示你这一次关心哪些文件描述符上的什么事件就绪, 从接口使用角度来说也非常不便,
- 每次调用前要重新设置fd集,调用后要遍历数组检测就绪fd
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大,同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
- 即:select可能需要高频地进行用户内核空间之间的相互拷贝
- select内部实现是通过遍历修改fd_set结构的
- select可监控的文件描述符数量太少
- fd_set结构导致select能够检测fd的个数有上限
1)之前我们说fd_set的时候就验证过:其实select可监控的文件描述符个数就是1024个, 因此我们实现的select服务器当中将fd_array数组的大小设置为1024是足够的,因为readfds当中最多就只能添加1024个文件描述符,但不同环境下fd_set的大小可能是不同的,并且fd_set的大小也是可以调整的(涉及重新编译内核),因此之前select服务器当中对NUM的宏定义正确写法应该是这样的 #define NUM (sizeof(fd_set)*8)
2)一个进程能打开的文件描述符个数
- 进程控制块task_struct当中有一个files指针,该指针指向一个struct files_struct结构,进程的文件描述符表fd_array就存储在该结构当中,文件描述符表fd_array的大小定义为
NR_OPEN_DEFAULT
,NR_OPEN_DEFAULT
的值实际就是32 - 但并不意味着一个进程最多只能打开32个文件描述符,进程能打开的文件描述符个数实际是可以扩展的,比如我当前使用的云服务器默认就是把进程能打开的文件描述符设置得很高的,通过
ulimit -a
命令就可以看到进程能打开的文件描述符上限
因此select可监控的文件描述符个数太少是一个很大的问题,比如select可监控的文件描述符个数是1024,除去其中的一个监听套接字,那么select服务器最多只能连接1023个客户端
select的适用场景
多路转接接口select、poll和epoll需要在一定的场景下使用,如果场景选择的不适宜,可能会适得其反
- 多路转接接口一般适用于多连接,且多连接中只有少部分连接比较活跃,因为少量连接比较活跃,也就意味着几乎所有的连接在进行IO操作时,都需要花费大量时间来等待事件就绪,此时使用多路转接接口就可以将这些等的事件进行重叠,提高IO效率
- 对于多连接中大部分连接都很活跃的场景,其实并不适合使用多路转接,因为每个连接都很活跃,也就意味着任何时刻每个连接上的事件基本都是就绪的,此时根本不需要动用多路转接接口来帮我们进行等待,毕竟使用多路转接接口也是需要花费系统的时间和空间资源的
多连接中只有少量连接是比较活跃的,比如聊天工具,我们登录QQ后大部分时间其实是没有聊天的,此时服务器端不可能调用一个read函数阻塞等待读事件就绪
多连接中大部分连接都很活跃,比如企业当中进行数据备份时,两台服务器之间不断在交互数据,这时的连接是特别活跃的,几乎不需要等的过程,也就没必要使用多路转接接口了