目录
I/O多路转接之select
select初识
select函数
socket就绪条件
select基本工作流程
select服务器
select的优点
select的缺点
select的适用场景
select是系统提供的一个多路转接接口。
select函数
select函数的函数原型如下:
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
参数说明:
参数timeout的取值:
返回值说明:
select调用失败时,错误码可能被设置为:
EBADF
:文件描述符为无效的或该文件已关闭。EINTR
:此调用被信号所中断。EINVAL
:参数nfds为负值。ENOMEM
:核心内存不足。fd_set结构
fd_set结构与sigset_t结构类似,fd_set本质也是一个位图,用位图中对应的位来表示要监视的文件描述符。
调用select函数之前就需要用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的全部位
timeval结构
传入select函数的最后一个参数timeout,就是一个指向timeval结构的指针,timeval结构用于描述一段时间长度,该结构当中包含两个成员,其中tv_sec表示的是秒,tv_usec表示的是微秒。
读就绪
SO_RCVLOWAT
,此时可以无阻塞的读取该文件描述符,并且返回值大于0。写就绪
SO_SNDLOWAT
,此时可以无阻塞的写,并且返回值大于0。异常就绪
注:带外数据和TCP的紧急模式相关,TCP报头当中的URG标志位和16位紧急指针搭配使用,就能够发送/接收带外数据。
如果我们要实现一个简单的select服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么这个select服务器的工作流程应该是这样的:
说明一下:
这其中还有很多细节,下面我们就来实现这样一个select服务器。
编写思路
Socket类
首先我们可以编写一个Socket类,对套接字相关的接口进行一定程度的封装。
代码如下:
#pragma once
#include "Err.hpp"
#include "Log.hpp"
#include
#include
#include
#include
#include
#include
#include
#include
const static int defaultfd = -1;
const static int gbacklog = 5;
class Sock
{
public:
Sock() : sockfd_(defaultfd)
{
}
void Socket() // 创建套接字
{
sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd_ < 0)
{
logMessage(Fatal, "sockfd create error:%s,[code:%d]", strerror(errno), errno);
exit(SOCKET_ERR);
}
logMessage(Info, "socket create sucessfully");
// 设置地址是复用的,即端口号是复用的
int opt = 1;
int ret = setsockopt(sockfd_, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
if (ret < 0)
{
logMessage(Error, "setsockopt error:%s,[code:%d]", strerror(errno), errno);
}
logMessage(Info, "setsockopt sucessfully");
}
void Bind(uint16_t port)
{
struct sockaddr_in loacal;
memset(&loacal, 0, sizeof(loacal));
loacal.sin_family = AF_INET;
loacal.sin_port = htons(port);
loacal.sin_addr.s_addr = INADDR_ANY;
int ret = bind(sockfd_, reinterpret_cast(&loacal), sizeof(loacal));
if (ret < 0)
{
logMessage(Fatal, "sockfd bind error:%s,[code:%d]", strerror(errno), errno);
exit(BIND_ERR);
}
logMessage(Info, "sock bind sucessfully");
}
void Listen()
{
int ret = listen(sockfd_, gbacklog);
if (ret < 0)
{
logMessage(Fatal, "sockfd listen error:%s,[code:%d]", strerror(errno), errno);
exit(LISTEN_ERR);
}
logMessage(Info, "sock listen sucessfully");
}
int Accept(std::string *clinetip, uint16_t *clientport)
{
struct sockaddr_in client;
memset(&client, 0, sizeof(client));
socklen_t len = sizeof(client);
int sockfd = accept(sockfd_, reinterpret_cast(&client), &len);
if (sockfd < 0)
{
logMessage(Warning, "sockfd accept error:%s,[code:%d]", strerror(errno), errno);
return -1;
}
*clinetip = inet_ntoa(client.sin_addr);
*clientport = ntohs(client.sin_port);
logMessage(Info, "sock accept sucessfully,clientip:%s, clientport:%d", clinetip->c_str(), clientport);
return sockfd;
}
void Connect(const std::string &serverip, const uint16_t &serverport)
{
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());
int ret = connect(sockfd_, (struct sockaddr *)&server, sizeof(server));
if (ret < 0)
{
logMessage(Fatal, "sockfd connect error:%s,[code:%d]", strerror(errno), errno);
exit(CONNECT_ERR);
}
logMessage(Info, "sock connect sucessfully");
}
void Close()
{
if (sockfd_ != defaultfd)
{
close(sockfd_);
}
}
int Fd()
{
return sockfd_;
}
~Sock()
{
}
private:
int sockfd_;
};
SelectServer类
现在编写SelectServer类,因为我当前使用的是云服务器,所以编写的select服务器在绑定时不需要显示绑定IP地址,直接将IP地址设置为INADDR_ANY
就行了,所以类当中只包含监听套接字和端口号两个成员变量。
代码如下:
#pragma once
#include "Sock.hpp"
#include "Err.hpp"
#include "Log.hpp"
#include
#include
#include
#include
#include
#include
const static int N = sizeof(fd_set) * 8; // select可以管理文件描述符为fd_set类型比特位
const static uint16_t defaultport = 8888;
class SelectServer
{
public:
SelectServer(int port = defaultport)
: port_(defaultport)
{
}
void InitServer()
{
listensockfd_.Socket();
listensockfd_.Bind(port_);
listensockfd_.Listen();
for (int i = 0; i < N; ++i)
{
fd_arry[i] = defaultfd;
}
fd_arry[0] = listensockfd_.Fd(); // 默认0号下标存储的是listen套接字
}
void Start()
{
// 1. 这里我们能够直接获取新的链接吗?
// 2. 最开始的时候,我们的服务器是没有太多的sock的,甚至只有一个sock!listensock
// 3. 在网络中, 新连接到来被当做 读事件就绪!
// listensock_.Accept(); 不能!
while (true)
{
// 1.设置我们需要关心的文件描述符,用户告诉内核
fd_set rfds;
FD_ZERO(&rfds);
int maxsockfd = listensockfd_.Fd();
for (int i = 0; i < N; ++i)
{
if (fd_arry[i] == defaultfd)
continue;
FD_SET(fd_arry[i], &rfds);
if (fd_arry[i] > maxsockfd)
maxsockfd = fd_arry[i];
}
// 2. 调用select接口
int n = select(maxsockfd + 1, &rfds, nullptr, nullptr, nullptr);
if (n > 0)
{
// 有事件就绪了,开始处理
logMessage(Info, "有事件就绪了,开始处理相关事件");
HandlerEvent(rfds);
}
else if (n == 0)
{
logMessage(Info, "select timeout ...");
}
else
{
logMessage(Warning, "select error:%s [code:%d]", strerror(errno), errno);
}
}
}
void HandlerEvent(fd_set &rfds)
{
for (int i = 0; i < N; ++i)
{
int fd = fd_arry[i];
if (fd == defaultfd)
continue;
if ((FD_ISSET(fd, &rfds)) && (fd == listensockfd_.Fd())) // listen套接字有读事件发生了
{
Accepter();
}
if ((FD_ISSET(fd, &rfds)) && (fd != listensockfd_.Fd())) // 其它的套接字有读事件发生了
{
ServerIO(i);
}
}
}
void Accepter()
{
// 1.接受客户
std::string clientip;
uint16_t clientport;
int sockfd = listensockfd_.Accept(&clientip, &clientport);
if (sockfd < 0)
return;
// 2. 存储sockfd到fd_array数组中
int index = 0;
while (index < N)
{
if (fd_arry[index] == defaultfd)
break;
++index;
}
if (index < N)
{
fd_arry[index] = sockfd;
logMessage(Info, "存储%d套接字成功!", sockfd);
}
else
{
close(sockfd);
logMessage(Warning, "存储%d失败,fd_arry[] is full", sockfd);
}
}
void ServerIO(int i)
{
int fd=fd_arry[i];
char buffer[1024];
int n=recv(fd,buffer,sizeof(buffer)-1,0);
if(n>0)
{
buffer[n]=0;
std::cout<<"client#"<
运行服务器
服务器初始化完毕后就应该周期性的执行某种动作了,而select服务器要做的就是不断调用select函数,当事件就绪时对应执行某种动作即可。
说明一下: 为了测试timeout不同取值时的不同效果,当有事件就绪时这里先只打印一句提示语句。
timeout测试
在运行服务器时需要先实例化一个SelectServer类对象,对select服务器进行初始化后就可以调用Start成员函数运行服务器了。
由于当前服务器调用select函数时直接将timeout设置为了nullptr,因此select函数调用后会进行阻塞等待。而服务器在第一次调用select函数时只让select监视监听套接字的读事件,所以运行服务器后如果没有客户端发来连接请求,那么读事件就不会就绪,而服务器则会一直在第一次调用的select函数中进行阻塞等待。
当我们借助telnet工具向select服务器发起连接请求后,select函数就会立马检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出,因为当前程序并没有对就绪事件进行处理,此后每次select函数一调用就会检测到读事件就绪并成功返回,因此会看到屏幕不断打印输出提示语句。
如果服务器在调用select函数时将timeout的值设置为0,那么select函数调用后就会进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回。
此时如果select监视的文件描述符上有事件就绪,那么select函数的返回值就是大于0的,如果select监视的文件描述符上没有事件就绪,那么select的返回值就是等于0的。
运行服务器后如果没有客户端发来连接请求,那么select服务器就会一直调用select函数进行轮询检测,但每次检测时读事件都不就绪,因此每次select函数的返回值都是0,因此就会不断打印“timeout…”提示语句。
当有客户端发来连接请求后,select在某次轮询检测时就会检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出。
如果服务器在调用select函数时将timeout的值设置为特定的时间值,比如我们这里将timeout的值设置为5秒,那么select函数调用后的5秒内会进行阻塞等待,如果5秒后依旧没有读事件就绪,那么select函数将会进行超时返回。
我们可以将select函数超时返回和成功返回时timeout的值进行打印,以验证timeout是一个输入输出型参数。
运行服务器后如果没有客户端发来连接请求,那么每次select函数调用5秒后都会进行超时返回,并且每次打印输出timeout的值都是0,也就意味着timeout的时间是被耗尽了的。
当有客户端发来连接请求后,在某次调用select函数时就会检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出。
因为当前程序并没有对就绪事件进行处理,因此在第一次select检测到读事件就绪后,之后每次select函数一调用就会检测到读事件就绪并成功返回,因此会看到屏幕不断打印输出提示语句,并且后续打印输出timeout的值都是4,表示本次select检测到读事件就绪时timeout的剩余时间为4秒。
因为timeout和readfds、writefds与exceptfds一样,它们都是输入输出型参数,因此如果要使用timeout参数,那么在每次调用select函数之前也都需要对timeout的值进行重新设置。
事件处理
当select检测到有文件描述符的读事件就绪并成功返回后,接下来就应该对就绪事件进行处理了,这里编写一个HandlerEvent函数,当读事件就绪后就调用该函数进行事件处理。
说明一下:
select服务器测试
至此select服务器编写完毕,重新编译后运行服务器,并用telnet工具连接我们的服务器,此时通过telnet向服务器发送的数据就能够被服务器读到并且打印输出了。
此外,虽然当前的select服务器是一个单进程的服务器,但它却可以同时为多个客户端提供服务,根本原因就是因为select函数调用后会告知select服务器是哪个客户端对应的连接事件就绪了,此时select服务器就可以读取对应客户端发来的数据,读取完后又会调用select函数等待某个客户端连接的读事件就绪。
当服务器检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从fd_array数组当中清除。
存在的一些问题
当前的select服务器实际还存在一些问题:
当然,这也是所有多路转接接口的优点。
select可监控的文件描述符个数
调用select函数时传入的readfds、writefds以及exceptfds都是fd_set结构的,fd_set结构本质是一个位图,它用每一个比特位来标记一个文件描述符,因此select可监控的文件描述符个数是取决于fd_set类型的比特位个数的。
我们可以通过以下代码来看看fd_set类型有多少个比特位。
#include
#include
int main()
{
std::cout << sizeof(fd_set)* 8 << std::endl;
return 0;
}
运行代码后可以看到,其实select可监控的文件描述符个数就是1024个。
因此我们实现的select服务器当中将fd_array数组的大小设置为1024是足够的,因为readfds当中最多就只能添加1024个文件描述符,但不同环境下fd_set的大小可能是不同的,并且fd_set的大小也是可以调整的(涉及重新编译内核),因此之前select服务器当中对NUM的宏定义正确写法应该是这样的。
#define NUM (sizeof(fd_set)*8)
一个进程能打开的文件描述符个数
进程控制块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、poll和epoll,需要在一定的场景下使用,如果场景选择的不适宜,可能会适得其反。
多连接中只有少量连接是比较活跃的,比如聊天工具,我们登录QQ后大部分时间其实是没有聊天的,此时服务器端不可能调用一个read函数阻塞等待读事件就绪。
多连接中大部分连接都很活跃,比如企业当中进行数据备份时,两台服务器之间不断在交互数据,这时的连接是特别活跃的,几乎不需要等的过程,也就没必要使用多路转接接口了。