欢迎光临
我们一直在努力

Select 服务器实战教学:从 Socket 封装到多客户端并发

文章目录

  • 引言
  • 一、框架设计思路​
  • 二、核心代码解析
    • 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 使用核心注意事项​

  • 必须重置 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

    .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 已停止

    总结与扩展

  • Select 模型优缺点​
    • 优点:跨平台(支持 Linux/Windows)、实现简单、无需多线程;​
    • 缺点:FD 数量上限(默认 1024)、轮询效率低(遍历所有 FD)。​
  • 后续优化方向​
  • 增加超时机制:select的第 5 个参数设置超时时间,避免永久阻塞;​
  • 支持 FD 扩容:修改系统参数 /proc/sys/fs/file-max 提升 FD 上限;​
  • 业务逻辑异步化:将耗时操作(如数据库查询)放入线程池,避免阻塞事件循环;​
  • 升级到 epoll:高并发场景下替换为 epoll 模型,解决 select 的性能瓶颈。
  • 赞(0)
    未经允许不得转载:171主机测评 » Select 服务器实战教学:从 Socket 封装到多客户端并发
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址