当前位置: 首页 > news >正文

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

文章目录

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

// 补充进程与信号相关头文件
#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 信号被正确处理):

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 函数完整代码

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);  // 子进程不处理通信,关闭客户端FDexit(EXIT_SUCCESS);  // 子进程退出,孙子成为孤儿进程(被init接管)}} else {  // 父进程逻辑:继续监听新连接close(client_fd);  // 父进程不处理通信,关闭客户端FD(避免FD泄漏)}}
}

关键细节

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

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

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

// 私有成员函数:孙子进程调用,处理客户端通信
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)未变,仅内部实现调整:

#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

.PHONY:all
all:tcpserver tcpclienttcpserver:TcpServer.ccg++ -o $@ $^ -std=c++14  # 保持C++14标准,兼容智能指针
tcpclient:TcpClient.ccg++ -o $@ $^ -std=c++14.PHONY:clean
clean:rm -f tcpserver tcpclient

4.2 多客户端并发测试步骤

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

./tcpserver 8080

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

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

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

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

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

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

客户端创建套接字成功,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 个孙子进程的处理日志,证明并发处理

客户端连接成功:[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 僵尸进程验证

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

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 方案。

http://www.dtcms.com/a/528529.html

相关文章:

  • 阮一峰《TypeScript 教程》学习笔记——装饰器
  • 一、基础预训练模型与能力
  • 上海网站建设选缘魁-企查公司简介模板文案
  • 重磅新书 | 《链改2.0:从数字资产到RWA》
  • 【IOS开发】SwiftUI + OpenCV实现图片的简单处理(一)
  • 【Docker】docker run
  • 成都网站建设 Vr便民网
  • LLama3架构原理浅浅学学
  • docker存储管理
  • Transformer架构发展历史
  • 【AI原生架构:数据架构】9、从打破数据孤岛到价值升维,企业数据资产变现全流程
  • Kubernetes 上的 GitLab + ArgoCD 实践(二):使用自建 GitLab Runner 完善 CI 流程
  • 网站如何查看浏览量2008建设网站
  • 开学季技术指南:高效知识梳理与实战经验分享
  • 网站推广计划渠道国外做美食视频网站有哪些
  • 金蝶K3老单 工艺路线维护特殊字符(使用模块返回值的方法)
  • 信贷控制范围
  • 乐陵网站优化最简单的网站设计
  • 项目信息和生产安全管理指南(试行)
  • 【Tesla】ICCV 2025技术分享
  • 企业做网站营销企业网站 响应式
  • 深度学习C++中的数据结构:栈和队列
  • 2025-tomcat web实践
  • 免费建立微信网站如何设计的英文网站
  • liferay 做网站哪里有网站开发公司
  • Leetcode 38
  • Django 学习路线图
  • 把网站放到服务器公司做网站需要准备什么资料
  • 如何批量获取蛋白质序列的所有结构域(domain)数据-2
  • MySQL基础知识大全