前言

之前,我已经详细的为大家讲解了五种IO模型与阻塞非阻塞的概念。
我们提到,如果你使用最基础的 accept、recv、send 等函数,它们都是阻塞 的(因为文件描述符是阻塞的 )。这意味着当一个进程/线程在等待一个连接(accept)或等待一个连接上的数据(recv)时,它什么也做不了。如果要处理多个客户端连接,传统做法是为每个连接创建一个线程/进程。这在连接数少的时候可行,但当连接数成千上万时,线程/进程的创建、调度、上下文切换和内存开销会成为巨大的性能瓶颈。
由此我们提出了多路转接模型,而其核心思想就是 :"用单线程(或少量线程)来监视和管理大量的文件描述符(如Socket)"。 这个线程会阻塞在一个特殊的函数上(如 select, poll, epoll),当这个函数返回时,它会告诉我们哪些描述符已经"就绪"(例如,有新的连接可以接受,有数据可以读取,可以发送数据等)。然后,程序只需要去处理这些就绪的描述符即可,避免了为每个连接都创建一个线程的巨大开销。
实现多路转接多路复用的方法有三种,select,poll,epoll。而多路转接的核心作用就是:对多个文件描述符进行等待,再通知上层,哪些fd已经准备好了。其本质就是对IO事件就绪的一种通知机制。
select 就是这个模型的一个具体实现,也是最早普及的解决方案之一。
这篇文章,我主要以select为主角进行讲解,其他的poll、epoll我会在后面的文章进行讲解,希望能够对大家有所帮助!
而具体的内容,我会划分为三个部分,第一个部分就是对函数的初步解读,第二个部分就是我们用这个函数写了一个简单的多路转接类型的服务器的demo代码,第三部分就是对此多路转接select的知识进行总结与进一步的拓展。
select函数初体验
系统提供 select 函数来实现多路复用输入/输出模型:
- select 系统调用是用来让我们的程序监视多个文件描述符的状态变化的。
- 程序会停在 select 这里等待,直到被监视的文件描述符有一个或多个发生了状态改变。
select 的函数原型如下:
c
#include <sys/select.h>
int select(int nfds, fd_set *readfds , fd_set *writefds , fd_set*exceptfds , struct timeval *timeout);
先来讲一下它的参数:
-
首先就是它的第一个参数nfds,这个代表你要监视的多个文件描述符中的最大值加一。
如何理解呢?:就比如说,我们要求监视的文件描述符为1,2,3,4,那么我们这里nfds应该填的值就应该是5。如果我们要监视的是4,5,6,那么nfds就应该是7。
-
最后一个参数timeout,用来设置 select()的等待时间
站在应用层的角度,我们有三种等待方式(和五种IO模型关联度不大但有相似性):
1、阻塞等待:也就是说等待文件描述符时条件不满足就不返回。也就是把最后一个参数设置为NULL,如果有多个fd,如果至少有一个fd就绪,select就会返回。
2、非阻塞等待:不管有没有就绪的fd,都直接立即返回
3、time_out方式:即通过结构体timeval来设定一个固定的等待时间,如果提前有事件发生,就提前返回,如果没有,就等待了指定时间之后自动返回。
我们接着来看一下这个结构体:
c++
#include <sys/time.h>
struct timeval {
time_t tv_sec; // 秒 (seconds)
suseconds_t tv_usec; // 微秒 (microseconds)
};
没错,他只有两个参数,一个表示秒,一个表示微秒,如果我们穿进去的timeout值是{5,0},就是代表我们设置的时间是5秒,如果五秒内未收到,就会自动返回。
值得注意的是,这个timeout是一个输入输出型参数,我们输入时是{5,0},我们等待了3秒,收到了一个事件,于是就会把timeout的值设为{2,0},表示剩下的时间,随后带出去。
所以如果我们是第五秒返回,timeout的值就会为{0,0}。
-
这个函数返回值是一个int类型的数,那么这个返回值有什么特殊含义呢?
如果返回值为-1,那么就代表select函数等待失败了,是调用失败的问题。如果返回值为0,那么代表select需要等待的fd中没有一个是就绪了的。如果n大于0,就代表了准备就绪的fd是个数。
-
中间三个参数我放一起讲了:fd_set *readfds , fd_set writefds , fd_set exceptfds。
可以看得出,fd_set是一个结构体类型。它本质上是一个位图(bit mask),用于记录我们感兴趣的一组文件描述符
这是操作系统提供给我们的数据类型,我们可以向这里添加多个文件描述符。如何理解这三个参数呢?为什么会有三个位图呢?:
我们设置select通常有哪方面的事件需要我们的关心
1、关心读事件:底层特定的文件描述符是否可读?接收缓冲区是否有数据?
2、关心写事件:fd是否可写?发送缓冲区是否有空间?
3、关心异常事件:fd是否出现异常?是否出现错误的fd?
这三个关心,分别一一对应上面的三个fd_set类型参数。
如果你对一个文件描述符,关心它的读写和是否异常,你就把这个文件描述符对应的比特位在读、写和异常三个集合中都置为1,在select调用时会同时监控该描述符的这三种事件,当任一条件满足时返回,并根据对应集合中的比特位变化判断具体就绪的事件类型。
而fd_set是类似这样的结构:
c
struct fd_set{
int bits[N];
}
如果这里的N是100,那么就相当于有4* 100* 8=3200个比特位,我们可以很轻松的通过/32等操作来获取到对应的下标,%32获取相对位置。
fd_set是一个固定的类型,那么他就有一个固定的大小,大小固定的位图,所以能够存储的fd也是固定的,这就代表了select可以管理的fd数量也是有上限的,所以select适合在小型应用上使用。
select函数的参数中,只有第一个nfds是输入型,其他四个全是输入输出型参数!!!
这就代表着我们的位图结构的01值也是会被修改的:
1、输入的时候:
用户告诉内核,你要帮我关心readfds位图中,被设置了的fd上的读事件,此时比特位上的编号是fd的序号,比特位内容代表是否需要关心对应的fd。
2、输出的时候:
内核告诉用户,你让我关心的fd中,有哪些fd是已经就绪了,此时比特位上的编号是fd的序号,比特位内容代表是对应位置的fd是否准备就绪。
在输出后,如果我们检索发现对应fd的位置的值为1,就代表这个fd已经准备就绪了,所以我们此时对其进行read等操作,就不会进入阻塞状态(因为已经准备就绪了)。
这也迎来了一个新的问题:我们如何检索呢?难不成一个一个的进行二进制的检查吗?
针对这个问题,我们提供了四个宏方法:

