文章目录
- 引言
- [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 回收,流程如下:
- 父进程(主进程) :仅负责监听端口(
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 函数中初始化信号:
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_ip 和 client_port,因为孙子进程无法访问父进程的 client_addr 结构(进程地址空间独立)。
2.4 多进程版本 TcpServer.cc 入口
入口文件与上一篇完全兼容,无需修改 ------ 因为 TcpServer 类的对外接口(Init、Start)未变,仅内部实现调整:
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 服务器,通过 "孙子进程执行任务、子进程主动退出" 的设计,实现了多进程并发处理,核心收获如下:
- 理解了僵尸进程的根源与 "孙子进程" 方案的解决逻辑;
- 掌握了多进程服务器的代码改造要点(信号处理、FD 管理、进程创建流程);
- 验证了多客户端并发处理与无僵尸进程残留的效果。
多进程方案是 TCP 服务器并发的基础方案,适合中小规模场景;若需支持高并发(如上万客户端),则需后续学习 IO 多路复用(epoll)或异步 IO 方案。