目录
我们上篇文章讲到IO,IO = 等 + 拷贝。select函数的作用就是等。
等什么呢?等fd(文件描述符),等fd对应的读事件就绪、写事件就绪,或者异常事件。
nfds:需要监视的最大的文件描述符值+1。
readfds:是可读文件描述符集合,为输入输出型参数。
writefds:是可写文件描述符集合,为输入输出型参数。
exceptfds:是异常文件描述符集合,为输入输出型参数。
这里的输入输出型参数是什么?
我们首先要知道这里的文件集合的数据类型为位图。输入型参数是用户告诉系统当前要关注的文件描述符,输出型参数是系统告诉用户当前那些文件描述符对应的事件已经就绪。以读的文件描述符集合为例,用户传入0111 0111,表示要关注0、1、2、4、5、6这几个描述符对应的读事件,系统返回0110 0000,表示当前5、6对应的读事件就绪。
关于位图的操作,用户不能自己设置,需要使用对应的函数。
timeout:设置一个时间,在这个时间内阻塞式等待,时间外立刻返回。
我们要关注下timeout的数据类型timeval:
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
意味着,我们可以设置秒或者微秒级别的deadline。
例如,我们设置5秒,在5秒内阻塞式等待,5秒外函数立马返回。
返回值: ①大于0,返回值是几代表有几个事件就绪。②==0,表示timeout,没有事件就绪,并且超过了设置的deadline。③<0,表示报错。
2.select版本的网络服务器
整篇文章的代码已上传https://gitee.com/jjjmodest/linux/tree/master/study20230406。
①网络编程
关于网络编程部分大家可以看我这篇博客,下面的代码是精简过后的代码。
#pragma once
#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <cstdio>
#include <cstring>
#include <signal.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <pthread.h>
#include <cerrno>
#include <cassert>
class Sock
{
public:
static const int gbacklog = 20;
static int Socket()
{
int listenSock = socket(PF_INET, SOCK_STREAM, 0);
if (listenSock < 0)
{
exit(1);
}
// 设置复用,避免处于time_wait状态,不能立马重连
int opt = 1;
setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
return listenSock;
}
static void Bind(int socket, uint16_t port)
{
struct sockaddr_in local; // 用户栈
memset(&local, 0, sizeof local);
local.sin_family = PF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
// 2.2 本地socket信息,写入sock_对应的内核区域
if (bind(socket, (const struct sockaddr *)&local, sizeof local) < 0)
{
exit(2);
}
}
static void Listen(int socket)
{
if (listen(socket, gbacklog) < 0)
{
exit(3);
}
}
static int Accept(int socket, std::string *clientip, uint16_t *clientport)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int serviceSock = accept(socket, (struct sockaddr *)&peer, &len);
if (serviceSock < 0)
{
// 获取链接失败
return -1;
}
if (clientport)
*clientport = ntohs(peer.sin_port);
if (clientip)
*clientip = inet_ntoa(peer.sin_addr);
return serviceSock;
}
};
②select
我们上文提到过通过0/1在位图中表示是否关注该位置对应的文件描述符。那这个位图有多大呢?
fd_set是一种类型,对应的是位图结构。sizeof求的是字节,而位图关注的是bit位,我们还要×8,1024才是这个位图最多能表示文件描述符的个数。
下面开始编写:
#include <iostream>
#include <sys/select.h>
#include "Sock.hpp"
using namespace std;
void Usage(string process)
{
cout << "Please entry" << process << " port" << endl;
}
int main(int argc, char **argv)
{
// fd_set set;
// cout<<sizeof(set)*8<<endl;
if (argc != 2)
{
Usage(argv[0]);
exit(1);
}
int listensocket = Sock::Socket();
Sock::Bind(listensocket, atoi(argv[1]));
Sock::Listen(listensocket);
while (1)
{
int maxfd = listensocket;
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(listensocket, &readfds);
struct timeval timeout = {5, 0};
// 这里将设置的初始化的参数放入循环中,是因为先前讲过
// select中的readfds等参数是输入输出型参数,一旦timeout,也就是
// 在规定时间内,或者有其他在位图中的文件描述符对应的读事件就绪了,readfds就会
// 被清空,只设置就绪的文件描述符对应的位图。
// 例子:传入0110 1111,第2个读事件就绪,返回0000 0100,但是其他文件描述符需要继续关注,所以要重复设置。
// 我们当前只关注读事件就绪 listensocket永远要设置进readfds中,时刻在监听
int n = select(maxfd + 1, &readfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
// timeout
cout << "timeout ... : " <<(unsigned int)time(nullptr)<<endl;
break;
// error
cout << "select error : " << strerror(errno) << endl;
case -1:
break;
default:
// 等待成功
cout << "等待成功" << endl;
break;
}
}
}
初始框架,我们首先将它运行起来。
我们要知道监听sock,是获取新连接的,本质是建立三次握手,也就是要发送syn,本质也是IO,监听sock只用关心读事件。我们会发现在listensock对应的读事件没有就绪时,每过5秒会timeout下,那是因为我们设置的是5秒。如果传入nullptr,则代表select永久阻塞,一直等待事件就绪。
我们此时使用telnet功能去链接该服务器。
我们使用个函数来模拟处理连接的到来。
static void HandlerEvent(int listensock, fd_set &readfds)
{
if (FD_ISSET(listensock, &readfds))
{
// 使用FD_ISSET来判断该listensock对应的读事件就绪
// 走到这里,说明listensock对应的读事件就绪,或者说来了一个新链接
cout << "新连接到来,需要处理" << endl;
}
}
继续完善。
static void HandlerEvent(int listensock, fd_set &readfds)
{
if (FD_ISSET(listensock, &readfds))
{
// 使用FD_ISSET来判断该listensock对应的读事件就绪
// 走到这里,说明listensock对应的读事件就绪,或者说来了一个新链接
cout << "新连接到来,需要处理" << endl;
string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(listensock, &clientip, &clientport); // 这里不会阻塞
if (sock < 0)
return;
cout << "获取新连接成功 " << clientip << ":" << clientport << " Sock:" << sock << endl;
}
}
接下来是不是就要通过read/write,来进行文件描述符读取或写入,不对。
我们当前只是对listensock进行select,进行等待,select成功只是说明listensock对应的读事件就绪表示有连接到来,而accept上来的文件描述符对应的读事件不代表就就绪了,还需要将这个文件描述符放入select中。
要将当前连接上来的文件描述符放入select中也就是readfds中,并且每次我们要更新maxfd,更新readfds,因为参数是输入输出型参数,所以我们要大改下代码。
代码:
#include <sys/select.h>
#include "Sock.hpp"
using namespace std;
int fdsArray[sizeof(fd_set) * 8] = {0}; // 辅助数组 里面存放历史文件描述符
const int gnum = sizeof(fdsArray) / sizeof(fdsArray[0]);
#define DEFAUIT -1 // 默认
void Usage(string process)
{
cout << "Please entry" << process << " port" << endl;
}
static void ShowArray()
{
cout << "当前的文件描述符为: ";
for(int i = 0; i < gnum; i++)
{
if(fdsArray[i] == DEFAUIT)
continue;
cout << fdsArray[i] << ' ';
}
cout<<endl;
}
static void HandlerEvent(int listensock, fd_set &readfds)
{
if (FD_ISSET(listensock, &readfds))
{
// 使用FD_ISSET来判断该listensock对应的读事件就绪
// 走到这里,说明listensock对应的读事件就绪,或者说来了一个新链接
cout << "新连接到来,需要处理" << endl;
string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(listensock, &clientip, &clientport); // 这里不会阻塞
if (sock < 0)
return;
cout << "获取新连接成功 " << clientip << ":" << clientport << " Sock:" << sock << endl;
// 将sock放入readfds中,首先要将sock放入辅助数组中
int i = 0;
for (; i < gnum; i++)
{
if(fdsArray[i] == DEFAUIT) break;
}
if(i == gnum)
{
// 说明文件描述符已经占满了fdsArray,新的文件描述符放入不到fdsArray中,fdsArray大小是fd_set的大小
// 说明服务器已经到达了上限,等待不了新的文件描述符
cerr << "服务器已经到达了上限"<<endl;
close(sock);
}
else
{
// 将文件描述符放入fdsArray中
fdsArray[i] = sock;
// debug
ShowArray();
}
}
}
int main(int argc, char **argv)
{
// fd_set set;
// cout<<sizeof(set)*8<<endl;
if (argc != 2)
{
Usage(argv[0]);
exit(1);
}
int listensocket = Sock::Socket();
Sock::Bind(listensocket, atoi(argv[1]));
Sock::Listen(listensocket);
for (int i = 0; i < gnum; i++)
{
fdsArray[i] = DEFAUIT;
}
fdsArray[0] = listensocket; // 默认fdsArray第一个元素存放
while (1)
{
// 在每次select的时候,将参数重新设定
int maxfd = DEFAUIT;
fd_set readfds;
FD_ZERO(&readfds);
for (int i = 0; i < gnum; i++)
{
if (fdsArray[i] == DEFAUIT)
continue;
// 走到这里说明fdsArray[i],存放的是文件描述符,不是DEFAUIT 则需要将这个文件描述符添加入readfds中
FD_SET(fdsArray[i], &readfds);
if (fdsArray[i] > maxfd)
maxfd = fdsArray[i]; // 更新maxfd
}
struct timeval timeout = {50, 0};
// 这里将设置的初始化的参数放入循环中,是因为先前讲过
// select中的readfds等参数是输入输出型参数,一旦timeout,也就是
// 在规定时间内,或者有其他在位图中的文件描述符对应的读事件就绪了,readfds就会
// 被清空,只设置就绪的文件描述符对应的位图。
// 例子:传入0110 1111,第2个读事件就绪,返回0000 0100,但是其他文件描述符需要继续关注,所以要重复设置。
// 我们当前只关注读事件就绪 listensocket永远要设置进readfds中,时刻在监听
int n = select(maxfd + 1, &readfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
// timeout
cout << "timeout ... : " << (unsigned int)time(nullptr) << endl;
break;
// error
cout << "select error : " << strerror(errno) << endl;
case -1:
break;
default:
// 等待成功
HandlerEvent(listensocket, readfds);
break;
}
}
}
现象:
我们的处理方式,不止要处理listensock,还要处理其他的文件描述符对应的事件。所以处理函数需要,作出修改。
代码:
static void HandlerEvent(int listensock, fd_set &readfds)
{
for (int j = 0; j < gnum; j++)
{
if (fdsArray[j] == DEFAUIT)
continue;
if (j == 0 && fdsArray[j] == listensock)
{
if (FD_ISSET(listensock, &readfds))
{
// 使用FD_ISSET来判断该listensock对应的读事件就绪
// 走到这里,说明listensock对应的读事件就绪,或者说来了一个新链接
cout << "新连接到来,需要处理" << endl;
string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(listensock, &clientip, &clientport); // 这里不会阻塞
if (sock < 0)
return;
cout << "获取新连接成功 " << clientip << ":" << clientport << " Sock:" << sock << endl;
// 将sock放入readfds中,首先要将sock放入辅助数组中
int i = 0;
for (; i < gnum; i++)
{
if (fdsArray[i] == DEFAUIT)
break;
}
if (i == gnum)
{
// 说明文件描述符已经占满了fdsArray,新的文件描述符放入不到fdsArray中,fdsArray大小是fd_set的大小
// 说明服务器已经到达了上限,等待不了新的文件描述符
cerr << "服务器已经到达了上限" << endl;
close(sock);
}
else
{
// 将文件描述符放入fdsArray中
fdsArray[i] = sock;
// debug
ShowArray();
}
}
}
else
{
// 处理其他的文件描述符的IO事件
if (FD_ISSET(fdsArray[j], &readfds))
{
char buffer[1024];
ssize_t s = recv(fdsArray[j], buffer, sizeof(buffer), 0);
// 这里的阻塞读取真的会阻塞住吗?并不会,因为走到这里select已经帮我们等了,并且此时事件就绪。
if (s > 0)
{
buffer[s] = 0;
cout << "client[" << fdsArray[j] << "]"
<< " # " << buffer << endl;
}
else if (s == 0)
{
cout << "client[" << fdsArray[j] << "]"
<< "quit"
<< " server close this" << fdsArray[j] << endl;
fdsArray[j] = DEFAUIT; // 恢复默认
close(fdsArray[j]); // 关闭sock
ShowArray(); // debug
}
else
{
cout << "recv error" << endl;
fdsArray[j] = DEFAUIT; // 恢复默认
close(fdsArray[j]); // 关闭sock
ShowArray(); // debug
}
}
}
}
}
现象:
但是这里还是有bug。如何保证你一次读上来的数据是完整的呢?
这里我们可以在发送时要求发送方在发送时制定好分隔符,读取之后依据分隔符来确定一个报文是否读完,这里的代码编写我们放到后面写epoll时在编写。
总结:
select:
特点:①在select之前要将参数重置
②select之后要遍历位图来检测事件是否就绪
③需要第三方数组来维护历史文件描述符
优点:①占用资源少,高效,相比多进程多线程来讲
缺点:①每一次都要进行大量工作
②每次检测的文件描述符是有上限的
③每一次都要用户将位图拷贝给内核,再有内核拷贝到用户
④select编写复杂,需要自己维护数组
海歌plus: 理解了
C.Ali.Y: 什么家庭啊,还学编程
MAKO900: 哥哥太强了,可是我一点都看不懂
JJJ MODEST: 哥们,只要记住一点 对称密钥可以加密解密,非对称密钥 公钥加私钥解 私钥加公钥解,再结合图看看就可以了。或者说有哪里不会,我再给你讲讲。
JkslxianKNmks: 本来前面看得懂,到后面思维逐渐离谱