文章目录
- 引言
- 一、框架设计思路
- 二、核心代码解析
-
- [1. Socket 虚基类(Socket.hpp)](#1. Socket 虚基类(Socket.hpp))
- [2. TcpSocket 派生类(Socket.hpp)](#2. TcpSocket 派生类(Socket.hpp))
- [3. SelectServer 封装(SelectServer.hpp)](#3. SelectServer 封装(SelectServer.hpp))
-
- [3.1 初始化流程](#3.1 初始化流程)
- [3.2 事件循环核心](#3.2 事件循环核心)
- [3.3 客户端事件处理](#3.3 客户端事件处理)
- [4. 客户端实现(TcpClient.hpp)](#4. 客户端实现(TcpClient.hpp))
- [三、Select 使用核心注意事项](#三、Select 使用核心注意事项)
- 四、测试效果展示
-
- [1. Makefile](#1. Makefile)
- [2. 启动服务器](#2. 启动服务器)
- [3. 客户端连接测试(多客户端并发)](#3. 客户端连接测试(多客户端并发))
- [4. 服务器日志输出](#4. 服务器日志输出)
- [5. 异常测试](#5. 异常测试)
- 总结与扩展
引言
Select 是 Linux 系统中经典的 I/O 多路复用模型,通过单个进程 / 线程管理多个文件描述符(FD),实现并发处理多个客户端连接。本文将基于 "虚基类抽象 + 派生类实现" 的设计思想,封装 Socket 接口并完成 SelectServer 开发,同时详解 select 使用的坑点与解决方案。
一、框架设计思路
核心设计理念
采用 "接口抽象 + 具体实现" 的分层架构,优势如下:
- 解耦性 :
Socket虚基类定义统一接口,派生类(如TcpSocket)负责具体协议实现; - 扩展性 :后续可快速添加
UdpSocket等派生类,无需修改服务器核心逻辑; - 可维护性 :业务逻辑与网络操作分离,
SelectServer专注于事件管理。
架构分层
┌─────────────────┐
│ SelectServer │ 服务器核心:事件循环、连接管理、回调分发
└────────┬────────┘
│
┌────────▼────────┐
│ TcpSocket │ 派生类:TCP协议的Socket实现
└────────┬────────┘
│
┌────────▼────────┐
│ Socket │ 虚基类:定义Socket统一接口
└─────────────────┘
二、核心代码解析
1. Socket 虚基类(Socket.hpp)
定义 Socket 操作的统一接口,所有派生类需实现纯虚函数:
cpp
class Socket {
public:
virtual ~Socket() {}
virtual void SocketOrDie() = 0; // 创建套接字(失败则退出)
virtual void BindOrDie(uint16_t port) = 0; // 绑定端口
virtual void ListenOrDie(int backlog) = 0; // 监听连接
virtual int Accept() = 0; // 接受新连接
virtual void Close() = 0; // 关闭套接字
virtual int Recv(std::string *out) = 0; // 接收数据
virtual int Send(const std::string& message) = 0; // 发送数据
virtual int Connect(const std::string &server_ip, uint16_t port) = 0; // 客户端连接
virtual int Fd() = 0; // 获取文件描述符
};
2. TcpSocket 派生类(Socket.hpp)
实现 TCP 协议的 Socket 操作,重点处理错误场景:
- 关键细节:
SocketOrDie():创建套接字失败直接退出,保证后续操作的有效性;Accept():非致命错误(如信号中断)仅打印日志,不退出进程;Recv/Send:使用MSG_NOSIGNAL标志,避免客户端断开时触发SIGPIPE信号导致服务器崩溃;- 所有系统调用(如bind、listen)均做错误校验,确保稳定性。
3. SelectServer 封装(SelectServer.hpp)
核心职责:管理监听套接字与客户端连接,通过 select 实现事件驱动。
3.1 初始化流程
cpp
bool Init() {
signal(SIGPIPE, SIG_IGN); // 忽略SIGPIPE信号(关键!)
_listen_socket = std::make_unique<TcpSocket>();
_listen_socket->SocketOrDie();
_listen_socket->BindOrDie(_port);
_listen_socket->ListenOrDie(_backlog);
_max_fd = _listen_socket->Fd(); // 初始化最大FD
_is_running = true;
return true;
}
3.2 事件循环核心
cpp
void Start() {
fd_set read_fds;
while (_is_running) {
FD_ZERO(&read_fds); // 重置FD集合(select会修改集合,必须每次重置)
FD_SET(_listen_socket->Fd(), &read_fds);
// 添加所有客户端FD到集合,并更新最大FD
for (const auto& [fd, client] : _clients) {
FD_SET(fd, &read_fds);
_max_fd = std::max(_max_fd, fd);
}
// 阻塞等待事件(无超时)
int ready = select(_max_fd + 1, &read_fds, nullptr, nullptr, nullptr);
if (ready == -1) {
std::cerr << "select失败(非致命)" << std::endl;
continue;
}
// 处理新连接和客户端数据
if (FD_ISSET(_listen_socket->Fd(), &read_fds)) HandleNewConnection();
HandleClientEvent(read_fds);
}
}
3.3 客户端事件处理
cpp
void HandleClientEvent(fd_set& read_fds) {
for (auto it = _clients.begin(); it != _clients.end();) {
int client_fd = it->first;
Socket* client = it->second.get();
if (FD_ISSET(client_fd, &read_fds)) {
std::string data;
int n = client->Recv(&data);
if (n <= 0) { // n=0:客户端正常断开;n<0:接收错误
client->Close();
_client_ips.erase(client_fd);
it = _clients.erase(it); // 安全删除,避免迭代器失效
continue;
}
_handler(client, _client_ips[client_fd], data); // 回调业务逻辑
}
++it;
}
}
4. 客户端实现(TcpClient.hpp)
提供简单的 TCP 客户端,用于测试服务器功能:
- 支持连接服务器、发送数据、接收回显;
- 捕获
SIGINT信号(Ctrl+C),优雅断开连接。
三、Select 使用核心注意事项
- 必须重置 FD 集合(关键坑点!)
- 原因 :
select会修改传入的fd_set,将未就绪的 FD 从集合中清除; - 解决方案 :每次循环调用
FD_ZERO重置集合,重新添加所有需要监听的 FD(监听 FD + 客户端 FD)。
- 原因 :
- 正确维护max_fd
- 原因 :
select的第一个参数是「最大 FD+1」,若值过小会导致高 FD 的事件漏检;若值过大则浪费资源; - 解决方案 :每次添加客户端 FD 时更新
_max_fd,确保其始终是当前最大的 FD。
- 原因 :
- 忽略SIGPIPE信号
- 场景 :客户端断开连接后,服务器继续调用
send会触发SIGPIPE信号,导致服务器崩溃; - 解决方案 :初始化时调用
signal(SIGPIPE, SIG_IGN)忽略该信号。
- 场景 :客户端断开连接后,服务器继续调用
- 处理accept的非致命错误
- 场景 :
accept可能被信号中断(如SIGINT),属于非致命错误; - 解决方案:仅打印日志,不退出进程,继续循环等待下一次连接。
- 场景 :
- 客户端 FD 的安全删除
- 问题:遍历客户端集合时删除 FD,会导致迭代器失效;
- 解决方案 :使用
for循环 + 迭代器,删除后通过it = _clients.erase(it)更新迭代器。
- 正确处理
recv的返回值n > 0:正常接收数据,触发业务逻辑;n == 0:客户端正常断开连接,关闭 FD 并删除;n < 0:接收错误,关闭 FD 并删除。
send添加MSG_NOSIGNAL标志- 作用 :与忽略
SIGPIPE配合,避免客户端断开时send触发信号。
- 作用 :与忽略
四、测试效果展示
1. Makefile
因为代码中涉及到了结构化绑定 的语法:for (const auto& [fd, client] : _clients),这是C++17 的特性,所以需要指定 -std=c++17
bash
.PHONY:all
all:select_server tcpclient
select_server:SelectServer.cc
g++ -o $@ $^ -std=c++17 # -lpthread
tcpclient:TcpClient.cc
g++ -o $@ $^ -std=c++17
.PHONY:clean
clean:
rm -f select_server tcpclient
2. 启动服务器
bash
./SelectServer 8888
# 输出:
# SelectServer 初始化成功,监听端口:8888,listen_fd:3
# 服务器启动成功,监听端口 8888 ...
3. 客户端连接测试(多客户端并发)
客户端 1:
bash
./TcpClient 127.0.0.1 8888
# 输出:
# 成功连接到服务器[127.0.0.1:8888],客户端fd:4
# 请输入要发送的数据(输入exit退出):Hello Select!
# 已发送:Hello Select!(字节数:13)
# 收到服务器回显:Server Echo: Hello Select!(字节数:24)
客户端 2:
bash
./TcpClient 127.0.0.1 8888
# 输出:
# 成功连接到服务器[127.0.0.1:8888],客户端fd:5
# 请输入要发送的数据(输入exit退出):多客户端测试
# 已发送:多客户端测试(字节数:8)
# 收到服务器回显:Server Echo: 多客户端测试(字节数:19)
4. 服务器日志输出
bash
新客户端连接,fd:4,地址:[127.0.0.1:43210]
新客户端连接,fd:5,地址:[127.0.0.1:43211]
[127.0.0.1] 发送数据: Hello Select!
[127.0.0.1] 发送数据: 多客户端测试
5. 异常测试
-
客户端断开:关闭客户端 1,服务器日志:
bash连接已关闭(fd=4) 客户端[127.0.0.1]断开连接,fd: 4 -
服务器优雅停止 :Ctrl+C 触发
SIGINT,服务器日志:bash收到中断信号,正在断开连接... 关闭客户端连接,fd:5 关闭监听Socket,fd:3 SelectServer 已停止
总结与扩展
- Select 模型优缺点
- 优点:跨平台(支持 Linux/Windows)、实现简单、无需多线程;
- 缺点:FD 数量上限(默认 1024)、轮询效率低(遍历所有 FD)。
- 后续优化方向
- 增加超时机制:select的第 5 个参数设置超时时间,避免永久阻塞;
- 支持 FD 扩容:修改系统参数
/proc/sys/fs/file-max提升 FD 上限; - 业务逻辑异步化:将耗时操作(如数据库查询)放入线程池,避免阻塞事件循环;
- 升级到
epoll:高并发场景下替换为epoll模型,解决 select 的性能瓶颈。