-
FD_ZERO- 功能:清空文件描述符集合,将所有位初始化为0。
- 作用:创建一个空的描述符集合,为后续添加描述符做准备。
-
FD_SET- 功能:将指定的文件描述符添加到集合中。
- 作用:设置需要监视的特定描述符,将其对应的位设为1。
-
FD_CLR- 功能:从集合中移除指定的文件描述符。
- 作用:取消对某个描述符的监视,将其对应的位设为0。
-
FD_ISSET- 功能:检查指定的文件描述符是否在集合中。
- 作用:判断某个描述符是否处于就绪状态,返回非零值表示就绪,0表示未就绪。
这四个宏共同完成了对 fd_set 位图集合的初始化、设置、清除和查询操作,是 select 多路复用机制的基础工具。
多路转接服务器代码demo
介绍完这个select函数,我们就带着大家来实现一个简单的多路转接模式的服务器demo代码。
事先说明,我们后面所用的各种自创头文件,例如log.hpp,都是我们之前的文章(同一个专栏:linux网络编程里的文章)所亲自带着大家编写的,所以可以参考之前的完整头文件,我这里就不再赘述。
自创头文件需求如下:

准备好后,我们就创建以下三个文件:
- SelectServer.hpp:这个用来定义我们的服务器类等操作
- SelectMain.cc:这个用来创建初始化我们的服务器对象,并运行服务器
- Makfile:用来方便我们在ubuntu终端里快速编译出可执行程序进行测试
SelectServer.hpp
首先,我们将可能用到的头文件都进行包含:
c++
#pragma once
#include <iostream>
#include <string>
#include "log.hpp"
#include "Socket.hpp"
class SelectServer
{
public:
SelectServer() {}
void Init() {}
void Start() {}
~SelectServer() {}
private:
};
根据之前所学,我们的服务器必须获取到端口号,这个端口号应该是在初始化的时候从外界提供给服务器的构造函数中去,并且,作为服务器,我们必定涉及多个fd文件描述符的监听操作,所以我们的服务器类中还需要包含监听套接字文件描述符,甚至于,我们知道start函数是用来规定一个while循环让服务器不断执行while循环的,所以我们需要用一个状态变量isrunning来表示服务器运行的状态:
c++
#pragma once
#include <iostream>
#include <string>
#include "log.hpp"
#include "Socket.hpp"
#include <memory>
class SelectServer
{
public:
/**
* @brief 默认构造函数
* 创建一个未初始化的SelectServer实例
*/
SelectServer() {}
/**
* @brief 初始化服务器
*/
void Init() {}
/**
* @brief 启动服务器主循环
*/
void Start() {}
~SelectServer() {}
private:
uint16_t _port; // 服务器监听的端口号
std::unique_ptr<Socket> _listen_socket; // 监听socket的智能指针,使用unique_ptr实现独占所有权
// 采用智能指针管理Socket资源,确保异常安全性和自动资源释放
bool _isrunning; // 服务器运行的状态
};
估计大家已经对Socket类有些遗忘了,这里我再说明一下。这个Socket类是我采用模板方法模式对套接字进行封装的头文件。
由于网络编程中存在多种类型的套接字,比如最经典的TCP套接字 和UDP套接字 ,我利用C++的继承和多态 特性设计了一个套接字抽象层。Socket作为抽象基类,定义了套接字操作的统一接口,然后让TcpSocket和UdpSocket(如果实现的话)继承这个基类,各自实现具体的套接字操作。
这样的设计允许我们通过父类指针或引用来操作不同的套接字类型,实现了运行时的多态性,让代码更加灵活和可扩展。
接下来对构造函数进行基础的实现,首先我们明确外界在创建服务器对象的时候需要给我们提供一个端口号,所以我们构造函数的参数中就必须包含这个端口号,而对于智能指针,我们直接构造一个默认的空指针就先凑合用,运行状态自然就是false表示服务器尚未运行起来:
c++
SelectServer(uint16_t port)
:_port(port),
_listen_socket(std::make_unique<TcpSocket>()),
_isrunning(false)
{
}
值得一提的是,我们这里使用了多态的设计 。虽然 _listen_socket 在编译时类型是 std::unique_ptr<Socket>(基类指针),但实际存储的是 TcpSocket 对象(子类对象)。这样在运行时通过虚函数机制,调用 _listen_socket->SocketOrDie() 等方法时,会正确调用到 TcpSocket 类中的具体实现。
对于Init函数,我们可以直接调用套接字类封装的统一接口:
c++
void Init()
{
_listen_socket->BuildTcpSocketMethod(_port);
}
对于start函数,就是启动服务器的功能,在进入循环之前,我们首先需要将状态设置为true,表示服务器已经开始运行的状态,随后在while循环中尝试获取新连接:
c++
void Start()
{
_isrunning=true;
while(_isrunning)
{
InetAddr client; // 创建客户端地址对象,用于接收连接客户端的地址信息
// 调用监听socket的Accept方法等待并接受新的客户端连接
SockPtr newsockfd=_listen_socket->Accepter(&client);
}
}
这个 InetAddr 类是网络地址封装类,主要用于简化 socket 编程中的地址管理。而SockPtr是我们定义在Socket.hpp中的一个类型,底层其实就是一个shared_ptr< Socket>的智能指针。
但是这里存在一个关键问题,大家发现了吗?
我们的这段代码:SockPtr newsockfd = _listen_socket->Accepter(&client); 可能会造成阻塞!!!
为什么呢?因为 accept() 系统调用本身就是一种阻塞式 I/O 操作 。当监听队列中没有已完成的连接时,调用 accept() 的进程会被操作系统挂起,直到有新的客户端连接到来。
这正是我们今天要解决的问题!我们使用 select 多路转接模型的目的,就是为了避免这种阻塞 。通过 select,我们可以同时监视监听socket和所有客户端socket,只有当监听socket真正有新的连接到达时(即变为可读状态),我们才调用 accept(),这样就避免了无谓的阻塞等待。
所以我们不能这样写,而是应该先使用select对我们的listen_socket进行管理。那么如何使用select呢?
我们今天是针对读的功能进行讲解,所以我这里主要考虑的读,而我们之前在介绍select函数的时候说了,我们需要给select提供一个位图,代表我们想要托管的fd。
所以我们必定创建对应的数据类型,并将fd添加进去:
c++
void Start()
{
fd_set rfds; // 读文件描述符集合
_isrunning = true;
while (_isrunning)
{
//-----------------------------------------------------------------------------------------
// 这样写是错误的,可能会出现阻塞的情况
// InetAddr client; // 创建客户端地址对象,用于接收连接客户端的地址信息
// // 调用监听socket的Accept方法等待并接受新的客户端连接
// SockPtr newsockfd=_listen_socket->Accepter(&client);
//-----------------------------------------------------------------------------------------
// 根据之前提到的四个宏方法,我们使用这四个宏方法来操作我们的rfds位图
// 首先就是清零
FD_ZERO(&rfds);
// 随后将listensocket添加到位图中
FD_SET(_listen_socket->Fd(), &rfds);
// 如果不想在调用select的时候阻塞,我们就需要提供一个设定好的timeval变量
struct timeval timeout = {0, 0};
// 我们不能让accept来阻塞检测新连接的到来,而应该让select来负责进行对就绪事件的检测
int n = ::select(_listen_socket->Fd() + 1, &rfds, nullptr, nullptr, &timeout);
// 随后对select的结果进行处理
switch (n)
{
case 0:
// 在指定的超时时间内,没有任何文件描述符就绪,返回0
std::cout << "time out......" << std::endl;
break;
case -1:
// 返回-1,调用出错,需要检查errno判断具体错误类型
perror("select");
break;
default:
// 发生事件就绪
std::cout << "有事件就绪了......" << "timeout: " << timeout.tv_sec << ":" << timeout.tv_usec << std::endl;
break;
}
}
}
但是这样还是会有问题,因为我们没有处理这个就绪事件,在while循环中,n会一直为1(因为监听socket一直可读),导致每次都会打印"事件就绪",所以我们需要对每一个就绪事件进行处理。
这里我们就新增一个方法,专门用来处理事件:
c++
void Start()
{
fd_set rfds; // 读文件描述符集合
_isrunning = true;
while (_isrunning)
{
//-----------------------------------------------------------------------------------------
// 这样写是错误的,可能会出现阻塞的情况
// InetAddr client; // 创建客户端地址对象,用于接收连接客户端的地址信息
// // 调用监听socket的Accept方法等待并接受新的客户端连接
// SockPtr newsockfd=_listen_socket->Accepter(&client);
//-----------------------------------------------------------------------------------------
// 根据之前提到的四个宏方法,我们使用这四个宏方法来操作我们的rfds位图
// 首先就是清零
FD_ZERO(&rfds);
// 随后将listensocket添加到位图中
FD_SET(_listen_socket->Fd(), &rfds);
// 如果不想在调用select的时候阻塞,我们就需要提供一个设定好的timeval变量
struct timeval timeout = {0, 0};
// 我们不能让accept来阻塞检测新连接的到来,而应该让select来负责进行对就绪事件的检测
int n = ::select(_listen_socket->Fd() + 1, &rfds, nullptr, nullptr, &timeout);
// 随后对select的结果进行处理
switch (n)
{
case 0:
// 在指定的超时时间内,没有任何文件描述符就绪,返回0
std::cout << "time out......" << std::endl;
break;
case -1:
// 返回-1,调用出错,需要检查errno判断具体错误类型
perror("select");
break;
default:
// 发生事件就绪
std::cout << "有事件就绪了......" << "timeout: " << timeout.tv_sec << ":" << timeout.tv_usec << std::endl;
HandlerEvents(rfds);
break;
}
}
}
void HandlerEvents(fd_set &who)
{
// 我们这个函数可能不只是处理listenSocket,还可能处理其他的文件描述符,所以我们其实在里面也是要分情况的
// 比如说我现在就是处理listensocket,我就if判断一下
// 如何判断呢?:还是那四个宏方法
if (FD_ISSET(_listen_socket->Fd(), &who))
{
// 只要判断在fd_set位图中的listensocket的位置是1,表示就绪中,就代表我们需要对其进行处理
InetAddr client;
SockPtr newsockfd = _listen_socket->Accepter(&client);//我们这里进行accept的时候不会阻塞,因为我们已经确认了,一定是就绪的状态
if (newsockfd) // 不为空指针
{
std::cout << "获取了一个新连接:" << newsockfd->Fd() << " client info:" << client.Addr() << std::endl;
}
else
{
return ;
}
}
}
在获取连接之后,如果我们要read,recv,对应的文件描述符的读事件是否就绪?我们并不清楚,所以我们还需要把这些文件描述符托管给select。
那么我们要怎么把对应的文件描述符托管给select呢?
在深入探讨具体实现方法之前,我们需要回顾一下之前学到的关键知识点。select函数的第2到第5个参数都是输入输出型参数 ,这意味着我们每次调用select时,都需要重新设置这些参数------特别是几个位图变量,因为它们的内容会被select函数修改。
那么问题来了:我们应该如何高效地设置这些参数呢?这就需要一个设置源 或者说模板参照物 ,保存所有需要监视的文件描述符信息。这样每次调用select时,我们都可以基于这个"模板"快速初始化位图。
所有的历史文件描述符都需要被服务器妥善保存,以便多次添加到fd_set中。为此,我们引入一个关键的数据结构------辅助数组。
这个数据结构不一定必须是数组,任何能够存储信息的数据结构(如哈希表、链表等)都可以胜任。为了简化实现,我们这里选择使用数组。
首先定义一个宏NUM来表示数组大小:#define NUM sizeof(fd_set) * 8
然后在类的成员变量中声明一个整数数组::int _fd_array[NUM]; // 辅助数组,从当我们每次设置位图的锚点
接下来,我们需要在Init函数中对这个数组进行初始化。初始化的操作很简单:将数组中所有位置设置为默认值-1,表示这些位置尚未用于存储文件描述符。
c++
void Init()
{
_listen_socket->BuildTcpSocketMethod(_port);
for (int i = 0; i < NUM; ++i)
{
_fd_array[i] = -1;
}
}
为了代码的可读性和可维护性,我们可以定义一个全局变量或宏来表示默认值:
const int defaultfd = -1;
我们还规定一个重要的约定:辅助数组下标为0的位置必须保存监听套接字(listensockfd)。因此在初始化函数的最后,我们需要进行特殊赋值:
c++
void Init()
{
_listen_socket->BuildTcpSocketMethod(_port);
for (int i = 0; i < NUM; ++i)
{
_fd_array[i] = defaultfd;
}
_fd_array[0]=_listen_socket->Fd();
}
在Start函数中,我们必须在每次调用select之前完成对rfds的设置。这个设置过程需要基于当前循环中辅助数组的内容来进行:
c++
FD_ZERO(&rfds);
for (int i = 0; i < NUM; ++i)
{
if (_fd_array[i] == defaultfd) // 辅助数组也没要求监管,那就不管
{
continue;
}
// 到这里就是要管的
// 这个步骤我们不再是只将listensocket添加到位图中,还会添加辅助数组里存储的文件描述符(这个文件描述符可能是上次循环获取到的,要求我们这次循环对其进行管理)
FD_SET(_fd_array[i], &rfds);
}
由于我们已经在遍历辅助数组,可以顺便计算当前最大的文件描述符值。这个值对于select函数的第一个参数至关重要:
c++
void Start()
{
fd_set rfds; // 读文件描述符集合
_isrunning = true;
while (_isrunning)
{
//-----------------------------------------------------------------------------------------
// 这样写是错误的,可能会出现阻塞的情况
// InetAddr client; // 创建客户端地址对象,用于接收连接客户端的地址信息
// // 调用监听socket的Accept方法等待并接受新的客户端连接
// SockPtr newsockfd=_listen_socket->Accepter(&client);
//-----------------------------------------------------------------------------------------
// 根据之前提到的四个宏方法,我们使用这四个宏方法来操作我们的rfds位图
// 首先就是清零
FD_ZERO(&rfds);
int maxfd = defaultfd;
for (int i = 0; i < NUM; ++i)
{
if (_fd_array[i] == defaultfd) // 辅助数组也没要求监管,那就不管
{
continue;
}
// 到这里就是要管的
// 这个步骤我们不再是只将listensocket添加到位图中,还会添加辅助数组里存储的文件描述符(这个文件描述符可能是上次循环获取到的,要求我们这次循环对其进行管理)
FD_SET(_fd_array[i], &rfds);
maxfd = std::max(maxfd, _fd_array[i])
}
// 如果不想在调用select的时候阻塞,我们就需要提供一个设定好的timeval变量
struct timeval timeout = {0, 0};
// 我们不能让accept来阻塞检测新连接的到来,而应该让select来负责进行对就绪事件的检测
int n = ::select(maxfd, &rfds, nullptr, nullptr, &timeout);
// 随后对select的结果进行处理
switch (n)
{
case 0:
// 在指定的超时时间内,没有任何文件描述符就绪,返回0
std::cout << "time out......" << std::endl;
break;
case -1:
// 返回-1,调用出错,需要检查errno判断具体错误类型
perror("select");
break;
default:
// 发生事件就绪
std::cout << "有事件就绪了......" << "timeout: " << timeout.tv_sec << ":" << timeout.tv_usec << std::endl;
HandlerEvents(rfds);
break;
}
}
}
目前辅助数组还没有接收新连接的功能,我们需要在HandlerEvents函数中实现这个逻辑:
c++
void HandlerEvents(fd_set &who)
{
// 我们这个函数可能不只是处理listenSocket,还可能处理其他的文件描述符,所以我们其实在里面也是要分情况的
// 比如说我现在就是处理listensocket,我就if判断一下
// 如何判断呢?:还是那四个宏方法
if (FD_ISSET(_listen_socket->Fd(), &who))
{
// 只要判断在fd_set位图中的listensocket的位置是1,表示就绪中,就代表我们需要对其进行处理
InetAddr client;
SockPtr newsockfd = _listen_socket->Accepter(&client); // 我们这里进行accept的时候不会阻塞,因为我们已经确认了,一定是就绪的状态
if (newsockfd) // 不为空指针
{
std::cout << "获取了一个新连接:" << newsockfd->Fd() << " client info:" << client.Addr() << std::endl;
int pos = -1;
for (int j = 0; j < NUM; ++j)
{
if (_fd_array[j] == defaultfd)
{
// 之前没监控,现在要监控了
pos = j;
break;
}
}
if (pos == -1)
{
LOG(LogLevel::ERROR) << "服务器已经满了......";
::close(newsockfd->Fd());
// newsockfd智能指针离开作用域时会自动释放
return;
}
else
{
_fd_array[pos] = newsockfd->Fd();
}
}
else
{
return;
}
}
}
随着程序的运行,辅助数组中会有越来越多的连接需要处理。我们需要遍历整个数组,检查每个文件描述符是否就绪,并做相应的处理。为了提升效率,可以进行适当的优化(剪枝):
c++
void HandlerEvents(fd_set &who)
{
for (int i = 0; i < NUM; ++i)
{
if (_fd_array[i] == defaultfd)
{
continue;
}
if (_fd_array[i] == _listen_socket->Fd())
{
// 我们这个函数可能不只是处理listenSocket,还可能处理其他的文件描述符,所以我们其实在里面也是要分情况的
// 比如说我现在就是处理listensocket,我就if判断一下
// 如何判断呢?:还是那四个宏方法
if (FD_ISSET(_fd_array[i], &who))
{
// 只要判断在fd_set位图中的listensocket的位置是1,表示就绪中,就代表我们需要对其进行处理
InetAddr client;
SockPtr newsockfd = _listen_socket->Accepter(&client); // 我们这里进行accept的时候不会阻塞,因为我们已经确认了,一定是就绪的状态
if (newsockfd) // 不为空指针
{
std::cout << "获取了一个新连接:" << newsockfd->Fd() << " client info:" << client.Addr() << std::endl;
int pos = -1;
for (int j = 0; j < NUM; ++j)
{
if (_fd_array[j] == defaultfd)
{
// 之前没监控,现在要监控了
pos = j;
break;
}
}
if (pos == -1)
{
LOG(LogLevel::ERROR) << "服务器已经满了......";
::close(newsockfd->Fd());
// newsockfd智能指针离开作用域时会自动释放
return;
}
else
{
_fd_array[pos] = newsockfd->Fd();
}
}
else
{
return;
}
}
}
else
{
if (FD_ISSET(_fd_array[i], &who))
{
// 此时如果走到这里,就是合法的,已经准备就绪的,普通的fd,那么我们可以直接调用recv等函数了
// 此时并不会发生阻塞,因为我们的fd已经准备好了
char buffer[1024];
ssize_t n = ::recv(_fd_array[i], buffer, sizeof(buffer) - 1, 0);
// 接下来就是公式化处理n的情况并打印了
if (n > 0)
{
buffer[n] = 0;
std::cout << "client# " << buffer << std::endl;
}
else if (n == 0)
{
std::cout << "客户端退出" << " : " << _fd_array[i] << std::endl;
::close(_fd_array[i]);
_fd_array[i] = defaultfd;
return;
}
else
{
LOG(LogLevel::ERROR) << "客户端读取出错" << _fd_array[i];
::close(_fd_array[i]);
_fd_array[i] = defaultfd;
return;
//理论上这个错误应该进行处理异常,但是我们这里就不考虑了
}
}
}
}
}
为了方便测试和观察效果,我们可以添加回显功能:
c++
buffer[n] = 0;
std::cout << "client# " << buffer << std::endl;
//添加回显信息
std::string message = "echo#";
message += buffer;
::send(_fd_array[i], message.c_str(), message.size(), 0);
考虑到这只是一个演示用的示例代码,为了避免复杂度过高影响理解,我就不再添加更多功能了。
SelectMain.cc
这个代码就更简单了,我们写了很多次,我就直接端上来了:
c++
#include "SelectServer.hpp"
// ./select_server 8080
int main(int argc, char *argv[])
{
if(argc != 2)
{
std::cout << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
ENABLE_CONSOLE_LOG();
uint16_t local_port = std::stoi(argv[1]);
std::unique_ptr<SelectServer> ssvr = std::make_unique<SelectServer>(local_port);
ssvr->Init();
ssvr->Start();
return 0;
}
我们SelectServer.hpp的汇总代码为:
c++
#pragma once
#include <iostream>
#include <string>
#include "log.hpp"
#include "Socket.hpp"
#include <memory>
#define NUM sizeof(fd_set) * 8
const int defaultfd = -1;
class SelectServer
{
public:
/**
* @brief 默认构造函数
* 创建一个未初始化的SelectServer实例
*/
SelectServer(uint16_t port)
: _port(port),
_listen_socket(std::make_unique<TcpSocket>()),
_isrunning(false)
{
}
/**
* @brief 初始化服务器
*/
void Init()
{
_listen_socket->BuildTcpSocketMethod(_port);
for (int i = 0; i < NUM; ++i)
{
_fd_array[i] = defaultfd;
}
_fd_array[0] = _listen_socket->Fd();
}
/**
* @brief 启动服务器主循环
*/
void Start()
{
fd_set rfds; // 读文件描述符集合
_isrunning = true;
while (_isrunning)
{
//-----------------------------------------------------------------------------------------
// 这样写是错误的,可能会出现阻塞的情况
// InetAddr client; // 创建客户端地址对象,用于接收连接客户端的地址信息
// // 调用监听socket的Accept方法等待并接受新的客户端连接
// SockPtr newsockfd=_listen_socket->Accepter(&client);
//-----------------------------------------------------------------------------------------
// 根据之前提到的四个宏方法,我们使用这四个宏方法来操作我们的rfds位图
// 首先就是清零
FD_ZERO(&rfds);
int maxfd = defaultfd;
for (int i = 0; i < NUM; ++i)
{
if (_fd_array[i] == defaultfd) // 辅助数组也没要求监管,那就不管
{
continue;
}
// 到这里就是要管的
// 这个步骤我们不再是只将listensocket添加到位图中,还会添加辅助数组里存储的文件描述符(这个文件描述符可能是上次循环获取到的,要求我们这次循环对其进行管理)
FD_SET(_fd_array[i], &rfds);
maxfd = std::max(maxfd, _fd_array[i]);
}
// 如果不想在调用select的时候阻塞,我们就需要提供一个设定好的timeval变量
struct timeval timeout = {10, 0};
// 我们不能让accept来阻塞检测新连接的到来,而应该让select来负责进行对就绪事件的检测
int n = ::select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);
// 随后对select的结果进行处理
switch (n)
{
case 0:
// 在指定的超时时间内,没有任何文件描述符就绪,返回0
std::cout << "time out......" << std::endl;
break;
case -1:
// 返回-1,调用出错,需要检查errno判断具体错误类型
perror("select");
break;
default:
// 发生事件就绪
std::cout << "有事件就绪了......" << "timeout: " << timeout.tv_sec << ":" << timeout.tv_usec << std::endl;
HandlerEvents(rfds);
break;
}
}
}
void HandlerEvents(fd_set &who)
{
for (int i = 0; i < NUM; ++i)
{
if (_fd_array[i] == defaultfd)
{
continue;
}
if (_fd_array[i] == _listen_socket->Fd())
{
// 我们这个函数可能不只是处理listenSocket,还可能处理其他的文件描述符,所以我们其实在里面也是要分情况的
// 比如说我现在就是处理listensocket,我就if判断一下
// 如何判断呢?:还是那四个宏方法
if (FD_ISSET(_fd_array[i], &who))
{
// 只要判断在fd_set位图中的listensocket的位置是1,表示就绪中,就代表我们需要对其进行处理
InetAddr client;
SockPtr newsockfd = _listen_socket->Accepter(&client); // 我们这里进行accept的时候不会阻塞,因为我们已经确认了,一定是就绪的状态
if (newsockfd) // 不为空指针
{
std::cout << "获取了一个新连接:" << newsockfd->Fd() << " client info:" << client.Addr() << std::endl;
int pos = -1;
for (int j = 0; j < NUM; ++j)
{
if (_fd_array[j] == defaultfd)
{
// 之前没监控,现在要监控了
pos = j;
break;
}
}
if (pos == -1)
{
LOG(LogLevel::ERROR) << "服务器已经满了......";
::close(newsockfd->Fd());
// newsockfd智能指针离开作用域时会自动释放
return;
}
else
{
_fd_array[pos] = newsockfd->Fd();
}
}
else
{
return;
}
}
}
else
{
if (FD_ISSET(_fd_array[i], &who))
{
// 此时如果走到这里,就是合法的,已经准备就绪的,普通的fd,那么我们可以直接调用recv等函数了
// 此时并不会发生阻塞,因为我们的fd已经准备好了
char buffer[1024];
ssize_t n = ::recv(_fd_array[i], buffer, sizeof(buffer) - 1, 0);
// 接下来就是公式化处理n的情况并打印了
if (n > 0)
{
buffer[n] = 0;
std::cout << "client# " << buffer << std::endl;
// 添加回显信息
std::string message = "echo#";
message += buffer;
::send(_fd_array[i], message.c_str(), message.size(), 0);
}
else if (n == 0)
{
std::cout << "客户端退出" << " : " << _fd_array[i] << std::endl;
::close(_fd_array[i]);
_fd_array[i] = defaultfd;
return;
}
else
{
LOG(LogLevel::ERROR) << "客户端读取出错" << _fd_array[i];
::close(_fd_array[i]);
_fd_array[i] = defaultfd;
return;
// 理论上这个错误应该进行处理异常,但是我们这里就不考虑了
}
}
}
}
}
~SelectServer() {}
private:
uint16_t _port; // 服务器监听的端口号
std::unique_ptr<Socket> _listen_socket; // 监听socket的智能指针,使用unique_ptr实现独占所有权
// 采用智能指针管理Socket资源,确保异常安全性和自动资源释放
bool _isrunning; // 服务器运行的状态
int _fd_array[NUM]; // 辅助数组,从当我们每次设置位图的锚点
};
虽然我们的基本实现已经完成,但大家可能已经注意到,HandlerEvents函数的功能更像是事件分发器的角色。在更完善的实现中,我们可以考虑以下改进:
-
将事件处理逻辑分离:监听事件和客户端数据处理可以拆分为独立的函数
-
增加错误恢复机制:处理更复杂的异常情况
-
优化数据结构:使用更高效的数据结构管理大量连接
-
添加日志和监控:更详细的运行状态记录
不过,作为一个教学示例,当前的实现已经足够帮助我们理解select多路转接模型的核心思想和基本用法了。
运行可执行程序,你可以看见这样的效果:

