从零开始: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 回收,流程如下:
- 父进程(主进程):仅负责监听端口(
listen)和接收连接(accept),不处理任何客户端通信; - 子进程(中间层):父进程
accept成功后,创建子进程;子进程不做任何任务,仅创建 “孙子进程” 后立刻退出; - 孙子进程(任务层):子进程退出后,孙子进程变成 “孤儿进程”,被
init进程接管;孙子进程负责与客户端收发数据,任务完成后退出,init会自动回收它,无僵尸进程残留。
整个流程的关键:子进程 “短暂存活”,仅用于创建孙子进程,退出后让孙子进程被 init 接管,省去父进程回收的麻烦。
2. Server 端改造:多进程版本实现
改造基于上一篇的 TcpServer 类,仅需新增信号处理逻辑、修改 Start 函数的连接处理流程,核心代码兼容原有的回调函数(func_t)和资源管理(智能指针)。
2.1 TcpServer 类的扩展(信号处理与进程控制)
首先在 TcpServer 类中新增信号处理函数(忽略 SIGCHLD 信号,双重保障避免僵尸进程),并补充进程相关的头文件(sys/wait.h、signal.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_ip 和 client_port,因为孙子进程无法访问父进程的 client_addr 结构(进程地址空间独立)。
2.4 多进程版本 TcpServer.cc 入口
入口文件与上一篇完全兼容,无需修改 —— 因为 TcpServer 类的对外接口(Init、Start)未变,仅内部实现调整:
#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 服务器,通过 “孙子进程执行任务、子进程主动退出” 的设计,实现了多进程并发处理,核心收获如下:
- 理解了僵尸进程的根源与 “孙子进程” 方案的解决逻辑;
- 掌握了多进程服务器的代码改造要点(信号处理、FD 管理、进程创建流程);
- 验证了多客户端并发处理与无僵尸进程残留的效果。
多进程方案是 TCP 服务器并发的基础方案,适合中小规模场景;若需支持高并发(如上万客户端),则需后续学习 IO 多路复用(epoll)或异步 IO 方案。
