从零开始:C++ 多进程 TCP 服务器实战(续篇)

文章目录

  • 引言
  • [1. 核心设计:"孙子进程执行任务" 的原理](#1. 核心设计:“孙子进程执行任务” 的原理)
    • [1.1 僵尸进程的根源](#1.1 僵尸进程的根源)
    • [1.2 孙子进程方案的逻辑链](#1.2 孙子进程方案的逻辑链)
  • [2. Server 端改造:多进程版本实现](#2. Server 端改造:多进程版本实现)
    • [2.1 TcpServer 类的扩展(信号处理与进程控制)](#2.1 TcpServer 类的扩展(信号处理与进程控制))
    • [2.1.1 头文件与类结构修改(TcpServer.hpp)](#2.1.1 头文件与类结构修改(TcpServer.hpp))
      • [2.1.2 Init 函数扩展:信号初始化](#2.1.2 Init 函数扩展:信号初始化)
    • [2.2 Start 函数改造:多进程核心逻辑](#2.2 Start 函数改造:多进程核心逻辑)
      • [2.2.1 改造后的 Start 函数完整代码](#2.2.1 改造后的 Start 函数完整代码)
    • [2.3 孙子进程的任务处理逻辑:HandleClient 函数](#2.3 孙子进程的任务处理逻辑:HandleClient 函数)
    • [2.4 多进程版本 TcpServer.cc 入口](#2.4 多进程版本 TcpServer.cc 入口)
  • [3. 客户端兼容性:无需修改的 Client 端](#3. 客户端兼容性:无需修改的 Client 端)
  • [4. 编译与多客户端测试](#4. 编译与多客户端测试)
    • [4.1 编译脚本(复用 Makefile)](#4.1 编译脚本(复用 Makefile))
    • [4.2 多客户端并发测试步骤](#4.2 多客户端并发测试步骤)
      • [步骤 1:启动多进程服务器](#步骤 1:启动多进程服务器)
      • [步骤 2:启动多个客户端(模拟并发)](#步骤 2:启动多个客户端(模拟并发))
      • [步骤 3:客户端发送数据,验证并发](#步骤 3:客户端发送数据,验证并发)
      • [步骤 4:查看服务器日志(关键验证)](#步骤 4:查看服务器日志(关键验证))
    • [4.3 僵尸进程验证](#4.3 僵尸进程验证)
  • [5. 多进程方案的优缺点](#5. 多进程方案的优缺点)
    • [5.1 优势:隔离性与稳定性](#5.1 优势:隔离性与稳定性)
    • [5.2 局限:资源开销与扩展性](#5.2 局限:资源开销与扩展性)
  • [6. 后续扩展方向](#6. 后续扩展方向)
    • [6.1 进程池优化:减少进程创建开销](#6.1 进程池优化:减少进程创建开销)
    • [6.2 信号精细化处理:应对异常退出](#6.2 信号精细化处理:应对异常退出)
    • [6.3 多进程间通信:共享配置与状态](#6.3 多进程间通信:共享配置与状态)
  • 总结

引言

上一篇教程实现的 TCP 服务器有个关键局限 ------单客户端阻塞:主循环在处理一个客户端的收发数据时,会阻塞在 recv 调用上,其他客户端的连接请求只能排队等待,直到当前客户端断开。这在实际场景中完全无法满足需求(比如同时有多个用户访问服务)。

要解决并发问题,最直观的方案是多进程模型 :每接收一个客户端连接,就创建一个独立进程处理该客户端的通信,主进程继续监听新连接。但直接用 "父进程创子进程" 会遇到僵尸进程问题 ------ 子进程退出后若未被父进程回收,会残留占用系统资源(进程表项)。

本篇将基于上一篇的 TcpServer 类,改造为多进程服务器,核心采用 "孙子进程执行任务、子进程主动退出" 的设计,从根源上避免僵尸进程,同时保持代码的模块化与原博客的码风一致。

1. 核心设计:"孙子进程执行任务" 的原理

在改造代码前,先明确 "孙子进程" 方案的设计逻辑 ------ 核心是利用 Linux 系统的进程回收机制(init 进程接管孤儿进程),彻底解决僵尸进程问题。

1.1 僵尸进程的根源

当子进程执行完任务退出时,会向父进程发送 SIGCHLD 信号,若父进程未调用 wait()/waitpid() 回收子进程,子进程会变成 "僵尸进程"(状态为 <defunct>),直到父进程退出后才被 init 进程(PID=1)回收。

上一篇的单进程服务器无需考虑此问题,但多进程场景下,父进程需持续监听新连接,无法频繁调用 wait() 阻塞回收子进程 ------ 这就需要更优雅的回收方式。

1.2 孙子进程方案的逻辑链

我们通过 "三层进程" 结构,让退出的进程自动被 init 回收,流程如下:

  1. 父进程(主进程) :仅负责监听端口(listen)和接收连接(accept),不处理任何客户端通信;
  2. 子进程(中间层) :父进程 accept 成功后,创建子进程;子进程不做任何任务,仅创建 "孙子进程" 后立刻退出;
  3. 孙子进程(任务层) :子进程退出后,孙子进程变成 "孤儿进程",被 init 进程接管;孙子进程负责与客户端收发数据,任务完成后退出,init 会自动回收它,无僵尸进程残留。

整个流程的关键:子进程 "短暂存活",仅用于创建孙子进程,退出后让孙子进程被 init 接管,省去父进程回收的麻烦。

2. Server 端改造:多进程版本实现

改造基于上一篇的 TcpServer 类,仅需新增信号处理逻辑、修改 Start 函数的连接处理流程,核心代码兼容原有的回调函数(func_t)和资源管理(智能指针)。

2.1 TcpServer 类的扩展(信号处理与进程控制)

首先在 TcpServer 类中新增信号处理函数(忽略 SIGCHLD 信号,双重保障避免僵尸进程),并补充进程相关的头文件(sys/wait.hsignal.h)。

2.1.1 头文件与类结构修改(TcpServer.hpp)

在原有类定义中,新增私有信号处理函数 HandleSigchld,并在 Init 函数中初始化信号:

cpp 复制代码
// 补充进程与信号相关头文件
#include <sys/wait.h>
#include <signal.h>
#include <sys/types.h>

// 数据处理回调函数类型(与上一篇一致)
typedef std::function<std::string(const std::string&)> func_t;

class TcpServer {
public:
    TcpServer(uint16_t port, func_t handler);
    ~TcpServer();
    bool Init();    // 初始化:创建套接字、绑定、监听、信号初始化
    void Start();   // 启动:多进程处理连接
    void Stop();    // 停止服务器(与上一篇一致)
private:
    // 新增:信号处理函数(忽略SIGCHLD,避免子进程僵尸)
    static void HandleSigchld(int signo) {
        // waitpid(-1, NULL, WNOHANG):非阻塞回收所有退出的子进程
        while (waitpid(-1, NULL, WNOHANG) > 0);
    }

    int _listen_fd;         // 监听套接字(与上一篇一致)
    uint16_t _listen_port;  // 监听端口(与上一篇一致)
    bool _is_running;       // 运行状态(与上一篇一致)
    func_t _data_handler;   // 数据处理回调(与上一篇一致)
};

注意:信号处理函数需定义为 static,因为非静态成员函数依赖 this 指针,无法作为信号回调(信号处理函数无 this 上下文)。

2.1.2 Init 函数扩展:信号初始化

在原有 Init 函数的末尾,添加信号处理初始化代码(确保 SIGCHLD 信号被正确处理):

cpp 复制代码
bool TcpServer::Init() {
    // 1. 创建套接字(与上一篇一致)
    _listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (_listen_fd == -1) {
        perror("socket 创建失败!");
        return false;
    }        
    std::cout << "套接字创建成功,listen_fd: " << _listen_fd << std::endl;

    // 2. 填充服务器地址结构(与上一篇一致)
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(_listen_port);
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    // 3. 绑定套接字与地址(与上一篇一致)
    int bind_ret = bind(_listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
    if (bind_ret == -1) {
        perror("绑定失败");
        close(_listen_fd);
        _listen_fd = -1;
        return false;
    }
    std::cout << "绑定成功,成功监听端口:" << _listen_port << std::endl;

    // 4. 开始监听连接(与上一篇一致)
    int listen_ret = listen(_listen_fd, 5);
    if (listen_ret == -1) {
        perror("listen 失败");
        close(_listen_fd);
        _listen_fd = -1;
        return false;
    }
    std::cout << "监听中,等待客户端连接..." << std::endl;

    // 新增:5. 初始化信号处理(避免僵尸进程)
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = HandleSigchld;  // 绑定信号处理函数
    sigemptyset(&sa.sa_mask);       // 清空信号掩码
    sa.sa_flags = SA_RESTART;       // 被信号中断的系统调用自动重启(如accept)
    if (sigaction(SIGCHLD, &sa, NULL) == -1) {
        perror("sigaction 初始化失败");
        close(_listen_fd);
        _listen_fd = -1;
        return false;
    }

    _is_running = true;
    return true;
}

关键参数说明:SA_RESTART 确保 accept 等系统调用被 SIGCHLD 信号中断后,会自动重启,避免主循环意外退出。

2.2 Start 函数改造:多进程核心逻辑

Start 函数是改造的核心 ------ 原有的 "单客户端循环收发" 逻辑,需替换为 "接收连接→创建子进程→子进程创孙子进程→子进程退出→孙子进程处理任务" 的流程。

2.2.1 改造后的 Start 函数完整代码

cpp 复制代码
void TcpServer::Start() {
    if (!_is_running || _listen_fd == -1) {
        perror("服务器未初始化,无法启动");
        return;
    }

    // 主循环:持续接收新连接(父进程逻辑)
    while (_is_running) {
        struct sockaddr_in client_addr;
        socklen_t client_addr_len = sizeof(client_addr);

        // 1. 父进程:接收客户端连接(阻塞)
        int client_fd = accept(_listen_fd, (struct sockaddr*)&client_addr, &client_addr_len);
        if (client_fd == -1) {
            perror("accept 失败!");
            continue;
        }

        // 解析客户端地址(与上一篇一致)
        std::string client_ip = inet_ntoa(client_addr.sin_addr);
        uint16_t client_port = ntohs(client_addr.sin_port);
        std::cout << "\n客户端连接成功:[" << client_ip << ":" << client_port << "],client_fd: " << client_fd << std::endl;

        // 2. 父进程:创建子进程(中间层)
        pid_t pid1 = fork();
        if (pid1 == -1) {  // fork失败:关闭客户端FD,继续循环
            perror("fork 子进程失败");
            close(client_fd);
            continue;
        } else if (pid1 == 0) {  // 子进程逻辑(中间层)
            close(_listen_fd);  // 子进程无需监听端口,关闭监听FD(避免FD泄漏)

            // 3. 子进程:创建孙子进程(任务层)
            pid_t pid2 = fork();
            if (pid2 == -1) {  // fork失败:关闭客户端FD,退出子进程
                perror("fork 孙子进程失败");
                close(client_fd);
                exit(EXIT_FAILURE);
            } else if (pid2 == 0) {  // 孙子进程逻辑(任务层)
                // 孙子进程:处理客户端通信(核心任务)
                HandleClient(client_fd, client_ip, client_port);
                exit(EXIT_SUCCESS);  // 任务完成后退出
            } else {  // 子进程逻辑:创建孙子后立刻退出(关键!)
                close(client_fd);  // 子进程不处理通信,关闭客户端FD
                exit(EXIT_SUCCESS);  // 子进程退出,孙子成为孤儿进程(被init接管)
            }
        } else {  // 父进程逻辑:继续监听新连接
            close(client_fd);  // 父进程不处理通信,关闭客户端FD(避免FD泄漏)
        }
    }
}

关键细节

  • 每个进程(父、子、孙子)需关闭不需要的文件描述符(如子进程关闭 _listen_fd,父进程关闭 client_fd),否则会导致 FD 泄漏;
  • 子进程创建孙子后立刻退出,确保孙子进程被 init 接管,后续无需父进程回收。

2.3 孙子进程的任务处理逻辑:HandleClient 函数

新增 HandleClient 成员函数,封装原有的 "收发数据" 逻辑 ------ 这部分与上一篇的单客户端处理逻辑基本一致,仅改为独立函数供孙子进程调用:

cpp 复制代码
// 私有成员函数:孙子进程调用,处理客户端通信
void TcpServer::HandleClient(int client_fd, const std::string& client_ip, uint16_t client_port) {
    char recv_buf[1024] = {0};
    std::cout << "孙子进程[" << getpid() << "]开始处理客户端[" << client_ip << ":" << client_port << "]" << std::endl;

    // 循环收发数据(与上一篇的通信逻辑一致)
    while (true) {
        // 1. 接收客户端数据
        ssize_t recv_len = recv(client_fd, recv_buf, sizeof(recv_buf)-1, 0);
        if (recv_len == -1) {
            perror("recv 失败");
            break;
        } else if (recv_len == 0) {
            std::cout << "客户端[" << client_ip << ":" << client_port << "]主动断开连接" << std::endl;
            break;
        }

        // 2. 处理数据(调用自定义回调函数)
        recv_buf[recv_len] = '\0';
        std::cout << "孙子进程[" << getpid() << "]收到[" << client_ip << ":" << client_port << "]数据:" << recv_buf << std::endl;
        std::string response = _data_handler(recv_buf);  // 复用原有回调

        // 3. 发送响应
        ssize_t send_len = send(client_fd, response.c_str(), response.size(), 0);
        if (send_len == -1) {
            perror("send 失败");
            break;
        }
        std::cout << "孙子进程[" << getpid() << "]向[" << client_ip << ":" << client_port << "]发送响应:" << response << std::endl;

        memset(recv_buf, 0, sizeof(recv_buf));  // 清空缓冲区
    }

    // 4. 关闭客户端FD,任务结束
    close(client_fd);
    std::cout << "孙子进程[" << getpid() << "]处理完客户端[" << client_ip << ":" << client_port << "],已退出" << std::endl;
}

注意 :函数参数需传入 client_ipclient_port,因为孙子进程无法访问父进程的 client_addr 结构(进程地址空间独立)。

2.4 多进程版本 TcpServer.cc 入口

入口文件与上一篇完全兼容,无需修改 ------ 因为 TcpServer 类的对外接口(InitStart)未变,仅内部实现调整:

cpp 复制代码
#include <memory>
#include "TcpServer.hpp"

void Usage(std::string proc) {
    std::cerr << "Usage: " << proc << " port" << std::endl;
}

// 自定义数据处理回调(与上一篇一致)
std::string DefaultDataHandler(const std::string& client_data) {
    return "TCP Server (Multi-Process) Response: " + client_data;
}

int main(int argc, char* argv[]) {
    if (argc != 2) {
        Usage(argv[0]);
        return 1;
    }

    // 解析端口(与上一篇一致)
    uint16_t listen_port = std::stoi(argv[1]);
    if (listen_port < 1024 || listen_port > 65535) {
        std::cerr << "端口号无效(需在1024~65535之间)" << std::endl;
        return 2;
    }

    // 智能指针创建服务器(与上一篇一致)
    std::unique_ptr<TcpServer> tcp_server = 
        std::make_unique<TcpServer>(listen_port, DefaultDataHandler);

    // 初始化并启动(与上一篇一致)
    if (!tcp_server->Init()) {
        std::cerr << "服务器初始化失败" << std::endl;
        return 3;
    }
    tcp_server->Start();

    return 0;
}

3. 客户端兼容性:无需修改的 Client 端

多进程改造仅涉及服务器端,客户端的通信逻辑完全不受影响 ------ 因为 TCP 是面向连接的协议,客户端只需与服务器建立连接后收发数据,无需关心服务器是单进程还是多进程。

直接复用上一篇的 TcpClient.cc 代码即可,无需任何修改。

4. 编译与多客户端测试

4.1 编译脚本(复用 Makefile)

由于代码仅在原有基础上扩展,编译选项与上一篇完全一致,直接复用 Makefile

bash 复制代码
.PHONY:all
all:tcpserver tcpclient

tcpserver:TcpServer.cc
	g++ -o $@ $^ -std=c++14  # 保持C++14标准,兼容智能指针
tcpclient:TcpClient.cc
	g++ -o $@ $^ -std=c++14

.PHONY:clean
clean:
	rm -f tcpserver tcpclient

4.2 多客户端并发测试步骤

步骤 1:启动多进程服务器

bash 复制代码
./tcpserver 8080

服务器输出(初始化成功):

bash 复制代码
套接字创建成功,listen_fd: 3
绑定成功,成功监听端口:8080
监听中,等待客户端连接...

步骤 2:启动多个客户端(模拟并发)

打开 3 个终端,分别启动客户端连接服务器:

bash 复制代码
# 终端2:客户端1
./tcpclient 127.0.0.1 8080

# 终端3:客户端2
./tcpclient 127.0.0.1 8080

# 终端4:客户端3
./tcpclient 127.0.0.1 8080

每个客户端启动后,输出如下(连接成功):

bash 复制代码
客户端创建套接字成功,client_fd: 3
已成功连接到服务器[127.0.0.1:8080]

请输入发送给服务器的数据(输入"exit"退出)

步骤 3:客户端发送数据,验证并发

  • 客户端 1 输入:Hello Multi-Process 1,按回车;
  • 客户端 2 输入:Hello Multi-Process 2,按回车;
  • 客户端 3 输入:Hello Multi-Process 3,按回车。

步骤 4:查看服务器日志(关键验证)

服务器终端会输出 3 个孙子进程的处理日志,证明并发处理

bash 复制代码
客户端连接成功:[127.0.0.1:54321],client_fd: 4
孙子进程[1234]开始处理客户端[127.0.0.1:54321]
孙子进程[1234]收到[127.0.0.1:54321]数据:Hello Multi-Process 1
孙子进程[1234]向[127.0.0.1:54321]发送响应:TCP Server (Multi-Process) Response: Hello Multi-Process 1

客户端连接成功:[127.0.0.1:54322],client_fd: 5
孙子进程[1235]开始处理客户端[127.0.0.1:54322]
孙子进程[1235]收到[127.0.0.1:54322]数据:Hello Multi-Process 2
孙子进程[1235]向[127.0.0.1:54322]发送响应:TCP Server (Multi-Process) Response: Hello Multi-Process 2

客户端连接成功:[127.0.0.1:54323],client_fd: 6
孙子进程[1236]开始处理客户端[127.0.0.1:54323]
孙子进程[1236]收到[127.0.0.1:54323]数据:Hello Multi-Process 3
孙子进程[1236]向[127.0.0.1:54323]发送响应:TCP Server (Multi-Process) Response: Hello Multi-Process 3

日志显示:3 个客户端的请求被不同 PID 的孙子进程处理,且无阻塞。

4.3 僵尸进程验证

打开新终端,执行命令查看是否有僵尸进程:

bash 复制代码
ps aux | grep tcpserver | grep defunct

若输出为空,证明无僵尸进程残留 ------ 孙子进程退出后被 init 自动回收。

5. 多进程方案的优缺点

5.1 优势:隔离性与稳定性

  • 进程隔离:每个客户端由独立进程处理,某一进程崩溃(如内存访问错误)不会影响其他进程和主进程,稳定性高;
  • 无僵尸进程:"孙子进程" 方案彻底避免僵尸进程,无需父进程阻塞回收;
  • 实现简单 :基于 fork 机制,逻辑直观,无需复杂的 IO 多路复用(如 epoll)知识。

5.2 局限:资源开销与扩展性

  • 进程创建开销大 :每次新连接都需 fork 两次(子进程 + 孙子进程),频繁创建销毁进程会消耗 CPU 和内存,适合客户端数量较少(如几十到几百)的场景;
  • 进程间通信复杂:多进程地址空间独立,若需共享配置(如服务器参数)或状态(如在线用户数),需额外实现 IPC 机制(如管道、共享内存);
  • 文件描述符限制:每个进程会占用独立的文件描述符,系统对进程数和 FD 数有上限,无法支持高并发(如上万客户端)。

6. 后续扩展方向

6.1 进程池优化:减少进程创建开销

针对 "进程创建开销大" 的问题,可预先创建一批进程(进程池):

  • 主进程启动时,创建固定数量的子进程(如 10 个);
  • 子进程阻塞等待 "任务队列" 中的客户端 FD;
  • 主进程 accept 成功后,将客户端 FD 放入任务队列,唤醒子进程处理;
  • 子进程处理完后,回到阻塞状态,无需频繁创建销毁。

6.2 信号精细化处理:应对异常退出

当前仅处理 SIGCHLD 信号,可扩展处理其他信号,提升服务器稳定性:

  • SIGINT(Ctrl+C):触发服务器优雅退出(关闭监听 FD、回收所有子进程);
  • SIGPIPE:客户端断开后,服务器继续 send 会触发此信号,需忽略该信号避免崩溃。

6.3 多进程间通信:共享配置与状态

若需多进程共享数据(如全局配置、在线用户数),可采用:

  • 共享内存:高效共享数据(如服务器最大连接数、日志级别);
  • 消息队列:实现进程间任务分发(如进程池的任务队列);
  • 文件锁:避免多进程同时修改共享文件(如日志文件)。

总结

本篇基于上一篇的单客户端 TCP 服务器,通过 "孙子进程执行任务、子进程主动退出" 的设计,实现了多进程并发处理,核心收获如下:

  1. 理解了僵尸进程的根源与 "孙子进程" 方案的解决逻辑;
  2. 掌握了多进程服务器的代码改造要点(信号处理、FD 管理、进程创建流程);
  3. 验证了多客户端并发处理与无僵尸进程残留的效果。

多进程方案是 TCP 服务器并发的基础方案,适合中小规模场景;若需支持高并发(如上万客户端),则需后续学习 IO 多路复用(epoll)或异步 IO 方案。

相关推荐
命运之光3 小时前
【快速解决】Linux服务器安装Java17运行环境
linux·运维·服务器
你喜欢喝可乐吗?3 小时前
Ubuntu服务器无法显示命令行登录提示
linux·运维·服务器·ubuntu
ceclar1233 小时前
C++容器queue
开发语言·c++
国科安芯3 小时前
AS32S601ZIT2抗辐照MCU在商业卫星飞轮系统中的可靠性分析
服务器·网络·人工智能·单片机·嵌入式硬件·fpga开发·1024程序员节
FJW0208143 小时前
【Linux】用户管理及优化
linux·运维·服务器
启诚科技3 小时前
树上二分(树的重心)
c++·算法·二分·树的重心
咖啡教室4 小时前
每日一个计算机小知识:TCP
后端·tcp/ip
autism_cx4 小时前
TCP/IP协议栈
服务器·网络·笔记·网络协议·tcp/ip·ios·osi
读书读傻了哟4 小时前
Windows 10 下 VS Code 配置 C++ 开发环境(MinGW)
c++·windows·mingw