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

文章目录

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

  1. 必须重置 FD 集合(关键坑点!)
    • 原因select 会修改传入的 fd_set,将未就绪的 FD 从集合中清除;
    • 解决方案 :每次循环调用 FD_ZERO 重置集合,重新添加所有需要监听的 FD(监听 FD + 客户端 FD)。
  2. 正确维护max_fd
    • 原因select 的第一个参数是「最大 FD+1」,若值过小会导致高 FD 的事件漏检;若值过大则浪费资源;
    • 解决方案 :每次添加客户端 FD 时更新 _max_fd,确保其始终是当前最大的 FD。
  3. 忽略SIGPIPE信号
    • 场景 :客户端断开连接后,服务器继续调用 send 会触发 SIGPIPE 信号,导致服务器崩溃;
    • 解决方案 :初始化时调用 signal(SIGPIPE, SIG_IGN) 忽略该信号。
  4. 处理accept的非致命错误
    • 场景accept 可能被信号中断(如 SIGINT),属于非致命错误;
    • 解决方案:仅打印日志,不退出进程,继续循环等待下一次连接。
  5. 客户端 FD 的安全删除
    • 问题:遍历客户端集合时删除 FD,会导致迭代器失效;
    • 解决方案 :使用 for 循环 + 迭代器,删除后通过 it = _clients.erase(it) 更新迭代器。
  6. 正确处理 recv 的返回值
    • n > 0:正常接收数据,触发业务逻辑;
    • n == 0:客户端正常断开连接,关闭 FD 并删除;
    • n < 0:接收错误,关闭 FD 并删除。
  7. 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 已停止

总结与扩展

  1. Select 模型优缺点
    • 优点:跨平台(支持 Linux/Windows)、实现简单、无需多线程;
    • 缺点:FD 数量上限(默认 1024)、轮询效率低(遍历所有 FD)。
  2. 后续优化方向
    1. 增加超时机制:select的第 5 个参数设置超时时间,避免永久阻塞;
    2. 支持 FD 扩容:修改系统参数 /proc/sys/fs/file-max 提升 FD 上限;
    3. 业务逻辑异步化:将耗时操作(如数据库查询)放入线程池,避免阻塞事件循环;
    4. 升级到 epoll:高并发场景下替换为 epoll 模型,解决 select 的性能瓶颈。
相关推荐
一勺菠萝丶2 小时前
芋道后端部署后总自己挂?从 Nginx 报错到 OOM Kill 的完整排查与修复(2核2G 服务器实战)
服务器·chrome·nginx
云边有个稻草人3 小时前
Windows 里用 Linux 不卡顿?WSL + cpolar让跨系统开发变简单
linux·运维·服务器·cpolar
LXY_BUAA3 小时前
将linux操作系统装入U盘20251107
linux·运维·服务器
IDC02_FEIYA3 小时前
Discuz论坛管理员怎么重置修改用户密码?
运维·服务器
九河云4 小时前
华为云ECS与Flexus云服务器X实例:差异解析与选型指南
大数据·运维·服务器·网络·人工智能·华为云
kaoa0004 小时前
Linux入门攻坚——53、drbd - Distribute Replicated Block Device,分布式复制块设备-2
linux·运维·服务器
ajax_beijing4 小时前
华为云ELB
运维·服务器·华为云
RisunJan4 小时前
Linux命令-e2label命令(设置第二扩展文件系统的卷标)
linux·运维·服务器
倦王4 小时前
Linux一些基本命令--黑马学习
linux·运维·服务器