文章目录
- 引言
- 一、框架设计思路
- 二、核心代码解析
-
- 1. Socket 虚基类(Socket.hpp)
- 2. TcpSocket 派生类(Socket.hpp)
- 3. SelectServer 封装(SelectServer.hpp)
-
- 3.1 初始化流程
- 3.2 事件循环核心
- 3.3 客户端事件处理
- 4. 客户端实现(TcpClient.hpp)
- 三、Select 使用核心注意事项
- 四、测试效果展示
-
- 1. Makefile
- 2. 启动服务器
- 3. 客户端连接测试(多客户端并发)
- 4. 服务器日志输出
- 5. 异常测试
- 总结与扩展
引言
Select 是 Linux 系统中经典的 I/O 多路复用模型,通过单个进程 / 线程管理多个文件描述符(FD),实现并发处理多个客户端连接。本文将基于 “虚基类抽象 + 派生类实现” 的设计思想,封装 Socket 接口并完成 SelectServer 开发,同时详解 select 使用的坑点与解决方案。
一、框架设计思路
核心设计理念
采用 “接口抽象 + 具体实现” 的分层架构,优势如下:
- 解耦性:Socket 虚基类定义统一接口,派生类(如 TcpSocket)负责具体协议实现;
- 扩展性:后续可快速添加 UdpSocket 等派生类,无需修改服务器核心逻辑;
- 可维护性:业务逻辑与网络操作分离,SelectServer 专注于事件管理。
架构分层
┌─────────────────┐
│ SelectServer │ 服务器核心:事件循环、连接管理、回调分发
└────────┬────────┘
│
┌────────▼────────┐
│ TcpSocket │ 派生类:TCP协议的Socket实现
└────────┬────────┘
│
┌────────▼────────┐
│ Socket │ 虚基类:定义Socket统一接口
└─────────────────┘
二、核心代码解析
1. Socket 虚基类(Socket.hpp)
定义 Socket 操作的统一接口,所有派生类需实现纯虚函数:
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 初始化流程
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 事件循环核心
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 客户端事件处理
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 使用核心注意事项
- 原因:select 会修改传入的 fd_set,将未就绪的 FD 从集合中清除;
- 解决方案:每次循环调用 FD_ZERO 重置集合,重新添加所有需要监听的 FD(监听 FD + 客户端 FD)。
- 原因:select 的第一个参数是「最大 FD+1」,若值过小会导致高 FD 的事件漏检;若值过大则浪费资源;
- 解决方案:每次添加客户端 FD 时更新 _max_fd,确保其始终是当前最大的 FD。
- 场景:客户端断开连接后,服务器继续调用 send 会触发 SIGPIPE 信号,导致服务器崩溃;
- 解决方案:初始化时调用 signal(SIGPIPE, SIG_IGN) 忽略该信号。
- 场景:accept 可能被信号中断(如 SIGINT),属于非致命错误;
- 解决方案:仅打印日志,不退出进程,继续循环等待下一次连接。
- 问题:遍历客户端集合时删除 FD,会导致迭代器失效;
- 解决方案:使用 for 循环 + 迭代器,删除后通过 it = _clients.erase(it) 更新迭代器。
- n > 0:正常接收数据,触发业务逻辑;
- n == 0:客户端正常断开连接,关闭 FD 并删除;
- n < 0:接收错误,关闭 FD 并删除。
- 作用:与忽略 SIGPIPE 配合,避免客户端断开时 send 触发信号。
四、测试效果展示
1. Makefile
因为代码中涉及到了结构化绑定的语法:for (const auto& [fd, client] : _clients),这是C++17 的特性,所以需要指定 -std=c++17
.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. 启动服务器
./SelectServer 8888
# 输出:
# SelectServer 初始化成功,监听端口:8888,listen_fd:3
# 服务器启动成功,监听端口 8888 …
3. 客户端连接测试(多客户端并发)
客户端 1:
./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:
./TcpClient 127.0.0.1 8888
# 输出:
# 成功连接到服务器[127.0.0.1:8888],客户端fd:5
# 请输入要发送的数据(输入exit退出):多客户端测试
# 已发送:多客户端测试(字节数:8)
# 收到服务器回显:Server Echo: 多客户端测试(字节数:19)
4. 服务器日志输出
新客户端连接,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,服务器日志:
连接已关闭(fd=4)
客户端[127.0.0.1]断开连接,fd: 4 -
服务器优雅停止:Ctrl+C 触发 SIGINT,服务器日志:
收到中断信号,正在断开连接...
关闭客户端连接,fd:5
关闭监听Socket,fd:3
SelectServer 已停止
总结与扩展
- 优点:跨平台(支持 Linux/Windows)、实现简单、无需多线程;
- 缺点:FD 数量上限(默认 1024)、轮询效率低(遍历所有 FD)。





