目录
1.2.参数二: readfds, writefds, exceptfds
1.2.2.readfds, writefds, exceptfds
1.2.3.怎么理解 readfds, writefds, exceptfds是输入输出型参数
前言
我们今天要讲的select,select的原理就像下面的赵六一样。
赵六去钓鱼,他是个小土豪,赵六手里拿了一堆鱼竿,目测有几百根鱼竿,赵六到达河边,首先就把几百根鱼竿每隔几米插上去,总共插了好几百米的鱼竿,然后赵六就依次遍历这些鱼竿,哪个鱼竿上的鱼漂动了,赵六就把这根鱼竿上的鱼钓上来,然后接下来赵六就又继续重复之前遍历鱼竿的动作进行钓鱼了。
一,select函数
select是我们学习的第一个多路转接IO接口,我们知道IO是由等待和拷贝两部分组成的。select只负责IO过程中等待的这一步,也就是说,用户可能关心一些sock上的读事件,想要从sock中读取数据,直接读取,可能recv调用会阻塞,等待数据到来,而此时服务器进程就会被阻塞挂起,但服务器挂起就完蛋了,服务器就无法给客户提供服务,可能会产生很多无法预料的不好影响,万一客户正转账呢,服务器突然挂起了,客户的钱没了,但商家这里又没有收到钱,客户找谁说理去啊,所以服务器挂起是一个问题,我们要避免产生这样的问题。
select函数是I/O多路复用的经典实现,其基本原型如下:
select函数的功能
select()函数允许程序监视多个文件描述符,等待所监视的一个或者多个文件描述符变为“准备好”的状态。所谓的”准备好“状态是指:文件描述符不再是阻塞状态,可以用于某类IO操作了,包括可读,可写,发生异常三种。
参数详解
1.1.参数一:nfds
当程序运行时,程序其实会在select这里进行等待,遍历一次底层的多个fd,看其中哪个fd就绪了,然后就将就绪的fd返回给上层,select的第一个参数nfds代表监视的fd中最大的fd值+1,其实就是select在底层需要遍历所有监视的fd,而这个nfds参数其实就是告知select底层遍历的范围是多大
1.2.参数二: readfds, writefds, exceptfds
1.2.1.fd_set类型和相关操作宏
fd_set是一个通过位图来管理文件描述符集合的数据结构,它允许高效地测试和修改集合中的成员。
- fd_set类型本质是一个位图,位图的位置 表示 相对应的文件描述符,内容表示该文件描述符是否有效,1代表该位置的文件描述符有效,0则表示该位置的文件描述符无效。
- 如果将文件描述符2,3设置位图当中,则位图表示的是为1100。
- fd_set的上限是1024个文件描述符。
由于文件描述符是整数,且通常范围有限(尤其是在UNIX和类UNIX系统中),因此使用位图来表示它们是一种非常有效的空间和时间优化方法。
- FD_SET(fd, &set):此宏将文件描述符fd添加到set集合中。它实际上是将set中与fd对应的位设置为1。
- FD_CLR(fd, &set):此宏从set集合中移除文件描述符fd。它实际上是将set中与fd对应的位清零。
- FD_ISSET(fd, &set):此宏检查文件描述符fd是否已经被加入到set集合中。如果set中与fd对应的位为1,则返回非零值(真),否则返回0(假)。
- FD_ZERO(&set):此宏用于清空set集合中的所有文件描述符,即将集合中的所有位都设置为0。这是在使用set之前的一个好习惯,以确保集合从一个已知的状态开始。
我们看个例子
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/types.h>
#include <sys/socket.h>
int main() {
// 假设fd是一个已经打开的文件描述符,这里我们用socket作为示例
int fd = socket(AF_INET, SOCK_STREAM, 0); // 创建一个socket,实际使用中需要设置地址并连接
if (fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 创建一个文件描述符集合
fd_set readfds;
// 清空集合
FD_ZERO(&readfds);
// 将文件描述符fd添加到集合中
FD_SET(fd, &readfds);
// 假设我们想要等待这个fd变得可读,最长等待时间为5秒
struct timeval tv;
tv.tv_sec = 5; // 秒
tv.tv_usec = 0; // 微秒
// 使用select等待文件描述符变得可读
int ret = select(fd + 1, &readfds, NULL, NULL, &tv);
if (ret == -1) {
perror("select");
close(fd); // 不要忘记关闭文件描述符
exit(EXIT_FAILURE);
} else if (ret == 0) {
printf("Timeout occurred! No data after 5 seconds.\n");
close(fd); // 即使没有数据,也要关闭文件描述符
} else {
// 检查fd是否就绪
if (FD_ISSET(fd, &readfds)) {
printf("Data is available now on fd %d.\n", fd);
// 在这里处理数据,例如使用read()函数读取数据
// 假设处理完数据后,我们不再需要等待这个fd
// 可以在这里调用FD_CLR来从集合中移除它,但在这个简单的例子中我们直接关闭它
close(fd);
}
}
return 0;
}
1.2.2.readfds, writefds, exceptfds
这三个参数都是输入输出型参数
readfds
- readfds:这是一个指向fd_set的指针,用于指定程序关心的、希望进行读操作的文件描述符集合。如果select函数返回时,某个文件描述符在该集合中被标记为就绪(即可以进行无阻塞的读操作),则可以通过FD_ISSET宏来检查。
- readfds是 等待读事件的文件描述符集合,.如果不关心读事件(缓冲区有数据),则可以传NULL值。
- 应用进程和内核都可以设置readfds,应用进程设置readfds是为了通知内核去等待readfds中的文件描述符的读事件,而内核设置readfds是为了告诉应用进程哪些读事件生效
writefds
- writefds:同样是一个指向fd_set的指针,但这次它用于指定程序希望进行写操作的文件描述符集合。如果select返回时某个文件描述符在该集合中被标记为就绪(即可以进行无阻塞的写操作),则同样可以通过FD_ISSET宏来检查。
- 与readfds类似,writefds是等待写事件(缓冲区中是否有空间)的集合,如果不关心写事件,则可以传值NULL。
exceptfds
- exceptfds:这个参数也是指向fd_set的指针,用于指定程序希望监视异常条件的文件描述符集合。这里的“异常”通常指的是网络套接字上的带外数据(out-of-band data)到达,或者其他一些非标准的I/O事件。
- 如果内核等待相应的文件描述符发生异常,则将失败的文件描述符设置进exceptfds中,如果不关心错误事件,可以传值NULL。
1.2.3.怎么理解 readfds, writefds, exceptfds是输入输出型参数
- 输入方面:
- 在调用
select
之前,调用者会设置这三个参数指向的fd_set
集合,以指定哪些文件描述符(fd)是调用者感兴趣的。具体来说,readfds
集合包含了调用者想要检查是否有数据可读的文件描述符,writefds
集合包含了调用者想要检查是否可以写入数据的文件描述符,而exceptfds
集合则包含了调用者想要检查是否有异常条件(如带外数据、连接挂断等)的文件描述符。
- 在调用
- 输出影响方面:
- 当
select
调用返回时,这三个集合会被select
函数内部修改,以反映哪些文件描述符在调用期间变得就绪或遇到异常条件。具体来说,如果某个文件描述符在select
等待期间变得可读、可写或出现异常,那么相应的集合中的该文件描述符的位将被设置(如果它之前没有被设置的话)。但是,这并不意味着select
在这些集合中添加了新的文件描述符或移除了原有的文件描述符;它只是在修改集合中文件描述符的“就绪”状态位。
- 当
1.3.参数三: timeout
1.3.1.timeval结构体
struct timeval 是一个在多种编程环境中,尤其是在 UNIX 和类 UNIX 系统(包括 Linux)的 C 语言标准库中定义的结构体,用于表示时间间隔或时间点。他的定义如下
struct timeval {
long tv_sec; // seconds
long tv_usec; // microseconds
};
它通常与需要精确到微秒(microseconds)的时间操作的函数一起使用,比如 select(), gettimeofday(), setitimer(), 和 utimes() 等。
这个结构体包含两个成员:
- long tv_sec;:这个成员表示自 Unix 纪元(即 1970 年 1 月 1 日 00:00:00 UTC)以来的秒数。它是一个长整型(long),通常可以存储非常大的数,足以表示从 Unix 纪元到现在的时间(以秒为单位)。
- long tv_usec;:这个成员表示秒之后的微秒数。它也是一个长整型(long),但用于存储 0 到 999999 之间的值,表示在 tv_sec 所表示的秒之后,再过去多少微秒。
这两个成员结合起来,就可以精确地表示一个时间点或时间间隔,精确到微秒级别。
例如,如果你想要表示一个从 Unix 纪元开始算起,经过了 123 秒又 456789 微秒的时间点,你可以这样设置 struct timeval 结构体:
struct timeval time;
time.tv_sec = 123;
time.tv_usec = 456789;
这个结构体经常与 gettimeofday() 函数一起使用,以获取当前时间(从 Unix 纪元开始的时间,精确到微秒)。例如:
#include <sys/time.h>
#include <stdio.h>
int main() {
struct timeval now;
gettimeofday(&now, NULL);
printf("Current time: %ld.%06ld\n", now.tv_sec, now.tv_usec);
return 0;
}
这段代码会输出当前的时间,格式为秒数和微秒数(微秒数前面补零至6位)。
1.3.1.timeout参数的设定
这是一个输入型参数!!
1.4.返回值
1.5.select的工作流程
应用进程和内核都需要从readfds和writefds获取信息,其中,内核需要从readfds和writefds知道哪些文件描述符需要等待,应用进程需要从readfds和writefds中知道哪些文件描述符的事件就绪.
如果我们要不断轮询等待文件描述符,则应用进程需要不断的重新设置readfds和writefds,因为每一次调用select,内核会修改readfds和writefds,所以我们需要在 应用程序 中 设置一个数组 来保存程序需要等待的文件描述符,保证调用 select 的时候readfds 和 writefds中的将如下:
二,select版TCP服务器
接下来我们将用select来重新编写一下我们的TCP服务器。
2.1.编写准备
还记得TCP服务器怎么写吗?
为了节约我们的时间,我们复制一下我们之前封装好的Socket.hpp
#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>
// 定义一些错误代码
enum
{
SocketErr = 2, // 套接字创建错误
BindErr, // 绑定错误
ListenErr, // 监听错误
};
// 监听队列的长度
const int backlog = 10;
class Sock //服务器专门使用
{
public:
Sock() : sockfd_(-1) // 初始化时,将sockfd_设为-1,表示未初始化的套接字
{
}
~Sock()
{
// 析构函数中可以关闭套接字,但这里选择不在析构函数中关闭,因为有时需要手动管理资源
}
// 创建套接字
void Socket()
{
sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd_ < 0)
{
printf("socket error, %s: %d", strerror(errno), errno); //错误
exit(SocketErr); // 发生错误时退出程序
}
int opt=1;
setsockopt(sockfd_,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt)); //关闭后快速重启
}
// 将套接字绑定到指定的端口上
void Bind(uint16_t port)
{
//让服务器绑定IP地址与端口号
struct sockaddr_in local;
memset(&local, 0, sizeof(local));//清零
local.sin_family = AF_INET; // 网络
local.sin_port = htons(port); // 我设置为默认绑定任意可用IP地址
local.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口
if (bind(sockfd_, (struct sockaddr *)&local, sizeof(local)) < 0) //让自己绑定别人
{
printf("bind error, %s: %d", strerror(errno), errno);
exit(BindErr);
}
}
// 监听端口上的连接请求
void Listen()
{
if (listen(sockfd_, backlog) < 0)
{
printf("listen error, %s: %d", strerror(errno), errno);
exit(ListenErr);
}
}
// 接受一个连接请求
int Accept(std::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)
{
printf("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; // 返回新的套接字文件描述符
}
// 连接到指定的IP和端口——客户端才会用的
bool Connect(const std::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)
{
std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;
return false;
}
return true;
}
// 关闭套接字
void Close()
{
close(sockfd_);
}
// 获取套接字的文件描述符
int Fd()
{
return sockfd_;
}
private:
int sockfd_; // 套接字文件描述符
};
首先我们要创建一个SelectServer.hpp,main.cc,makefile
select_server:main.cc
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -rf select_server
#pragma once
#include<iostream>
#include"Socket.hpp"
const uint16_t default_port = 8877; // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IP
class SelectServer
{
public:
SelectServer(const uint16_t port = default_port, const std::string ip = default_ip)
: ip_(ip), port_(port)
{
}
~SelectServer()
{
listensock_.Close();
}
bool Init()
{
listensock_.Socket();
listensock_.Bind(port_);
listensock_.Listen();
return true;
}
void Start()
{
}
private:
uint16_t port_;//绑定的端口号
Sock listensock_;//专门用来listen的
std::string ip_; // ip地址
};
#include"SelectServer.hpp"
#include<memory>
int main()
{
std::unique_ptr<SelectServer> svr(new SelectServer());
svr->Init();
svr->Start();
}
2.2.SelectServer.hpp的编写
接下来我们就只剩下
class SelectServer{
void Start()
{
}
};
没有编写了。
我们可以看看我们之前编写的TCP服务器是怎么编写的。
void Start()
{
while(true)
{
std::string clientip;
uint16_t clientport;
int sockfd=listensock_.Accept(&clientip,&clientport);//这里会返回一个新的套接字
if(socket<0)
continue;
//提供服务
if(fork()==0)
{
listensock_.Close();
//通过sockfd使用提供服务
std::string inbuf;
while (1)
{
char buf[1024];
// 1.读取客户端发送的信息
ssize_t s = read(sockfd, buf, sizeof(buf) - 1);
if (s == 0)
{ // s == 0代表对方发送了空消息,视作客户端主动退出
printf("client quit: %s[%d]", clientip.c_str(), clientport);
break;
}
else if (s < 0)
{
// 出现了读取错误,打印错误后断开连接
printf("read err: %s[%d] = %s", clientip.c_str(), clientport, strerror(errno));
break;
}
else // 2.读取成功
{
}
}
exit(0);//子进程退出
}
close(sockfd);//
}
}
我们发现,我们首先进行的就是accept啊!!那我们这里能不能里面进行accept呢?答案是不能的。accept本质就是检测并建立listen上面有没有新连接的到来。
还记得我们最开始讲的例子吗?
赵六去钓鱼,他是个小土豪,赵六手里拿了一堆鱼竿,目测有几百根鱼竿,赵六到达河边,首先就把几百根鱼竿每隔几米插上去,总共插了好几百米的鱼竿,然后赵六就依次遍历这些鱼竿,哪个鱼竿上的鱼漂动了,赵六就把这根鱼竿上的鱼钓上来,然后接下来赵六就又继续重复之前遍历鱼竿的动作进行钓鱼了。
这个新链接就是鱼啊!!!新连接的到来就相当于鱼咬钩了。所以我们处理新连接的时候就得采用IO多路复用思想。
如果是一个select服务器进程,则服务器进程会不断的接收有新链接,每个链接对应一个文件描述符,如果想要我们的服务器能够同时等待多个链接的数据的到来,我们监听套接字listen_sock读取新链接的时候,我们需要将新链接的文件描述符保存到read_arrys数组中,下次轮询检测的就会将新链接的文件描述符设置进readfds中,如果有链接关闭,则将相对应的文件描述符从read_arrys数组中拿走。
一张图看懂select服务器:
按照上面的思路,我们暂且写出了下面这个
#pragma once
#include<iostream>
#include"Socket.hpp"
#include<sys/select.h>
#include<sys/time.h>
const uint16_t default_port = 8877; // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IP
class SelectServer
{
public:
SelectServer(const uint16_t port = default_port, const std::string ip = default_ip)
: ip_(ip), port_(port)
{
}
~SelectServer()
{
listensock_.Close();
}
bool Init()
{
listensock_.Socket();
listensock_.Bind(port_);
listensock_.Listen();
return true;
}
void Start()
{
int listensock=listensock_.Fd();
struct timeval timeout ={5,0};
for(;;)
{
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(listensock,&rfds);
int n=select(listensock+1,&rfds,NULL,NULL,&timeout);//刚开始的时候只有1个连接啊!!!
switch(n)
{
case 0:
std::cout<<"time out"<<std::endl;
break;
case -1:
std::cout<<"select error"<<std::endl;
break;
default:
//有事件就绪
break;
}
}
}
private:
uint16_t port_;//绑定的端口号
Sock listensock_;//专门用来listen的
std::string ip_; // ip地址
};
这里需要补充一些知识:
当一个新的连接请求到达监听套接字时,操作系统会接受这个请求(但不在用户空间的应用程序中立即处理它),并将监听套接字的状态标记为可读。这是因为从技术上讲,新的连接请求会导致监听套接字上有一些数据可读——具体来说,是新的连接的信息(例如,客户端的地址和端口号),这些信息将用于后续的 accept
调用。
我们编译运行一下
我们看看
我们回去再看看我们运行情况
嗯?什么情况?为什么一直在打印time out?这个是因为timeout参数是个输入输出型参数
我们现在需要修改一下代码
void Start()
{
int listensock=listensock_.Fd();
for(;;)
{
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(listensock,&rfds);
struct timeval timeout ={5,0};//注意这里
int n=select(listensock+1,&rfds,NULL,NULL,&timeout);//刚开始的时候只有1个连接啊!!!
switch(n)
{
case 0:
std::cout<<"time out"<<std::endl;
break;
case -1:
std::cout<<"select error"<<std::endl;
break;
default:
//有事件就绪
break;
}
}
}
现在我们再去编译一下
现在就不会变了。 一直为5秒了。
#pragma once
#include<iostream>
#include"Socket.hpp"
#include<sys/select.h>
#include<sys/time.h>
const uint16_t default_port = 8877; // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IP
class SelectServer
{
public:
SelectServer(const uint16_t port = default_port, const std::string ip = default_ip)
: ip_(ip), port_(port)
{
}
~SelectServer()
{
listensock_.Close();
}
bool Init()
{
listensock_.Socket();
listensock_.Bind(port_);
listensock_.Listen();
return true;
}
void Start()
{
int listensock=listensock_.Fd();
for(;;)
{
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(listensock,&rfds);
int n=select(listensock+1,&rfds,NULL,NULL,nullptr);
switch(n)
{
case 0:
std::cout<<"time out"<<std::endl;
break;
case -1:
std::cout<<"select error"<<std::endl;
break;
default:
//有事件就绪
std::cout<<"get a new link"<<std::endl;
break;
}
}
}
private:
uint16_t port_;//绑定的端口号
Sock listensock_;//专门用来listen的
std::string ip_; // ip地址
};
我们编译运行一下
我们发现程序怎么一直打印get a new link啊?这是因为我们没有把连接处理。
其实这个是select的特点:如果事件就绪,上层不处理的话,select会一直通知你!!!
如果select告诉我们就绪,接下来的一次读取,我们读取fd的时候,不会被阻塞
接下来我们就要来处理这个连接了!!!
为了让代码看起来更好看一点,我们将处理连接这部分封装起来。
void HandlerEvent(fd_set& rfds)
{
// 检查监听套接字是否就绪
if(FD_ISSET(listensock_.Fd(),&rfds))
// 监听套接字上有新的连接请求
// 调用accept来接受连接
{
//我们的连接事件就绪了
std::string clientip;
uint16_t clientport;
int sockfd=listensock_.Accept(&clientip,&clientport);//这里会返回一个新的套接字
//请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
if(sockfd<0)
return;
}
}
注意:
- FD_ISSET(fd, &set):此宏检查文件描述符fd是否已经被加入到set集合中。如果set中与fd对应的位为1,则返回非零值(真),否则返回0(假)。
现在有一个问题,
我们现在可不可以在后面使用read对socked直接进行读数据呢?
答案是不可以。!!!!!
为什么呢?
因为我们一旦调用read,万一客户端没有发数据过来,服务器进程就会阻塞在read这里!!这样子就会导致HandlerEvent函数调用不会返回,继而导致Start函数的循环阻塞,无法调用select函数监视新加入的连接。
我们发现
所以在accept函数后面我们不能直接调用read函数,而是将新连接加入到select中。
可是我们发现,我们的select和我们的accept在不同的函数里面,我们怎么让select来设置我们的文件描述符呢?这个时候我们就要设置一个辅助数组了。
2.2.1.为什么要设置辅助数组
我们可以让新增的fd都添加进辅助数组中,然后让select每次动态设置max_fd,以及三个位图(新增操作在"处理函数"中介绍)
- 可以固定监听套接字(也就是我们创建的第一个套接字)作为数组的第一项,方便我们后续区分[获取新连接] 和 [读写事件]。
- 因为在过程中,可能会陆陆续续关掉一些文件(断开连接时),所以原本添加进的连续fd,会变成零零星星的,所以需要我们每次都重新整理一下这个数组,把有效的fd统一放在左侧,我们每次在循环开头就处理数组中的值,合法的fd就让它设置进位图中
- 不仅如此,在这个过程中,我们还可以找到fd中的最大值,来填充select的第一个参数
接下来我们就修改一下Start函数
void Start()
{
int listensock = listensock_.Fd();
fd_arry[0] = listensock; // 将监听套接字加入辅助数组
for (;;)
{
fd_set rfds;//每调用一次select函数rfds需要重新设定
FD_ZERO(&rfds);
int maxfd = fd_arry[0]; // 最大有效数组下标
for (int i = 0; i < fd_num_max; ++i)
{
if (fd_arry[i] == default_fd)
{
continue;
}
FD_SET(fd_arry[i], &rfds);
//注意辅助数组第一个元素是listen套接字,所以最开始的时候一定会执行到这里
if (maxfd<fd_arry[i])//如果有更大的文件描述符,就替换掉maxfd
{
maxfd = fd_arry[i];
}
}
int n = select(maxfd + 1, &rfds, NULL, NULL, nullptr); // 注意这里会修改rfds,返回的rfds有效位代表着哪个位置的有效事件就绪了
switch (n)
{
case 0:
std::cout << "time out" << std::endl;
break;
case -1:
std::cout << "select error" << std::endl;
break;
default:
// 有事件就绪
std::cout << "get a new link" << std::endl;
HandlerEvent(rfds); // 处理事件
break;
}
}
}
接下来需要修改一下我们的HandlerEvent函数,我们accept新连接后不能直接读取,会阻塞,我们需要将这个新连接加入我们的select函数的范围,这就需要我们借助辅助数组了
当我们识别到有事件就绪,获取连接后获得新套接字fd,之后就该将该fd设置进辅助数组中了
- 需要我们遍历数组,找到空位(值为-1/其他你设定的[数组内的初始值]),然后添加进去
- 但是要注意位图还有没有空位置(别忘了位图是有上限的)
- 所以,还需要加个判断
void HandlerEvent(fd_set &rfds)
{
// 1.判断哪个读事件就绪
if (FD_ISSET(listensock_.Fd(), &rfds)) //
{
// 我们的连接事件就绪了
std::string clientip;
uint16_t clientport;
int sockfd = listensock_.Accept(&clientip, &clientport); // 这里会返回一个新的套接字
// 请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
if (sockfd <0)
return;
else // 把新fd加入位图
{
int i = 1;
for (; i < fd_num_max; i++)//为什么从1开始,因为我们0号下标对应的是listen套接字,我们不要修改
{
if (fd_arry[i] !=default_fd ) // 没找到空位
{
continue;;
}
else{//找到空位,但不能直接添加
break;
}
}
if (i != fd_num_max)//没有满
{
fd_arry[i] = sockfd;//把新连接加入数组
}
else // 满了
{
close(sockfd);//处理不了了,直接关闭连接吧
}
}
}
}
一旦有新连接的到来,我们就是只先把连接放到辅助数组里面。
大家有没有发现,这个辅助数组里面的事件有两类啊!!!!就是[新连接]和[读写事件],如何区分fd集上的事件就绪究竟是[新连接]还是[读写事件]呢?
如何区分fd集上的事件就绪究竟是[新连接]还是[读写事件]呢?
- 前面我们提到,将监听套接字固定在数组第一项,就是为了区分两者,所以写个判断语句就行
void HandlerEvent(fd_set &rfds)
{
for (int n = 0; n < fd_num_max; n++)
{
int fd = fd_arry[n];
if (fd == default_fd) // 无效的
continue;
if (FD_ISSET(fd, &rfds)) // fd套接字就绪了
{
// 1.是listen套接字就绪了
if (fd == listensock_.Fd()) // 如果是listen套接字就绪了!!!
{
// 我们的连接事件就绪了
std::string clientip;
uint16_t clientport;
int sockfd = listensock_.Accept(&clientip, &clientport); // 这里会返回一个新的套接字
// 请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
if (sockfd < 0)
continue;
else // 把新fd加入位图
{
int i = 1;
for (; i < fd_num_max; i++) // 为什么从1开始,因为我们0号下标对应的是listen套接字,我们不要修改
{
if (fd_arry[i] != default_fd) // 没找到空位
{
continue;
}
else
{ // 找到空位,但不能直接添加
break;
}
}
if (i != fd_num_max) // 没有满
{
fd_arry[i] = sockfd; // 把新连接加入数组
Printfd();
}
else // 满了
{
close(sockfd); // 处理不了了,直接关闭连接吧
}
}
}
// 2.是通信的套接字就绪了,fd不是listen套接字
else // 读事件
{
char in_buff[1024];
int n = read(fd, in_buff, sizeof(in_buff) - 1);
if (n > 0)
{
in_buff[n] = 0;
std::cout << "get message: " << in_buff << std::endl;
}
else if (n == 0) // 客户端关闭连接
{
close(fd);//我服务器也要关闭
fd_arry[n] = default_fd; // 重置数组内的值
}
else
{
close(fd);//我服务器也要关闭
fd_arry[n] = default_fd; // 重置数组内的值
}
}
}
}
}
我们做个实验
很完美啊
很好!!
我们也可以把处理过程单拎出来封装成两个函数
- 就相当于我们把收到的事件根据类型不同,派发给不同的模块进行处理
void Accept()
{
// 我们的连接事件就绪了
std::string clientip;
uint16_t clientport;
int sockfd = listensock_.Accept(&clientip, &clientport); // 这里会返回一个新的套接字
// 请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
if (sockfd < 0)
return;
else // 把新fd加入位图
{
int i = 1;
for (; i < fd_num_max; i++) // 为什么从1开始,因为我们0号下标对应的是listen套接字,我们不要修改
{
if (fd_arry[i] != default_fd) // 没找到空位
{
continue;
}
else
{ // 找到空位,但不能直接添加
break;
}
}
if (i != fd_num_max) // 没有满
{
fd_arry[i] = sockfd; // 把新连接加入数组
Printfd();
}
else // 满了
{
close(sockfd); // 处理不了了,直接关闭连接吧
}
}
}
void Receiver(int fd, int i)
{
char in_buff[1024];
int n = read(fd, in_buff, sizeof(in_buff) - 1);
if (n > 0)
{
in_buff[n] = 0;
std::cout << "get message: " << in_buff << std::endl;
}
else if (n == 0) // 客户端关闭连接
{
close(fd); // 我服务器也要关闭
fd_arry[i] = default_fd; // 重置数组内的值
}
else
{
close(fd); // 我服务器也要关闭
fd_arry[i] = default_fd; // 重置数组内的值
}
}
void HandlerEvent(fd_set &rfds)
{
for (int n = 0; n < fd_num_max; n++)
{
int fd = fd_arry[n];
if (fd == default_fd) // 无效的
continue;
if (FD_ISSET(fd, &rfds)) // fd套接字就绪了
{
// 1.是listen套接字就绪了
if (fd == listensock_.Fd()) // 如果是listen套接字就绪了!!!
{
Accept();
}
// 2.是通信的套接字就绪了,fd不是listen套接字
else // 读事件
{
Receiver(fd,n);
}
}
}
}
2.2.2.select的优缺点
总的来说,select最重要的就是思维方式 -- 我们要将所有等待的过程都交给select
并且优缺点很明显
优点
- 确实实现了多路转接,可以等待多个fd
- 代码简单明了
缺点
- 等待的fd数量有上限
- 输入输出型参数较多,需要频繁进行用户和内核之间的数据拷贝操作 , 以及每次都要重复设置
- 使用了第三方数组,用户层在读取fd时需要很多次遍历
- 除了用户层,内核层也要经过多次遍历 -- 内核需要知道自己要关注的fd(遍历位图),关注的过程其实就是在遍历文件描述符表(遍历范围上限=max_fd+1,因为fd本身就是下标,+1就是数量了)
2.3.源代码
#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>
// 定义一些错误代码
enum
{
SocketErr = 2, // 套接字创建错误
BindErr, // 绑定错误
ListenErr, // 监听错误
};
// 监听队列的长度
const int backlog = 10;
class Sock //服务器专门使用
{
public:
Sock() : sockfd_(-1) // 初始化时,将sockfd_设为-1,表示未初始化的套接字
{
}
~Sock()
{
// 析构函数中可以关闭套接字,但这里选择不在析构函数中关闭,因为有时需要手动管理资源
}
// 创建套接字
void Socket()
{
sockfd_ = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd_ < 0)
{
printf("socket error, %s: %d", strerror(errno), errno); //错误
exit(SocketErr); // 发生错误时退出程序
}
int opt=1;
setsockopt(sockfd_,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt)); //服务器主动关闭后快速重启
}
// 将套接字绑定到指定的端口上
void Bind(uint16_t port)
{
//让服务器绑定IP地址与端口号
struct sockaddr_in local;
memset(&local, 0, sizeof(local));//清零
local.sin_family = AF_INET; // 网络
local.sin_port = htons(port); // 我设置为默认绑定任意可用IP地址
local.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口
if (bind(sockfd_, (struct sockaddr *)&local, sizeof(local)) < 0) //让自己绑定别人
{
printf("bind error, %s: %d", strerror(errno), errno);
exit(BindErr);
}
}
// 监听端口上的连接请求
void Listen()
{
if (listen(sockfd_, backlog) < 0)
{
printf("listen error, %s: %d", strerror(errno), errno);
exit(ListenErr);
}
}
// 接受一个连接请求
int Accept(std::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)
{
printf("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; // 返回新的套接字文件描述符
}
// 连接到指定的IP和端口——客户端才会用的
bool Connect(const std::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)
{
std::cerr << "connect to " << ip << ":" << port << " error" << std::endl;
return false;
}
return true;
}
// 关闭套接字
void Close()
{
close(sockfd_);
}
// 获取套接字的文件描述符
int Fd()
{
return sockfd_;
}
private:
int sockfd_; // 套接字文件描述符
};
#pragma once
#include <iostream>
#include "Socket.hpp"
#include <sys/select.h>
#include <sys/time.h>
const uint16_t default_port = 8877; // 默认端口号
const std::string default_ip = "0.0.0.0"; // 默认IP
const int fd_num_max = sizeof(fd_set) * 8;
const int default_fd = -1;
class SelectServer
{
public:
SelectServer(const uint16_t port = default_port, const std::string ip = default_ip)
: ip_(ip), port_(port)
{
for (int i = 0; i < fd_num_max; i++)
{
fd_arry[i] = -1; // 辅助数组所有元素都是-1;
}
}
~SelectServer()
{
listensock_.Close();
}
bool Init()
{
listensock_.Socket();
listensock_.Bind(port_);
listensock_.Listen();
return true;
}
void Accept()
{
// 我们的连接事件就绪了
std::string clientip;
uint16_t clientport;
int sockfd = listensock_.Accept(&clientip, &clientport); // 这里会返回一个新的套接字
// 请问进程会阻塞在Accept这里吗?答案是不会的,因为上层的select已经完成的了等待部分,accept只需要完成建立连接即可
if (sockfd < 0)
return;
else // 把新fd加入位图
{
int i = 1;
for (; i < fd_num_max; i++) // 为什么从1开始,因为我们0号下标对应的是listen套接字,我们不要修改
{
if (fd_arry[i] != default_fd) // 没找到空位
{
continue;
}
else
{ // 找到空位,但不能直接添加
break;
}
}
if (i != fd_num_max) // 没有满
{
fd_arry[i] = sockfd; // 把新连接加入数组
Printfd();
}
else // 满了
{
close(sockfd); // 处理不了了,直接关闭连接吧
}
}
}
void Receiver(int fd, int i)
{
char in_buff[1024];
int n = read(fd, in_buff, sizeof(in_buff) - 1);
if (n > 0)
{
in_buff[n] = 0;
std::cout << "get message: " << in_buff << std::endl;
}
else if (n == 0) // 客户端关闭连接
{
close(fd); // 我服务器也要关闭
fd_arry[i] = default_fd; // 重置数组内的值
}
else
{
close(fd); // 我服务器也要关闭
fd_arry[i] = default_fd; // 重置数组内的值
}
}
void HandlerEvent(fd_set &rfds)
{
for (int n = 0; n < fd_num_max; n++)
{
int fd = fd_arry[n];
if (fd == default_fd) // 无效的
continue;
if (FD_ISSET(fd, &rfds)) // fd套接字就绪了
{
// 1.是listen套接字就绪了
if (fd == listensock_.Fd()) // 如果是listen套接字就绪了!!!
{
Accept();
}
// 2.是通信的套接字就绪了,fd不是listen套接字
else // 读事件
{
Receiver(fd,n);
}
}
}
}
void Printfd()
{
std::cout << "online fd list: ";
for (int i = 0; i < fd_num_max; i++)
{
if (fd_arry[i] == default_fd)
continue;
else
{
std::cout << fd_arry[i] << " ";
}
}
std::cout << std::endl;
}
void Start()
{
int listensock = listensock_.Fd();
fd_arry[0] = listensock; // 将监听套接字加入辅助数组
for (;;)
{
fd_set rfds; // 每调用一次select函数rfds需要重新设定
FD_ZERO(&rfds);
int maxfd = fd_arry[0]; // 最大有效数组下标
for (int i = 0; i < fd_num_max; ++i)
{
if (fd_arry[i] == default_fd)
{
continue;
}
FD_SET(fd_arry[i], &rfds);
// 注意辅助数组第一个元素是listen套接字,所以最开始的时候一定会执行到这里
if (maxfd < fd_arry[i]) // 如果有更大的文件描述符,就替换掉maxfd
{
maxfd = fd_arry[i];
std::cout << "max_fd:" << maxfd << std::endl;
}
}
int n = select(maxfd + 1, &rfds, NULL, NULL, nullptr); // 注意这里会修改rfds,返回的rfds有效位代表着哪个位置的有效事件就绪了
switch (n)
{
case 0:
std::cout << "time out" << std::endl;
break;
case -1:
std::cout << "select error" << std::endl;
break;
default:
// 有事件就绪
std::cout << "get a new link" << std::endl;
HandlerEvent(rfds); // 处理事件
break;
}
}
}
private:
uint16_t port_; // 绑定的端口号
Sock listensock_; // 专门用来listen的
std::string ip_; // ip地址
int fd_arry[fd_num_max]; // 辅助数组——方便文件描述符在不同函数间传递
};
#include"SelectServer.hpp"
#include<memory>
int main()
{
std::unique_ptr<SelectServer> svr(new SelectServer());
svr->Init();
svr->Start();
}