代码运行结果分析
从运行结果可以看出,我们的Select服务器已经成功运行并正确处理了客户端连接。让我们逐步分析这个结果:
1. 服务器启动成功
2025-12-01 21:45:31\] \[DEBUG\] \[56548\] \[Socket.hpp\] \[84\] - socket create success \[2025-12-01 21:45:31\] \[DEBUG\] \[56548\] \[Socket.hpp\] \[101\] - bind create success: 3 \[2025-12-01 21:45:31\] \[DEBUG\] \[56548\] \[Socket.hpp\] \[123\] - listen create success: 3 **解读**:服务器成功创建了socket(文件描述符fd=3),绑定到8080端口,并开始监听连接。这个fd=3就是我们的监听socket。 ##### 2. **多路转接模式正常工作** time out... 有事件就绪了...timeout: 6:978870 **解读** :服务器首先经历了几次超时(timeout),这表明在初始阶段没有客户端连接。当有客户端连接时,`select`检测到事件并立即返回,进入了事件处理流程。 ##### 3. **成功接受客户端连接** 获取了一个新连接:4 client info=127.0.0.1:10 **解读**:服务器成功接受了第一个客户端连接,为该连接分配了文件描述符fd=4。客户端IP是127.0.0.1(本地),端口是10。 **注意**:这里显示端口为10是因为客户端使用了短暂的临时端口。 ##### 4. **客户端通信验证** time out... 有事件就绪了...timeout: 21482516 client mihao 有事件就绪了...timeout: 11518275 client hello **解读**:客户端发送了两条消息: * "mihao" * "hello" 服务器正确接收并处理了这两条消息。从telnet端的响应可以看到: 1#mihao echo#mihao hello echo#hello **验证**:我们的回显功能正常工作!服务器在接收到客户端消息后,会添加"echo#"前缀并发送回去。 ##### 5. **客户端正常断开连接** 有事件就绪了...timeout: 61454245 客户端退出:4 **解读** :当客户端输入`^]`后输入`quit`退出时: \^\]quit telnet\> quit Connection closed. 服务器检测到fd=4的连接关闭,正确清理了资源(关闭文件描述符并将数组位置重置为defaultfd)。 ##### 6. **并发连接测试** 获取了一个新连接:4 client info=127.0.0.1:10 有事件就绪了...timeout: 41185913 获取了一个新连接:5 client info=127.0.0.1:10 **解读** :这里出现了**关键现象**: 1. 第一个连接再次建立,分配到fd=4 2. 紧接着第二个连接建立,分配到fd=5 **证明** :我们的服务器确实支持**同时处理多个客户端连接**!这是多路转接模型的核心优势。 ##### 7. **客户端异常断开测试** Connection closed by foreign host. **解读**:当客户端异常断开时,服务器能够检测到并正确处理连接关闭。 *** ** * ** *** ### Select的总结 #### 1. 工作模式回顾 我们一路从最开始的阻塞式accept走过来,到现在实现了完整的Select多路转接模型。可以看到,Select的核心思想就是"一个管家管多家"------用一个线程(或者说一个进程)同时监视多个文件描述符,哪个准备好了就处理哪个。 就像我们的测试结果里展示的,fd=3是监听socket,fd=4和fd=5是客户端连接,它们都在同一个select调用中被同时监视。当有客户端发来"hello"或者"mihao"时,select就能及时告诉我们:"嘿,fd=4有数据了,快去处理!" #### 2. 关键设计要点 从代码实现中我们学到了,使用select有几个**必须遵守的规则**: 第一,**辅助数组不能少** 。就像我们写的`_fd_array[NUM]`这个数组,它为什么那么重要?因为select会修改传入的fd_set啊!每次select返回后,fd_set里只剩下就绪的描述符了。如果我们不把所有的描述符备份起来,下次调用select时,那些没就绪但还需要监视的描述符就丢了。 第二,**每次都要重新设置**。这是很多人容易忘记的点。每次调用select前,都必须: ```c++ FD_ZERO(&rfds); // 先清零 for (遍历_fd_array) { if (fd有效) { FD_SET(fd, &rfds); // 重新添加 maxfd = std::max(maxfd, fd); // 更新最大值 } } int n = select(maxfd + 1, ...); // 注意要+1! ``` 第三,**遍历检查不能偷懒**。select只告诉你"有几个描述符就绪了",但不告诉你"具体是哪几个"。所以我们得自己一个个检查: ```c++ if (FD_ISSET(_listen_socket->Fd(), &rfds)) { // 处理新连接 } for (遍历所有客户端fd) { if (FD_ISSET(client_fd, &rfds)) { // 处理客户端数据 } } ``` #### 3. Select的特点分析 从我们刚才看到的总结里,select有几个**硬性限制**: **文件描述符数量有限制** :这个限制来自fd_set这个位图的大小。就像我们代码里写的`#define NUM sizeof(fd_set) * 8`,算出来就是能监控的最大fd数量。有的系统是1024,有的可能是4096。你想想,如果我们要做一个大型的即时通讯服务器,同时在线几万人,1024个fd够用吗?明显不够! **性能有瓶颈**:每次调用select都要做三件事: 1. 把整个fd_set从用户空间拷贝到内核空间(拷贝开销) 2. 内核要遍历所有fd,检查哪些就绪了(遍历开销) 3. 把结果再从内核拷贝回用户空间(又是拷贝开销) 而且最要命的是,就算只有1个fd就绪,select也要把0到maxfd-1的所有fd都检查一遍。这就像是老师点名:"1号?到。2号?到。3号?......1023号?到。"哪怕只有1个同学举手,老师也得把全班都点一遍名。 #### 4. Select的实际表现 从我们的测试结果看,select确实**能工作**,而且工作得还不错: * 能处理多个客户端(我们看到fd=4和fd=5同时存在) * 能正确收发数据(echo功能正常) * 能检测连接断开(客户端退出时正确清理) 但是,如果你仔细观察测试结果里的那些"time out...",会发现我们设置了超时,select会在没有事件时定期返回。这种设计可以让我们在不忙的时候做些其他事情,比如打印日志、检查资源等。 #### 5. Select的适用场景 所以,select适合用在什么地方呢? **小型服务器** :连接数不多,比如几十个、几百个连接。 **跨平台需求** :如果你的程序要在Windows、Linux、macOS上都运行,select是个安全的选择,因为它几乎所有系统都支持。 **学习入门**:理解select是学习更高级的epoll、kqueue的基础。先把select搞明白了,后面的就更容易理解了。 但是,如果你要写一个高性能的服务器,处理成千上万的并发连接,那么select可能就不太合适了。这时候就该请出我们Linux下的王牌------epoll了。 #### 6. 从Select到Epoll 虽然我们今天重点讲的是select,但我想提一嘴epoll的设计思路,作为对比: select就像是**老师点名** ,每个人都点一遍。 epoll就像是**学生举手**,谁准备好了谁举手,老师只看向举手的人。 select需要**每次重新设置** 要监视的fd。 epoll只需要**注册一次**,然后等待事件通知。 select有**fd数量限制** 。 epoll理论上**只受内存限制**,可以处理数万甚至数十万的连接。 不过,epoll是Linux特有的,Windows上不能用。而select虽然效率不高,但胜在**通用性强**。 *** ** * ** *** 所以,总结一下:select是一个**经典的、通用的、但效率有限**的多路转接方案。它教会了我们多路复用的基本思想,为我们理解更高效的I/O模型打下了坚实的基础。在我们的代码中,select已经成功证明了它的价值------让单线程处理多连接成为了可能!