文章目录
- 引言
- [1. 核心设计:多线程模型的原理与优势](#1. 核心设计:多线程模型的原理与优势)
-
- [1.1 多线程模型设计](#1.1 多线程模型设计)
- [1.2 关键问题与解决](#1.2 关键问题与解决)
- [2. Server 端改造:多线程版本实现](#2. Server 端改造:多线程版本实现)
-
- [2.1 头文件与类结构修改(TcpServer.hpp)](#2.1 头文件与类结构修改(TcpServer.hpp))
- [2.2 Init 函数扩展:信号忽略(避免进程终止)](#2.2 Init 函数扩展:信号忽略(避免进程终止))
- [2.3 Start 函数改造:多线程核心逻辑](#2.3 Start 函数改造:多线程核心逻辑)
- [2.4 线程入口与客户端通信实现](#2.4 线程入口与客户端通信实现)
-
- [2.4.1 静态线程入口函数(ThreadRoutine)](#2.4.1 静态线程入口函数(ThreadRoutine))
- [2.4.2 客户端通信处理(HandleClient)](#2.4.2 客户端通信处理(HandleClient))
- [2.5 服务器入口(TcpServer.cc)](#2.5 服务器入口(TcpServer.cc))
- [3. 客户端兼容性:无需修改](#3. 客户端兼容性:无需修改)
- [4. 编译与多客户端测试](#4. 编译与多客户端测试)
-
- [4.1 Makefile 改造(链接线程库)](#4.1 Makefile 改造(链接线程库))
- [4.2 多客户端并发测试步骤](#4.2 多客户端并发测试步骤)
-
- [步骤 1:启动多线程服务器](#步骤 1:启动多线程服务器)
- [步骤 2:启动多个客户端(模拟并发)](#步骤 2:启动多个客户端(模拟并发))
- [步骤 3:客户端发送数据,验证并发](#步骤 3:客户端发送数据,验证并发)
- [步骤 4:查看服务器日志](#步骤 4:查看服务器日志)
- [5. 多线程方案的优缺点](#5. 多线程方案的优缺点)
-
- [5.1 优势(对比多进程)](#5.1 优势(对比多进程))
- [5.2 局限(需注意的问题)](#5.2 局限(需注意的问题))
- [6. 后续扩展方向](#6. 后续扩展方向)
-
- [6.1 线程池优化(解决线程数量上限问题)](#6.1 线程池优化(解决线程数量上限问题))
- [6.2 线程安全增强(共享资源保护)](#6.2 线程安全增强(共享资源保护))
- [6.3 连接超时管理(避免死连接)](#6.3 连接超时管理(避免死连接))
- [6.4 信号精细化处理(保障主线程稳定)](#6.4 信号精细化处理(保障主线程稳定))
- [7. 总结](#7. 总结)
引言
上一篇教程中,我们通过多进程模型解决了单客户端 TCP 服务器的阻塞问题 ------ 主进程负责接收连接,子进程(孙子进程)处理客户端通信,利用 init 进程自动回收孤儿进程避免僵尸问题。但多进程方案存在天然局限:进程间地址空间独立,内存拷贝开销大,进程间通信(IPC)需依赖管道、共享内存等复杂机制,且创建 / 销毁进程的系统开销远高于线程。
为进一步优化资源占用与通信效率,本篇将基于原有 TcpServer 类,改造为多线程 TCP 服务器 :采用「主线程监听连接 + 子线程处理客户端」的模型,利用线程共享进程内存的特性降低开销,同时通过线程分离(pthread_detach)避免僵尸线程。教程将延续模块化设计思想,保持与前两篇一致的码风,完整覆盖多线程改造的核心细节与线程安全基础。
1. 核心设计:多线程模型的原理与优势
多线程方案的核心是线程级并发------ 所有线程共享同一进程的地址空间(如代码段、数据段、文件描述符表),仅拥有独立的栈空间和线程上下文,因此创建 / 销毁开销仅为多进程的 1/10~1/100,且线程间通信无需额外 IPC 机制。
1.1 多线程模型设计
基础版多线程服务器采用 "一对一连接" 模型,流程如下:
- 主线程 :仅负责核心流程 ------ 创建监听套接字、绑定端口、监听连接,通过
accept阻塞等待客户端连接; - 子线程 :主线程每接收一个客户端连接(获得
client_fd),立即创建一个子线程,将client_fd与客户端信息(IP / 端口)传递给子线程; - 任务处理 :子线程独立处理与客户端的收发数据(
recv/send),通信结束后关闭client_fd并自动释放线程资源; - 资源回收 :通过
pthread_detach将子线程设为 "分离态",线程退出后系统自动回收其栈空间与上下文,避免 "僵尸线程"(类似多进程的init回收机制)。
1.2 关键问题与解决
- 线程安全 :基础版中,每个子线程仅操作独立的
client_fd,无共享资源(如全局变量、类成员变量),因此无需互斥锁;若后续扩展连接计数、共享配置等,需通过pthread_mutex_t实现同步。 - 僵尸线程 :若子线程未分离且主线程未调用
pthread_join,线程退出后资源无法回收,形成 "僵尸线程";通过pthread_detach或创建线程时设置PTHREAD_CREATE_DETACHED属性可解决。 - 线程函数兼容性 :
pthread_create要求线程入口函数为void* (*)(void*)类型,需通过静态函数封装类的成员方法(非静态成员函数依赖this指针,无法直接作为线程入口)。
2. Server 端改造:多线程版本实现
改造基于前两篇的 TcpServer 类,核心新增线程入口函数、客户端通信封装函数,并修改 Start 函数的连接处理逻辑,线程相关操作依赖 POSIX 线程库(pthread)。
2.1 头文件与类结构修改(TcpServer.hpp)
首先补充线程相关头文件,新增线程入口函数(静态)、客户端处理函数,并定义线程参数结构体(传递 this 指针与客户端信息):
cpp
// 补充线程相关头文件(POSIX 线程库)
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <csignal>
#include <unistd.h>
#include <functional>
#include <iostream>
#include <string>
#include <cstring>
// 线程参数结构体:传递给线程入口函数的参数(包含this指针与客户端信息)
typedef struct {
class TcpServer* server; // 指向TcpServer实例的指针
int client_fd; // 客户端通信套接字
std::string client_ip; // 客户端IP(点分十进制)
uint16_t client_port; // 客户端端口
} ThreadArg;
class TcpServer {
public:
TcpServer(uint16_t port, func_t handler);
~TcpServer();
bool Init(); // 初始化:创建套接字、绑定、监听(新增信号忽略)
void Start(); // 启动:主线程accept,子线程处理客户端
void Stop(); // 停止服务器(与前两篇一致)
private:
// 新增1:静态线程入口函数(适配pthread_create的函数签名)
static void* ThreadRoutine(void* arg);
// 新增2:客户端通信处理(子线程实际执行的逻辑)
void HandleClient(int client_fd, const std::string& client_ip, uint16_t client_port);
int _listen_fd; // 监听套接字(与前两篇一致)
uint16_t _listen_port; // 服务器监听端口(与前两篇一致)
bool _is_running; // 服务器运行状态(与前两篇一致)
func_t _data_handler; // 数据处理回调函数(与前两篇一致)
};
关键说明:
ThreadArg结构体:解决线程入口函数无法直接访问类成员的问题,通过server指针间接调用HandleClient;- 静态线程入口
ThreadRoutine:必须为静态成员函数(无this指针),否则无法匹配pthread_create要求的函数签名。
2.2 Init 函数扩展:信号忽略(避免进程终止)
TCP 通信中,若客户端意外断开连接(如断网),服务器继续调用 send 会触发 SIGPIPE 信号,默认行为是终止整个进程。因此在 Init 函数末尾新增信号忽略逻辑,保障服务器稳定性:
cpp
bool TcpServer::Init() {
// 1. 创建 TCP 套接字(与前两篇一致)
_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; // IPv4 协议
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); // backlog=5(未完成连接队列长度)
if (listen_ret == -1) {
perror("listen 失败");
close(_listen_fd);
_listen_fd = -1;
return false;
}
std::cout << "监听中,等待客户端连接..." << std::endl;
// 新增:5. 忽略 SIGPIPE 信号(避免客户端断开导致服务器崩溃)
signal(SIGPIPE, SIG_IGN);
_is_running = true;
return true;
}
2.3 Start 函数改造:多线程核心逻辑
Start 函数是多线程改造的核心:主线程循环调用 accept 接收连接,每获得一个客户端连接,创建子线程并传递 ThreadArg 参数,子线程负责后续通信,主线程立即返回继续监听新连接:
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. 准备线程参数(动态分配,避免栈内存释放问题)
ThreadArg* thread_arg = new ThreadArg();
thread_arg->server = this; // 传递当前TcpServer实例
thread_arg->client_fd = client_fd; // 客户端通信套接字
thread_arg->client_ip = client_ip; // 客户端IP
thread_arg->client_port = client_port; // 客户端端口
// 3. 创建子线程:处理该客户端的通信
pthread_t tid;
int pthread_ret = pthread_create(&tid, nullptr, ThreadRoutine, (void*)thread_arg);
if (pthread_ret != 0) {
perror("pthread_create 失败!");
// 创建线程失败:释放参数内存,关闭客户端套接字
delete thread_arg;
close(client_fd);
continue;
}
// 4. 分离线程:子线程结束后自动释放资源(无需主线程pthread_join)
pthread_detach(tid);
std::cout << "子线程创建成功(tid: " << tid << "),负责处理客户端[" << client_ip << ":" << client_port << "]" << std::endl;
}
}
关键细节:
- 线程参数动态分配:
thread_arg用new分配在堆上,避免主线程栈内存被释放后,子线程访问非法内存; pthread_detach:将子线程设为分离态,线程退出时系统自动回收其资源(栈、寄存器等),无需主线程调用pthread_join阻塞等待;- 错误回滚:若线程创建失败,需手动释放
thread_arg并关闭client_fd,避免内存泄漏与文件描述符泄漏。
2.4 线程入口与客户端通信实现
2.4.1 静态线程入口函数(ThreadRoutine)
线程启动后,首先进入静态入口函数,通过 ThreadArg 中的 server 指针调用非静态的 HandleClient 函数,完成业务逻辑转发:
cpp
void* TcpServer::ThreadRoutine(void* arg) {
// 1. 解析线程参数
ThreadArg* thread_arg = static_cast<ThreadArg*>(arg);
if (thread_arg == nullptr) {
std::cerr << "线程参数为空,退出线程" << std::endl;
return nullptr;
}
// 2. 调用客户端处理函数(通过server指针访问非静态成员)
thread_arg->server->HandleClient(
thread_arg->client_fd,
thread_arg->client_ip,
thread_arg->client_port
);
// 3. 释放线程参数内存(避免堆内存泄漏)
delete thread_arg;
return nullptr;
}
2.4.2 客户端通信处理(HandleClient)
将原单客户端版本的 "收发数据" 逻辑抽离为 HandleClient 函数,由子线程执行,逻辑与前两篇一致,仅需确保通信结束后关闭 client_fd:
cpp
void TcpServer::HandleClient(int client_fd, const std::string& client_ip, uint16_t client_port) {
char recv_buf[1024] = {0}; // 接收缓冲区
std::cout << "子线程(tid: " << pthread_self() << ")开始处理客户端[" << 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 << "子线程(tid: " << pthread_self() << ")收到[" << 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 << "子线程(tid: " << pthread_self() << ")向[" << client_ip << ":" << client_port << "]发送响应:" << response << std::endl;
// 4. 清空接收缓冲区
memset(recv_buf, 0, sizeof(recv_buf));
}
// 5. 通信结束:关闭客户端套接字
close(client_fd);
std::cout << "子线程(tid: " << pthread_self() << ")处理完毕,关闭客户端[" << client_ip << ":" << client_port << "]连接" << std::endl;
}
2.5 服务器入口(TcpServer.cc)
与前两篇一致,复用 DefaultDataHandler 回调函数,通过命令行参数指定监听端口,核心逻辑无修改:
cpp
#include <memory>
#include <iostream>
#include <string>
#include "TcpServer.hpp"
// 打印用法提示
void Usage(std::string proc) {
std::cerr << "Usage: " << proc << " <listen_port>" << std::endl;
std::cerr << "示例:" << proc << " 8080" << std::endl;
}
// 自定义数据处理回调:给客户端消息添加前缀
std::string DefaultDataHandler(const std::string& client_data) {
return "TCP Thread Server Response: " + client_data;
}
int main(int argc, char* argv[]) {
// 检查命令行参数数量
if (argc != 2) {
Usage(argv[0]);
return 1;
}
// 解析监听端口(1024~65535 为有效端口)
uint16_t listen_port = std::stoi(argv[1]);
if (listen_port < 1024 || listen_port > 65535) {
std::cerr << "端口号无效!需在 1024~65535 之间" << std::endl;
return 2;
}
// 创建 TCP 服务器实例(智能指针自动释放资源)
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. 客户端兼容性:无需修改
多线程服务器仅改变 "服务器端并发模型",客户端与服务器的通信协议(TCP 三次握手、收发数据格式)完全不变。因此前两篇实现的 TcpClient.cc 可直接复用,无需任何修改。
4. 编译与多客户端测试
4.1 Makefile 改造(链接线程库)
POSIX 线程库(pthread)并非 C++ 标准库,编译时需通过 -lpthread 显式链接线程库。
bash
.PHONY: all clean
# 编译目标:多线程服务器 + 客户端
all: tcpserver tcpclient
# 编译多线程服务器:需链接pthread库
tcpserver: TcpServer.cc
g++ -o $@ $^ -std=c++14 -lpthread # -lpthread:链接线程库
# 编译客户端:与前两篇一致
tcpclient: TcpClient.cc
g++ -o $@ $^ -std=c++14
# 清理编译产物
clean:
rm -f tcpserver tcpclient
4.2 多客户端并发测试步骤
步骤 1:启动多线程服务器
bash
./tcpserver 8888
服务器输出(初始化成功):
bash
套接字创建成功,listen_fd: 3
绑定成功,成功监听端口:8888
监听中,等待客户端连接...
步骤 2:启动多个客户端(模拟并发)
打开 3 个终端,分别启动客户端并连接服务器(127.0.0.1 为本地回环地址):
bash
# 终端1:客户端1
./tcpclient 127.0.0.1 8888
# 终端2:客户端2
./tcpclient 127.0.0.1 8888
# 终端3:客户端3
./tcpclient 127.0.0.1 8888
客户端输出(连接成功):
bash
客户端创建套接字成功,client_fd: 3
已成功连接到服务器[127.0.0.1:8888]
请输入发送给服务器的数据(输入"exit"退出):
步骤 3:客户端发送数据,验证并发
-
客户端 1 输入:
Hello Thread Server! This is Zkp 1
客户端 1 输出:bash已发送数据Hello Thread Server! This is Zkp 1(字节数:34) 收到服务器响应:TCP Server Response: Hello Thread Server! This is Zkp 1 请输入发送给服务器的数据(输入"exit"退出) -
客户端 2 输入:
Hello Thread Server! This is Zkp 2
客户端 2 输出:bash已发送数据Hello Thread Server! This is Zkp 2(字节数:34) 收到服务器响应:TCP Server Response: Hello Thread Server! This is Zkp 2 请输入发送给服务器的数据(输入"exit"退出) -
客户端 3 输入:
Hello Thread Server! This is Zkp 3
客户端 3 输出:bash已发送数据Hello Thread Server! This is Zkp 3(字节数:34) 收到服务器响应:TCP Server Response: Hello Thread Server! This is Zkp 3 请输入发送给服务器的数据(输入"exit"退出)
步骤 4:查看服务器日志
服务器输出(多线程并发处理):
bash
客户端连接成功:[127.0.0.1:47118],client_fd: 4
子线程创建成功(tid:139722238830144),负责处理客户端[127.0.0.1:47118]
子线程(tid: 139722238830144)开始处理客户端[127.0.0.1:47118]
客户端连接成功:[127.0.0.1:56970],client_fd: 5
子线程创建成功(tid:139722230437440),负责处理客户端[127.0.0.1:56970]
子线程(tid: 139722230437440)开始处理客户端[127.0.0.1:56970]
客户端连接成功:[127.0.0.1:51488],client_fd: 6
子线程创建成功(tid:139722222044736),负责处理客户端[127.0.0.1:51488]
子线程(tid: 139722222044736)开始处理客户端[127.0.0.1:51488]
子线程(tid: 139722238830144)收到[127.0.0.1:47118]的数据:Hello Thread Server! This is Zkp 1
子线程(tid: 139722238830144)向[127.0.0.1:47118]发送响应:TCP Server Response: Hello Thread Server! This is Zkp 1
子线程(tid: 139722230437440)收到[127.0.0.1:56970]的数据:Hello Thread Server! This is Zkp 2
子线程(tid: 139722230437440)向[127.0.0.1:56970]发送响应:TCP Server Response: Hello Thread Server! This is Zkp 2
子线程(tid: 139722222044736)收到[127.0.0.1:51488]的数据:Hello Thread Server! This is Zkp 3
子线程(tid: 139722222044736)向[127.0.0.1:51488]发送响应:TCP Server Response: Hello Thread Server! This is Zkp 3
5. 多线程方案的优缺点
5.1 优势(对比多进程)
- 资源开销低:线程共享进程地址空间,无需拷贝代码段、数据段,创建 / 销毁开销仅为多进程的 1/10~1/100;
- 线程间通信便捷:可直接访问进程内的共享变量(如全局配置、连接计数器),无需依赖管道、共享内存等复杂 IPC 机制;
- 上下文切换快:线程仅需切换栈和寄存器状态,切换开销远低于进程(进程需切换页表、地址空间)。
5.2 局限(需注意的问题)
- 线程安全风险:若多个线程修改同一共享资源(如连接数计数器),未加互斥锁会导致 "竞态条件"(数据不一致);
- 线程数量上限 :每个线程默认占用 8MB 栈空间(Linux),若客户端数量过多(如上万),会耗尽进程内存,导致
pthread_create失败; - 进程级崩溃风险:线程共享进程资源,一个线程触发致命错误(如访问空指针)会导致整个进程崩溃,隔离性弱于多进程。
6. 后续扩展方向
基础版多线程服务器可针对 "高并发" 与 "稳定性" 进一步优化,核心扩展方向如下:
6.1 线程池优化(解决线程数量上限问题)
- 问题:基础版 "一个连接一个线程" 的模型,在客户端数量激增时会频繁创建 / 销毁线程,且线程过多导致内存耗尽;
- 方案:预先创建固定数量的线程(如 10 个),主线程 accept 后将 client_fd 加入任务队列,线程池中的线程从队列中取任务处理;
关键技术:任务队列需通过 "互斥锁(pthread_mutex_t)+ 条件变量(pthread_cond_t)" 实现线程安全,避免多个线程同时操作队列。
6.2 线程安全增强(共享资源保护)
场景:若需统计当前在线客户端数量,多个线程会同时修改 online_count 变量;
实现:用互斥锁包裹共享资源的读写操作,确保同一时间仅一个线程访问:
cpp
运行
// 类中新增互斥锁成员
pthread_mutex_t _mutex;
// 初始化互斥锁(在Init函数中)
pthread_mutex_init(&_mutex, nullptr);
// 访问共享资源时加锁/解锁
pthread_mutex_lock(&_mutex);
_online_count++; // 共享资源修改
pthread_mutex_unlock(&_mutex);
6.3 连接超时管理(避免死连接)
- 问题:客户端异常断网后,服务器端连接会成为 "死连接",长期占用 client_fd 与线程资源;
- 方案:
- 给
recv设置超时(通过setsockopt函数设置SO_RCVTIMEO); - 实现心跳机制:服务器定期向客户端发送心跳包,若超时未收到响应则关闭连接。
- 给
6.4 信号精细化处理(保障主线程稳定)
- 场景:主线程
accept时若收到SIGINT(Ctrl+C),需优雅关闭服务器(关闭_listen_fd、等待子线程处理完毕); - 方案:在
Init函数中注册SIGINT信号处理函数,设置_is_running = false,并关闭_listen_fd,让主线程退出accept循环。
7. 总结
本篇教程基于前两篇的 TCP 服务器基础,实现了多线程版本,核心知识点回顾:
- 多线程模型:主线程
accept+ 子线程处理客户端,通过pthread_detach避免僵尸线程; - 关键技术:静态线程入口函数、线程参数传递、
SIGPIPE信号忽略、线程库链接(-lpthread); - 对比优势:多线程比多进程开销小、通信便捷,适合中高并发场景;
- 扩展核心:线程池解决线程数量上限,互斥锁保障线程安全。
通过基础版与扩展方向的结合,可逐步构建出工业级的高并发 TCP 服务器(如简易聊天系统、文件传输服务)。建议先掌握基础版代码的调试与运行,再逐步尝试线程池、线程安全等高级特性。