首先我们要了解一下,什么是多路转接?
多路转接也叫多路复用,是一种用于管理多个IO通道的技术。它能实现同时监听和处理多个IO事件,而不是为每个IO通道创建单独的线程或者进程,多路转接允许在单个进程或线程中同时处理多个IO操作,从而提高程序的性能和效率。
本篇文章介绍的select函数,就用于select系统调用的多路转接技术。
select函数是系统提供的一个多路转接接口。
IO = 等待就绪 + 数据拷贝,而select是只负责等。
参数timeout的取值:
返回值说明:
select调用失败,错误码可能被设置为:
fd_set 结构
fd_set 结构与 sigset_t 结构类似,fd_set 本质也是一个位图,用位图中对应的位来表示要监听的文件描述符。
调用select函数之前就需要用fd_set结构定义出对应的文件描述符集,然后将需要监视的文件描述符添加到文件描述符集当中,这个添加的过程本质就是在进行位操作,但是这个位操作不需要用户自己进行,系统专门提供了一组专门的接口,用户对fd_set位图进行各种操作。
timeval 结构
传入select函数的最后一个参数timeout,就是一个指向timeval结构的指针,timeval结构用于描述一段时间长度,该结构当中包含两个成员,其中tv_sec表示的是秒,tv_usec表示的是微妙。
读就绪
写就绪
异常就绪
这里我们只介绍select处理读取的操作。
如果我们要实现一个简单的select服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么这个select服务器的工作流程应该是这样的:
注意:
Socket类
我们编写一个Socket类,对套接字相关的接口进行一定程序的封装,为了让外部能够直接调用Socket类当中的函数,我们将这些成员函数定义成静态成员函数。
#pragma once
#include
#include
#include
#include
#include
#include
#include
class Socket
{
public:
// 创建套接字
static int SocketCreate()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
// 设置端口复用
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
// 绑定
static void SocketBind(int sock, int 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;
socklen_t len = sizeof(local);
if (bind(sock, (struct sockaddr*)&local, len) < 0)
{
std::cerr << "bind error" << std::endl;
exit(3);
}
}
// 监听
static void SocketListen(int sock, int backlog)
{
if (listen(sock, backlog) < 0)
{
std::cerr << "listen error" << std::endl;
exit(4);
}
}
};
SelectServer类
编写SelectServer类,因为我当前使用的是云服务器,所以编写的select服务器在绑定时只需将IP地址设置为INADDR_ANY即可,所以类中只包含监听套接字和端口号两个成员变量即可。
#pragma once
#include "Socket.hpp"
#include
#define BACK_LOG 5
class SelectServer
{
public:
SelectServer(int port)
: _port(port)
{}
void InitSelectServer()
{
_listen_sock = Socket::SocketCreate();
Socket::SocketBind(_listen_sock, _port);
Socket::SocketListen(_listen_sock, BACK_LOG);
}
~SelectServer()
{
if (_listen_sock >= 0) close(_listen_sock);
}
private:
int _listen_sock;
int _port;
};
运行服务器
服务器初始化完毕之后就可以周期性地执行某种动作了,而select服务器要做的就是不断调用select函数,当事件就绪对应执行某种动作即可。
#pragma once
#include "Socket.hpp"
#include
#define BACK_LOG 5
#define NUM 1024
#define DFL_FD - 1
class SelectServer
{
public:
SelectServer(int port)
: _port(port)
{}
void InitSelectServer()
{
_listen_sock = Socket::SocketCreate();
Socket::SocketBind(_listen_sock, _port);
Socket::SocketListen(_listen_sock, BACK_LOG);
}
~SelectServer()
{
if (_listen_sock >= 0) close(_listen_sock);
}
void Run()
{
fd_set readfds; // 创建读文件描述符集
int fd_array[NUM]; // 保存需要被监视读事件是否就绪的文件描述符
ClearFdArray(fd_array, NUM, DFL_FD); // 将数组中的所有位置设置为无效
fd_array[0] = _listen_sock; // 将监听套接字添加到fd_array数组中的第0个位置
while (1)
{
FD_ZERO(&readfds); // 清空readfds
// 将fd_array数组当中的文件描述符添加到readfds中,并记录最大的文件描述符
int maxfd = DFL_FD;
for (int i = 0; i < NUM; ++i)
{
if (fd_array[i] == DFL_FD) continue; // 跳过无效的位置
FD_SET(fd_array[i], &readfds); // 将有效位置的文件描述符添加到readfds中
if (fd_array[i] > maxfd) maxfd = fd_array[i]; // 更新最大文件描述符
}
switch (select(maxfd + 1, &readfds, nullptr, nullptr, nullptr))
{
case 0:
std::cout << "timeout..." << std::endl;
break;
case -1:
std::cerr << "select error" << std::endl;
break;
default:
std::cout << "有事件发生..." << std::endl;
break;
}
}
}
private:
void ClearFdArray(int fd_array[], int num, int default_fd)
{
for (int i = 0; i < num; ++i) fd_array[i] = default_fd;
}
int _listen_sock;
int _port;
};
启动服务器
#include "SelectServer.hpp"
#include
int main(int argc, char* argv[])
{
if (argc != 2)
{
std::cerr << "Usage: " << "SelectServer" << " port" << std::endl;
exit(1);
}
int port = atoi(argv[1]);
SelectServer* svr = new SelectServer(port);
svr->InitSelectServer();
svr->Run();
return 0;
}
由于当前服务器调用select函数时直接将timeout设置为了nullptr,因此select函数调用后会进行阻塞等待。而服务器在第一次调用select函数时只让select函数监视监听套接字的读事件,所以运行服务器之后如果没有客户端发来连接请求,那么读事件就不会就绪,而服务器会一直在第一次调用的select函数中进行阻塞等待。
当我们借助telnet工具向select服务器发起连接请求之后,select函数就会立马检测到监听套接字的读事件就绪,此时select函数便会返回成功,并将我们设置的提示语句进行打印输出,因为当前程序没有对就绪事件进行处理,此后每次select函数一调用就会检测到读事件就绪成功返回,因此屏幕不但打印输出提示语句。
如果服务器在调用select函数时将timeout的值设置为0,那么select函数调用后就会进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回。
此时如果select监视的文件描述符上有事件就绪,那么select函数的返回值就是大于0的,如果select函数监视的文件描述符上没有事件就绪,那么select的返回值就是小于0的,这里也就不进行演示了。
事件处理
当select检测到右文件描述符的读事件就绪并成功返回后,接下来就应该对就绪事件进行处理了,这里编写一个HandleEvent函数,当读事件就绪之后就调用该函数进行事件处理。
void HandleEvent(const fd_set& readfds, int fd_array[], int num)
{
for (int i = 0; i < num; ++i)
{
// 跳过无效位置
if (fd_array[i] == DFL_FD) continue;
// 连接事件就绪
if (fd_array[i] == _listen_sock && FD_ISSET(fd_array[i], &readfds))
{
// 获取连接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0)
{
std::cerr << "accept error" << std::endl;
continue;
}
std::string peer_ip = inet_ntoa(peer.sin_addr);
int peer_port = ntohs(peer.sin_port);
std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;
// 将获取到的文件描述符添加到fd_array中
if (!SetFdArray(fd_array, num, sock))
{
// 如果添加失败,关闭文件描述符
close(sock);
std::cout << "select server is full, close fd: " << sock << std::endl;
}
}
}
}
private:
bool SetFdArray(int fd_array[], int num, int fd)
{
for (int i = 0; i < num; ++i)
{
if (fd_array[i] == DFL_FD)
{
fd_array[i] = fd;
return true;
}
}
return false;
}
添加文件描述符到fd_array数组中,本质就是遍历fd_array数组,找到一个没有被使用的位置将该文件描述符添加进去即可。但有可能fd_array数组中全部的位置都已经被占用了,那么文件描述符就会添加失败,此时就只能将刚刚获取上来的连接对应的套接字进行关闭,因为此时服务器是没有能力处理这个连接的。
该select服务器存在的一些问题。
select的优点
当然,这也是所有多路转接接口的优点。
select的缺点
select可监控的文件描述符有1024个,除去其中的一个监听套接字,那么它最多只能连接1023个客户端。