【Linux网络】高性能 TCP 服务器:从多线程到线程池的架构演进与落地实践
本文深入探讨了TCP服务器从单线程到多线程再到线程池的演进过程,重点分析了线程池TCP服务器的核心设计与实现。文章首先对比了四种服务器模型的优缺点,指出线程池在高并发短连接场景下的优势;然后详细解析了自研基础组件(互斥锁、条件变量、线程封装等)的实现原理;接着通过远程命令执行服务器案例展示了多线程TCP服务器的实现;最后重点讲解了工业级线程池模板类的设计,并基于线程池实现了高并发Echo服务器。文

🔥个人主页:Cx330🌸
❄️个人专栏:《C语言》《LeetCode刷题集》《数据结构-初阶》《C++知识分享》
《优选算法指南-必刷经典100题》《Linux操作系统》:从入门到入魔
🌟心向往之行必能
🎥Cx330🌸的简介:

目录
3.2 业务层:命令执行模块深度解析(ExcuteCommand.hpp)
3.3.2 服务端完整代码解析(TcpServer.hpp)
4.2 线程池模板类深度解析(ThreadPool.hpp)
4.3.1 服务器核心实现(TcpEchoServer.hpp)
前言
在网络编程的江湖里,如何让服务器优雅、高效地处理成千上万的客户端连接,是每一位 C++ 程序员的必修课。从最初的“单兵作战”(单线程阻塞)到后来的“人海战术”(多线程机制),再到现代高性能框架中必备的“精兵强将”(线程池架构),每一步演进都伴随着对系统资源(CPU、内存、I/O)更深层次的思考。
今天,我们就来彻底打通 TCP 服务器多线程/线程池的核心知识点,深入内核,并手写一套工业级、可直接复用的现代 C++ 线程池 TCP 服务器!
一. TCP 服务器核心模型演进
为了帮助大家构建清晰的底层认知,我们将单进程、多进程、多线程、线程池四种经典模型在核心指标上进行横向对比:
|
评估指标 |
单进程(串行/迭代) |
多进程(Fork Per Conn) |
多线程(One Thread Per Conn) |
线程池(Thread Pool) |
|---|---|---|---|---|
|
并发能力 |
极差(同一时间只能处理一个) |
较好(受限于系统最大进程数) |
较好(受限于最大系统线程数) |
优秀(高效复用线程,支持海量任务排队) |
|
创建/销毁开销 |
无(无需频繁创建/销毁) |
极大(需分配独立虚拟地址空间) |
较大(虽比进程轻量,但频繁调用代价仍高) |
极小(初始化时一次性创建,运行期零开销) |
|
上下文切换开销 |
无 |
极大(涉及页表切换、TLB 刷新等) |
较大(涉及 CPU 寄存器、内核栈切换) |
极小且可控(线程数量固定,避免频繁换入换出) |
|
通信机制 (IPC/Sync) |
无需通信 |
复杂(需使用管道、共享内存、信号量等) |
简单但敏感(通过共享内存,需互斥锁/条件变量防竞争) |
适中(内置线程安全任务队列进行平滑同步) |
|
安全稳定性 |
较差(单连接崩溃导致服务瘫痪) |
极高(进程间内存隔离,单个子进程崩溃互不影响) |
较差(一个线程因段错误崩溃,整个进程会随之崩塌) |
较好(线程数固定,可通过捕获异常和限制规模来控险) |
|
实现复杂度 |
极简 |
中等(需要妥善处理僵尸进程 |
较易 |
较高(需要精细处理线程同步、锁竞争及优雅退场) |
|
典型应用场景 |
局域网简单测试、单客户端调试 |
经典的 Unix 服务(如早期 Apache)、对安全隔离要求极高的场景 |
早期中低并发服务器、简单多任务后台 |
现代高性能服务器默认核心架构(如 Nginx、Memcached) |
核心关键结论:线程池版本仅适合短服务 / 短连接场景。如果用线程池处理长连接,一个连接会长期占用一个工作线程,当线程池线程耗尽后,新的客户端将完全无法建立连接,这是新手最容易踩的坑。
二. 自研基础组件深度解析
本文所有服务器实现,均基于自研的 Linux 系统编程组件封装,这些组件不仅屏蔽了原生 C 接口的繁琐细节,更是解决了并发安全、资源泄漏等经典问题。
2.1 互斥锁与 RAII 锁守卫(Mutex.hpp)
互斥锁是并发编程的基石,用于保护临界资源的原子访问;而 RAII 风格的锁守卫,是 C++ 中避免锁泄漏、死锁的最佳实践。
#ifndef MUTEX_HPP
#define MUTEX_HPP
#include <iostream>
#include <pthread.h>
// 互斥锁封装类:提供加锁/解锁及获取原始锁的接口
class Mutex
{
public:
// 构造函数:初始化互斥锁
Mutex()
{
pthread_mutex_init(&_lock, nullptr);
}
// 析构函数:销毁互斥锁
~Mutex()
{
pthread_mutex_destroy(&_lock);
}
// 加锁操作
void Lock()
{
pthread_mutex_lock(&_lock);
}
// 解锁操作
void UnLock()
{
pthread_mutex_unlock(&_lock);
}
// 获取原始互斥锁指针,用于需要原生 pthread_mutex_t 的接口
pthread_mutex_t* Origin()
{
return &_lock;
}
private:
pthread_mutex_t _lock; // POSIX 互斥锁
};
// RAII 风格的锁守卫类:构造时加锁,析构时解锁,自动管理锁的生命周期
class LockGuard
{
public:
// 构造函数:接收一个 Mutex 指针,并立即加锁
LockGuard(Mutex* lockptr) : _lockptr(lockptr)
{
_lockptr->Lock();
}
// 析构函数:自动解锁
~LockGuard()
{
_lockptr->UnLock();
}
private:
Mutex* _lockptr; // 指向被管理的互斥锁
};
#endif
源码核心解读:
- 接口极简设计:封装了原生pthread_mutex的核心操作,同时提供Origin()接口,方便与条件变量等原生 C 接口配合使用。
- RAII 机制保障:LockGuard在对象构造时自动加锁,生命周期结束时自动解锁,无论函数正常返回、异常抛出,都能保证锁被释放,彻底避免锁泄漏。
- 使用场景:所有临界资源的访问(如线程池任务队列、日志文件写入)都通过LockGuard保护,无需手动调用unlock,代码更健壮。
2.2 条件变量封装(Cond.hpp)
条件变量用于线程间的通知机制,配合互斥锁实现生产者 - 消费者模型,是线程池的核心同步组件。
#ifndef COND_HPP
#define COND_HPP
#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"
/**
* @brief 条件变量封装类
* 核心逻辑:提供线程间的通知机制。
* 它允许线程在某些条件不满足时挂起,并在其他线程改变条件并发送信号时被唤醒。
*/
class Cond
{
public:
// 构造函数:初始化条件变量
Cond()
{
// nullptr 表示使用操作系统默认的条件变量属性
pthread_cond_init(&cond, nullptr);
}
/**
* @brief 等待条件满足
* @param mutex 必须是当前线程已经持有的互斥锁
* * 底层逻辑“三步跳”:
* 1. 自动释放传入的 mutex 锁(这样其他线程才能修改临界资源)。
* 2. 将当前线程挂起并加入到该条件变量的等待队列中。
* 3. 当被唤醒返回时,会自动尝试重新竞争并持有该 mutex 锁。
*/
void Wait(Mutex &mutex)
{
// 调用封装好的 Mutex 类的 Origin() 接口,配合底层 C 接口使用
pthread_cond_wait(&cond, mutex.Origin());
}
// 唤醒一个在此条件变量下等待的线程
void NotifyOne()
{
// 唤醒队列中的第一个线程(如果存在)
pthread_cond_signal(&cond);
}
// 唤醒所有在此条件变量下等待的线程
void NotifyAll()
{
// 广播通知,常用于多个消费者或复杂的资源变动场景
pthread_cond_broadcast(&cond);
}
// 析构函数:销毁条件变量资源
~Cond()
{
/**
* 注意事项:
* 销毁一个仍有线程在等待的条件变量是危险行为。
* 在线程池销毁前,通常需要先调用 NotifyAll 并回收所有线程。
*/
pthread_cond_destroy(&cond);
}
private:
pthread_cond_t cond; // POSIX 线程库提供的底层条件变量结构
};
#endif
源码核心解读:
- Wait 函数的底层 “三步跳”:这是条件变量最核心的考点
- 自动释放传入的互斥锁,让其他线程可以修改临界资源
- 将当前线程挂起,加入条件变量的等待队列
- 被唤醒返回时,自动重新竞争并持有互斥锁
- 唤醒机制区分:
NotifyOne用于常规任务通知,NotifyAll用于线程池优雅退出等需要唤醒所有线程的场景。 - 使用规范:条件变量的等待必须配合
while循环(而非 if),避免虚假唤醒,这一点在线程池实现中会重点体现。
2.3 线程封装(Thread.hpp)
对原生 POSIX 线程库进行 C++ 封装,解决了类 成员函数作为线程入口的参数匹配问题,同时提供了线程命名、LWP 获取、状态管理等实用能力。
#ifndef __THREAD_HPP
#define __THREAD_HPP
#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>
// 定义线程执行的任务类型,使用包装器增强灵活性
using func_t = std::function<void()>;
// 线程状态枚举:用于构建简单的状态机,确保护法操作
enum class TSTAYUS
{
THREAD_NEW, // 新建状态
THREAD_RUNNING, // 运行状态
THREAD_STOPPED, // 停止/退出状态
};
// 这个是有点bug的:全局静态变量在多线程并发创建对象时存在“竞态条件”
// 多个线程可能同时执行 gunm++,导致线程编号重复,生产环境下建议使用 std::atomic<int>
static int gunm = 1;
class Thread
{
private:
// 获取所属进程的 PID
void get_pid()
{
_pid = getpid();
}
// 获取内核级线程 ID (LWP ID),这才是 Linux 系统监控(如 top -H)看到的真正 ID
void get_lwid()
{
// 原生 pthread 库没有直接获取 LWP 的接口,必须通过系统调用
_lwid = syscall(SYS_gettid);
}
/**
* @brief 静态成员函数作为线程入口点
* 关键逻辑:pthread_create 要求回调函数必须是 void* (*)(void*)
* 类的普通成员函数隐含 this 指针,参数不匹配,故必须设为 static。
* 通过传入 args (this 指针) 重新找回对象上下文。
*/
static void* routine(void* args)
{
Thread* ts = static_cast<Thread*>(args);
ts->get_pid();
ts->get_lwid();
// 为线程设置名字,方便在调试器(如 gdb)中识别
pthread_setname_np(pthread_self(), ts->Name().c_str());
// 执行用户真正传入的任务
ts->_func();
return nullptr;
}
public:
// 构造函数:完成任务绑定与命名,此时线程尚未在内核中创建
Thread(func_t f) : _func(f), _joinable(true), _status(TSTAYUS::THREAD_NEW)
{
_name = "Worker-" + std::to_string(gunm++);
}
// 启动线程:正式调用底层接口
void start()
{
if(_status == TSTAYUS::THREAD_RUNNING)
{
std::cerr << "thread is already running" << std::endl;
return;
}
// 传入 this 作为 routine 的参数,实现 C 到 C++ 的跨越
int n = pthread_create(&_tid, nullptr, routine, this);
if(n != 0)
{
std::cerr << "pthread_create failed" << std::endl;
}
_status = TSTAYUS::THREAD_RUNNING;
}
// 停止线程:通过发送取消请求
void stop()
{
if(_status == TSTAYUS::THREAD_RUNNING)
{
// pthread_cancel 是比较暴力的退出方式,依赖线程内部是否存在取消点
int n = pthread_cancel(_tid);
if(n != 0)
{
std::cerr << "pthread_cancel failed" << std::endl;
}
_status = TSTAYUS::THREAD_STOPPED;
}
else
{
std::cerr << "thread status is : THREAD_STOPPED or THREAD_NEW" << std::endl;
return;
}
}
// 资源回收:阻塞等待线程结束
void join()
{
if(_joinable)
{
// 只有处于 joinable 状态的线程才需要被 join,否则会产生资源泄露
int n = pthread_join(_tid, nullptr);
if(n != 0)
{
std::cerr << "pthread_join failed" << std::endl;
}
printf("lwp: %d, name: %s, join success\n", _lwid, _name.c_str());
}
else {
printf("lwp: %d, name: %s, join failed, because thread is detached\n", _lwid, _name.c_str());
}
}
// 线程分离:将线程设置为由系统自动回收
void detach()
{
if(_joinable && _status == TSTAYUS::THREAD_RUNNING)
{
_joinable = false;
// 分离后,该线程退出时会自动释放所有资源,无需 join
int n = pthread_detach(_tid);
if(n != 0)
{
std::cerr << "pthread_detach failed" << std::endl;
}
}
}
// 获取线程名称接口
std::string Name()
{
return _name;
}
~Thread()
{
// 析构函数中未做强制 join,这是为了给使用者留出控制权
// 但要注意,如果对象销毁时线程还在跑且未 detach,可能会导致程序崩溃
}
private:
pthread_t _tid; // 线程库层面的 ID (用户层 ID)
pid_t _pid; // 所属进程 ID
pid_t _lwid; // 轻量级进程 ID (内核层真正的线程 ID)
std::string _name; // 线程可读性名称
func_t _func; // 线程执行的任务包装器
bool _joinable; // 是否允许被等待标记
TSTAYUS _status; // 当前线程状态机
};
#endif
源码核心解读:
- 线程入口的核心设计:原生
pthread_create要求入口函数必须是void* (*)(void*),而类的普通成员函数隐含this指针,参数不匹配。因此必须使用静态成员函数作为入口,通过传入this指针找回对象上下文。 - 线程状态机管理:通过枚举类型管理线程的新建、运行、停止状态,避免重复启动、重复停止的非法操作。
- 资源回收双方案:提供
detach(线程分离,系统自动回收)和join(阻塞等待回收)两种方式,适配不同场景。
2.4 策略模式日志系统(Logger.hpp)
基于策略模式实现的多线程 安全日志系统,支持控制台 / 文件双输出策略,通过 RAII 机制实现日志消息的自动刷新,是服务器调试与问题定位的核心工具。
#ifndef __LOGGER_HPP
#define __LOGGER_HPP
#include <iostream>
#include <cstdio>
#include <string>
#include <ctime>
#include <filesystem> // C++17
#include "Mutex.hpp"
#include <fstream>
#include <sstream>
#include <memory>
#include <unistd.h>
namespace LogModule
{
// 1.获取时间
std::string GetTimeStamp()
{
time_t timestamp = time(nullptr);
struct tm data_time;
localtime_r(×tamp, &data_time);
char data_time_str[128];
snprintf(data_time_str, sizeof(data_time_str), "%4d-%02d-%02d %02d:%02d:%02d",
data_time.tm_year + 1900, // 从1900开始记的
data_time.tm_mon + 1, // 默认月份从0开始记的
data_time.tm_mday,
data_time.tm_hour,
data_time.tm_min,
data_time.tm_sec);
return data_time_str;
}
enum LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
// 2.日志等级
std::string LogLevel2String(LogLevel level)
{
switch (level)
{
case LogLevel::DEBUG:
return "DEBUG";
case LogLevel::INFO:
return "INFO";
case LogLevel::WARNING:
return "WARNING";
case LogLevel::ERROR:
return "ERROR";
case LogLevel::FATAL:
return "FATAL";
default:
return "UNKNOWN";
}
}
// 3.日志刷新
// 基类:策略基类,设置刷新策略的
class LogStrategy
{
public:
virtual ~LogStrategy() = default;
virtual void SyncLog(const std::string &logmessage) = 0;
};
// 子类:继承纯虚接口类
// 策略1
class ConsoleLogStrategy : public LogStrategy
{
public:
ConsoleLogStrategy(){}
~ConsoleLogStrategy(){}
virtual void SyncLog(const std::string &logmessage) override
{
LockGuard lockguard(&_mutex);
std::cout<<logmessage<<std::endl;
}
private:
Mutex _mutex;
};
static const std::string glogdir = "./log/";
static const std::string glogfilename = "log.log";
// 子类:继承纯虚接口类
// 策略2
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(const std::string &dir = glogdir,const std::string &filename = glogfilename)
:_logdir(dir),_logfilename(filename)
{
// log/log.txt
LockGuard lockguard(&_mutex);
if(std::filesystem::exists(_logdir))
{
return;
}
else
{
try
{
std::filesystem::create_directories(_logdir);
}
catch (const std::filesystem::filesystem_error &e)
{
std::cerr<< e.what() <<std::endl;
}
}
}
~FileLogStrategy()
{}
void SyncLog(const std::string &logmessage) override
{
std::string target = _logdir + _logfilename;
std::ofstream out(target,std::ios::app); // 追加写入文件
if(!out.is_open())
{
return;
}
// 方法1:
// out.write(logmessage.c_str(), logmessage.size());
// out.write("\n", 1); // 写入换行符
// 方法2:
// std::string line = logmessage + '\n';
// out.write(line.c_str(), line.size());
// 方法3:
out << logmessage << '\n';
out.close();
}
private:
std::string _logdir;
std::string _logfilename; // ./log/XXX.log
Mutex _mutex;
};
// 真正要的日志类
class Logger
{
public:
Logger()
{
UseConsoleLogStrategy();
}
~Logger(){}
// 显示器的刷新策略
void UseConsoleLogStrategy()
{
_strategy = std::make_unique<ConsoleLogStrategy>();
}
// 文件的刷新策略
void UseFileLogStrategy()
{
_strategy = std::make_unique<FileLogStrategy>();
}
// 内部类:一条日志
// 目标是把一个类对象,变成一个string
class LogMessage
{
public:
LogMessage(LogLevel level,std::string &filename,int line,Logger &self)
:_level(level),
_curr_time(GetTimeStamp()),
_pid(getpid()),
_filename(filename),
_line(line),
_logger(self)
{
std::stringstream ss;
ss << "[" << _curr_time << "] "
<< "[" << LogLevel2String(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _filename << "] "
<< "[" << _line << "] "
<< "- ";
_loginfo = ss.str();
}
template<typename T>
LogMessage &operator << (const T &info)
{
std::stringstream ss;
ss << info;
_loginfo += ss.str();
return *this;
}
~LogMessage() // RAII风格的日志刷新
{
if(_logger._strategy)
{
_logger._strategy->SyncLog(_loginfo);
}
}
private:
LogLevel _level; // 日志等级
std::string _curr_time; // 当前时间
pid_t _pid; // 进程pid
std::string _filename; // 文件名
int _line; // 行号
std::string _loginfo; // 一条完整的日志
Logger &_logger; // 外部类的引用
};
// LogMessage 对象打印日志的时候,故意返回一个临时的 LogMessage对象
// 为什么要返回临时内部类对象?
LogMessage operator()(LogLevel level,std::string filename, int line)
{
return LogMessage(level,filename,line,*this);
}
private:
std::unique_ptr<LogStrategy> _strategy; // 刷新日志的策略
};
Logger logger;
// 使用宏,包装我们的日志打印过程,宏有一个特点,#define A B,B替换成A
#define LOG(level) logger(level,__FILE__,__LINE__)
// 动态调整日志策略
#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileLogStrategy()
}
#endif
核心设计亮点:
- 策略模式解耦:将日志的生成与输出目的地分离,新增输出方式(如网络、数据库)只需新增策略子类,无需修改原有代码
- RAII 自动刷新:日志临时对象生命周期结束时,自动调用策略接口刷新日志,保证日志必然输出
- 多线程安全:所有输出操作都通过互斥锁保护,避免多线程打日志出现字符交织
- 丰富的日志上下文:自动携带时间戳、日志等级、PID、文件名、行号,方便问题定位
2.5 网络地址封装(InetAddr.hpp)
对sockaddr_in结构体与字节序转换进行封装,屏蔽了网络字节序与主机字节序的转换细节,大幅简化 TCP 服务器中bind、connect、accept的地址操作。
#pragma once
// 网络和本地socket转换的类
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define CONV(addr) ((struct sockaddr*)(addr))
class InetAddr
{
public:
// n -> h --- 网络转主机
InetAddr(struct sockaddr_in &addr):_net_addr(addr)
{
_port = ntohs(_net_addr.sin_port);
_ip = inet_ntoa(_net_addr.sin_addr);
}
InetAddr(uint16_t port, std::string ip = "0.0.0.0")
:_port(port),_ip(ip)
{
_net_addr.sin_family = AF_INET;
_net_addr.sin_port = htons(_port); // 将主机字节序的端口号转换为网络字节序
_net_addr.sin_addr.s_addr = inet_addr(_ip.c_str()); // 等价 INADDR_ANY
}
uint16_t Port() { return _port; }
std::string IP() { return _ip; }
struct sockaddr *Addr()
{
return CONV(&_net_addr);
}
bool operator==(const InetAddr &addr)
{
return (_ip == addr._ip) && (_port == addr._port);
}
socklen_t AddrLen()
{
return sizeof(_net_addr);
}
std::string StringAddress()
{
return "[" + _ip + ":" + std::to_string(_port) + "]";
}
~InetAddr()
{
}
private:
// 本地地址
uint16_t _port;
std::string _ip;
// 网络地址
struct sockaddr_in _net_addr;
};
核心能力:
- 自动完成主机字节序与网络字节序的转换(
htons/ntohs) - 自动完成字符串 IP 与 32 位整型 IP 的转换(
inet_addr/inet_ntoa) - 提供统一的地址指针与长度获取接口,适配原生 socket 系统调用
- 重载判等运算符,支持客户端地址的唯一标识与查找
三. V3 多进程版本:远程命令执行服务器实战
3.1 需求背景与整体设计
我们要实现一个类 SSH 的远程命令执行服务器,核心能力如下:
- 客户端与服务端建立 TCP 连接后,可发送 Linux 命令字符串
- 服务端对命令进行安全校验,仅允许执行白名单内的命令
- 服务端执行命令后,将执行结果返回给客户端
- 支持多客户端并发连接,每个连接由独立线程处理,互不干扰
整体架构设计:采用分层设计思想,将网络通信与业务处理完全解耦
- 网络通信层:多线程 TCP 服务器,负责 socket 创建、监听、连接管理、数据收发
- 业务处理层:命令执行模块,负责命令安全校验、命令执行、结果封装
- 解耦方案:通过
std::function回调函数,将业务处理注入到网络层,网络层无需关心业务细节
3.2 业务层:命令执行模块深度解析(ExcuteCommand.hpp)
命令执行模块是业务的核心,负责命令的安全校验与执行,核心解决两个问题:如何在 C++ 中执行 Linux 命令并获取输出、如何避免恶意命令执行的安全风险。
#ifndef __EXCUTECOMMAND__HPP
#define __EXCUTECOMMAND__HPP
#include <cstddef>
#include <cstdio> // popen, pclose, fgets 等标准 C 库函数依赖于此
#include <iostream>
#include <vector>
#include "Logger.hpp"
using namespace std;
using namespace LogModule;
// 命令执行器类:专门负责解析并执行来自网络的系统命令,并将输出结果作为字符串返回
// 这是一个典型的将“业务逻辑”与“网络通信”解耦的设计
class ExcuteCommand
{
private:
// 安全校验核心:基于“白名单”机制进行拦截
// 为什么不用黑名单?因为黑名单防不住命令注入(如 "ls && rm -rf /")
bool IsSafe(const std::string &cmdstr)
{
for(auto& str : _white_list)
{
// 注意:这里使用的是精确匹配 (==),意味着客户端发来的命令必须连空格都分毫不差
// 这种设计极其严格,但也最安全,杜绝了任何形式的参数注入攻击
if(cmdstr == str) return true;
}
return false; // 如果遍历完白名单都没找到,默认视作不安全命令,直接拒绝
}
public:
ExcuteCommand()
{
// 构造时初始化白名单。只有这 5 个纯读取、无破坏性的命令是被允许执行的
_white_list.push_back("pwd");
_white_list.push_back("who");
_white_list.push_back("whoami");
_white_list.push_back("ls -a -l");
_white_list.push_back("env");
}
// 执行外部命令的主函数
std::string Excute(const std::string cmdstr)
{
// 1. 执行前置安全拦截
if(!IsSafe(cmdstr)) return "UnSafe";
// 2. 核心系统调用:popen
// popen 在底层会自动为你完成四大步骤:
// (1) 创建无名管道 (pipe)
// (2) fork() 创建子进程
// (3) 在子进程中调用 exec 函数簇执行 shell 命令 (sh -c cmdstr)
// (4) "r" 表示父进程要读取子进程的标准输出流
FILE* fp = popen(cmdstr.c_str(), "r");
if(fp == nullptr)
{
LOG(LogLevel::ERROR) << "exec error: " << cmdstr;
return "error"; // 创建进程或管道失败
}
std::string result;
char buffer[512]; // 用于暂存从管道中读取的数据块
// 3. 循环读取执行结果
// fgets 会从 fp (管道读端) 中读取数据,直到读到 EOF 或发生错误
// 由于命令输出可能很长(比如 env),这里必须用 while 循环分批读取
while(fgets(buffer, sizeof(buffer), fp) != nullptr)
{
result += buffer; // 将当前读到的数据块追加到最终结果字符串中
buffer[0] = 0; // 清空一下 (原注释保留。底层逻辑:将首字符置为 \0,作为一种防守型编程习惯)
}
// 4. 收尾工作:极其重要
// pclose 不仅仅是关闭文件指针,它在底层还会调用 waitpid() 去回收刚刚 popen 创建的子进程
// 如果不调 pclose,会导致产生大量的僵尸进程,最终耗尽系统 PID 资源
pclose(fp);
return result; // 将完整的执行结果返回给调用者(网络层)
}
~ExcuteCommand()
{}
private:
std::vector<std::string> _white_list; // 白名单列表,存储 100% 信任的命令
};
#endif
源码核心解读:
- 安全设计:白名单优先原则
- 不采用黑名单机制(无法覆盖所有恶意命令,如
ls && rm -rf/命令注入),而是采用白名单机制,仅允许执行明确指定的安全命令,从根源避免命令注入风险。 - 非法命令直接返回
UnSafe,不执行任何系统调用,最大程度保证服务器安全。
- 不采用黑名单机制(无法覆盖所有恶意命令,如
- popen 函数的核心价值
popen底层会自动创建管道、fork 子进程、调用 exec 执行 shell 命令,将命令的标准输出重定向到管道中,一行代码完成 shell 调用与结果读取,避免了手动调用 pipe+fork+exec 的繁琐流程。- 第二个参数
"r"表示读取命令输出,若为"w"则表示向命令标准输入写入数据。 - 必须调用
pclose关闭管道,否则会产生文件描述符泄漏与僵尸进程。
结果读取逻辑:通过fgets循环读取管道中的输出,拼接成完整的结果字符串返回给客户端,缓冲区逐行清空,避免数据残留。
3.3 网络层:多线程 TCP 服务器深度解析
多线程 TCP 服务器负责网络通信,通过回调函数注入业务处理逻辑,完全不关心具体业务,具备极高的复用性。
3.3.1 核心类型与成员定义
// 业务回调函数类型:输入命令字符串,返回执行结果
using callback_t = std::function<std::string(std::string)>;
static const uint16_t gdefaultport = 8080;
static const int gbacklog = 32;
class TcpServer
{
private:
// 核心业务处理函数:每个连接的IO循环
void Service(int sockfd, InetAddr client);
public:
TcpServer (uint16_t port = gdefaultport): _port(port), _listensockfd(-1) {}
// 服务器初始化:注入业务回调、socket创建、bind、listen
void Init(callback_t cb);
// 服务器启动:事件循环,accept新连接
void Start();
~TcpServer(){}
private:
uint16_t _port; // 服务器监听端口
int _listensockfd; // 监听套接字
callback_t _cb; // 业务处理回调函数
};
核心设计解读:
- 回调函数
callback_t是网络层与业务层解耦的核心,服务器只负责收发数据,具体的数据处理完全由注入的回调函数实现。 _listensockfd是监听套接字,仅用于接收客户端的连接请求,真正与客户端通信的是accept返回的新套接字。
3.3.2 服务端完整代码解析(TcpServer.hpp)
#ifndef __TCP__SERVER__HPP
#define __TCP__SERVER__HPP
#include <csignal>
#include <cstdint>
#include <functional>
#include <pthread.h>
#include <string>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "InetAddr.hpp"
#include "Logger.hpp"
using namespace LogModule;
// 定义回调函数类型:接收一个 string(客户端发来的请求),返回一个 string(处理后的结果)。
// 这是网络层(TcpServer)和业务层(如 ExecuteCommand)彻底解耦的灵魂所在。
using callback_t = std::function<std::string(std::string)>;
static const uint16_t gdefaultport = 8080;
static const int gbacklog = 32;
class TcpServer
{
private:
// sockfd: 既可以支持读,又可以支持写, TCP socket也是全双工的.
// 专门负责与某一个特定客户端进行 IO 通信的“服务员”函数
void Service(int sockfd, InetAddr client)
{
// 长连接服务
while (true)
{
char inbuffer[1024];
// 1. 读取
// 注意:sizeof(inbuffer) - 1 是为了预留一个字节,给后面的 \0 占位,防止字符串越界
int n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
if(n > 0)
{
inbuffer[n] = 0; // 手动将其转换为安全的 C 风格字符串
LOG(LogLevel::INFO) << client.StringAddress() << " say# " << inbuffer;
}
else if(n == 0)
{
// read 返回 0 是一个非常关键的信号:代表客户端主动关闭了连接 (EOF)
LOG(LogLevel::INFO) << client.StringAddress() << " close sockfd: " << sockfd << ", me too!";
break;
}
else
{
// 发生网络错误或被信号中断
LOG(LogLevel::ERROR) << "read socket error";
break;
}
// 加工处理数据
// 架构亮点:将读到的数据直接扔给绑定的回调函数,网络层根本不关心里面是在算加法还是在执行系统命令
std::string result = _cb(inbuffer);
// 2. 写回数据
int m = write(sockfd, result.c_str(), result.size());
if(m < 0)
{
LOG(LogLevel::ERROR) << "write socket error";
break;
}
}
// 极其重要:跳出循环说明服务结束,必须关闭 IO 套接字,否则会造成系统文件描述符泄漏
close(sockfd);
}
public:
TcpServer (uint16_t port = gdefaultport): _port(port), _listensockfd(-1)
{}
void Init(callback_t cb)
{
// 将外部传进来的业务逻辑注册到服务器内部
_cb = cb;
// 1. 创建套接字
// AF_INET代表使用IPv4协议,SOCK_STREAM代表使用面向连接的TCP字节流
_listensockfd = socket(AF_INET, SOCK_STREAM, 0); // 设置成0就可以了
if(_listensockfd < 0)
{
LOG(LogLevel::FATAL) << "create socket error: " << _listensockfd;
exit(2);
}
LOG(LogLevel::INFO) << "create socket success: " << _listensockfd;
// 2.bind
// 我们这里是可以直接使用我们的InetAddr的,但是我们后面再用
struct sockaddr_in local;
socklen_t len = sizeof(local);
memset(&local, 0, len); // 结构体清零,养成良好习惯防止脏数据
local.sin_family = AF_INET;
// htons/htonl: 必须将主机的字节序转换为网络统一规定的大端字节序
local.sin_port = htons(_port);
local.sin_addr.s_addr = htonl(INADDR_ANY); // INADDR_ANY表示监听本机所有网卡接口的请求
int n = bind(_listensockfd, (struct sockaddr*)&local, len);
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind error: " << _listensockfd;
exit(3);
}
LOG(LogLevel::INFO) << "bind success: " << _listensockfd;
// 3. 设置成监听
// listen 使得服务器真正开始在系统内核层面“被动待命”,gbacklog 是内核维护的全连接队列长度
n = listen(_listensockfd, gbacklog);
if(n < 0)
{
LOG(LogLevel::FATAL) << "listen error: " << _listensockfd;
exit(3);
}
LOG(LogLevel::INFO) << "listen success: " << _listensockfd;
}
void Start()
{
// 多进程版本等待的最佳实践
// 忽略 SIGCHLD 信号,告诉内核:“我对子进程的死活不感兴趣”。
// 这样子进程结束后,内核会自动清理其资源,完美避免了僵尸进程的产生,主进程也不用阻塞去 wait。
signal(SIGCHLD, SIG_IGN);
while(true)
{
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
// accept 负责从底层队列中拿取一个已经建立好的连接。_listensockfd 是拉客的,返回的 sockfd 是干活的。
int sockfd = accept(_listensockfd, (struct sockaddr *)&clientaddr, &len);
if (sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept error";
continue; // accept 失败往往是局部网络波动或信号打断,不影响全局,继续接待下一个即可
}
// 网络转主机
InetAddr clientaddress(clientaddr);
LOG(LogLevel::INFO) << "get a new link: " << clientaddress.StringAddress() << " sockfd: " << sockfd;
// version1 -- 多进程版本
// fork() 会创建一个子进程,子进程会拷贝父进程的文件描述符表
pid_t pid = fork();
if(pid < 0)
{
LOG(LogLevel::ERROR) << "fork error";
close(sockfd);
}
else if(pid == 0)
{
// 子进程,拷贝父进程的文件描述符表,从而和父进程看到同一批文件
// 关闭自己不需要的文件fd
// 子进程专职负责为这个客户端服务,不负责拉客,所以关闭监听套接字
close(_listensockfd);
// **************************************
// 子进程
// if(fork() > 0)
// exit(0);
// // 孙子进程 -- 你去执行
// Service(sockfd, clientaddress);
// **************************************
Service(sockfd, clientaddress);
exit(0); // 服务完毕后,子进程必须退出,绝不能让它回到外层的 while 循环去 accept
}
else{}
// 父进程不需要这个
// 因为父进程已经把这个 sockfd 交给子进程处理了。如果不关,每来一个客人父进程就多占一个 fd,很快就会 fd 耗尽导致服务器崩溃。
close(sockfd);
// // 父进程
// pid_t rid = waitpid(pid, nullptr, 0);
}
}
~TcpServer(){}
private:
uint16_t _port;
int _listensockfd;
callback_t _cb; // 存放外部注入的业务逻辑回调函数
};
#endif




3.3.3 客户端完整代码解析
#include "InetAddr.hpp"
#include <arpa/inet.h>
#include <cstdint>
#include <iostream>
#include <netinet/in.h>
#include <string>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
// 打印使用说明手册。
// (注:原代码这里的提示字串为 " ServerPort",但下面逻辑实际需要 IP 和 Port,这是个小瑕疵,但不影响整体逻辑)
void Usage(std::string procname)
{
std::cout << "Usage: " << procname << " ServerPort" << std::endl;
}
// 客户端启动示例: ./TcpClient 127.0.0.1 8080
int main(int argc, char *argv[])
{
// 客户端启动必须知道目标服务器是谁,所以参数个数必须为 3 (程序名、目标IP、目标端口)
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
// 解析命令行参数提取目标服务器信息
std::string serverIp = argv[1];
uint16_t serverPort = std::stoi(argv[2]);
// 1. 创建socket套接字
// 客户端也需要一个通信端点。AF_INET(IPv4), SOCK_STREAM(TCP协议)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
// 2. 建立连接
// 封装服务器的 IP 和端口地址信息
InetAddr serveraddress(serverPort, serverIp);
// 核心考点:客户端不需要手动 bind 绑定本机端口!
// 当我们调用 connect 向服务器发起连接(TCP 三次握手)时,操作系统会在底层自动为该客户端分配一个空闲的随机端口。
// 这完美避免了如果客户端把端口写死,导致端口被占用无法启动的问题。
int n = connect(sockfd, serveraddress.Addr(), serveraddress.AddrLen());
if(n < 0)
{
std::cerr << "connect error" << std::endl;
exit(3); // 连接失败通常是因为网络不通或目标服务器进程未启动
}
// 3. sockfd通信过程
// 进入长连接的请求-响应循环 (一问一答模式)
while(true)
{
std::string line;
std::cout << "Please Enter# ";
// 使用 std::getline 而不是 cin >>,是因为用户输入的命令中通常包含空格(如 "ls -a -l")
// cin >> 遇到空格就会停止读取,而 getline 会读取完整的一行
std::getline(std::cin, line);
// 写
// TCP 是全双工的,客户端用 sockfd 向服务器发数据。
ssize_t n = write(sockfd, line.c_str(), line.size());
(void)n; // 强制类型转换,用来消除编译器关于 "变量 n 声明了但未使用" 的警告
// 读
char buffer[4096]; // 开大一点
// 之前测试过,如果接收服务器 `ls -al` 返回的数据,缓冲区太小会导致数据截断。
// 所以这里开了 4096 字节。但要注意,在纯面向字节流的 TCP 中,一次 read 依然不一定能读完超大数据。
ssize_t m = read(sockfd, buffer, sizeof(buffer) - 1);
if(m > 0)
{
buffer[m] = 0; // 手动在末尾添加 '\0',将原始字节流转化为 C 风格字符串以便安全打印
std::cout << "-> " << buffer << std::endl;
}
else if(m == 0)
{
// 服务器端关闭了连接(比如服务端被 kill 掉了,或者是一个短服务处理完主动 close 了)
// 客户端感知到 EOF,随之退出
std::cerr << "server quit!" << std::endl;
break;
}
else
{
std::cerr << "read error" << std::endl;
break;
}
}
// 退出循环后,关闭属于客户端的套接字
// 此时操作系统底层会向服务器发送 TCP 四次挥手的 FIN 报文,正式断开连接
close(sockfd);
return 0;
}
3.3.4 服务端主函数代码(TcpServer.cc)
#include "TcpServer.hpp"
#include "ExcuteCommand.hpp"
#include "Logger.hpp"
#include <cstdint>
#include <memory> // 提供 std::unique_ptr 等智能指针的支持
// 打印程序的使用说明手册
// 当用户在命令行启动程序的参数数量不符合预期时,调用此函数进行提示
void Usage(std::string procname)
{
// procname 通常接收的是 argv[0],即当前可执行程序的路径或名称
std::cout << "Usage: " << procname << " ServerPort" << std::endl;
}
// 标准启动方式: ./tcp_echo_server 8080
int main(int argc, char *argv[])
{
// 参数校验:确保用户输入了程序名和监听端口号这两个参数
if(argc != 2)
{
Usage(argv[0]);
exit(1);
}
// 初始化日志系统,将后续产生的日志信息输出到控制台
ENABLE_CONSOLE_LOG_STRATEGY();
// 解析命令行参数,将字符串格式的端口号 (argv[1]) 转换为 16位无符号整数
uint16_t ServerPort = std::stoi(argv[1]);
// ==============================================================================
// 架构核心:模块化与依赖注入
// ==============================================================================
// 1. 创建一个命令行处理的模块 (代表【业务层 / 应用层】)
// 专职负责判断命令是否安全(白名单),以及使用 popen 在底层执行命令。
// 它根本不知道网络的存在,是一个纯粹的业务组件。
std::unique_ptr<ExcuteCommand> excute = std::make_unique<ExcuteCommand>();
// 2. 创建一个网络服务模块 (代表【网络通信层】)
// 专职负责 socket 创建、bind、listen 以及多进程的并发调度 (accept / fork)。
// 它只知道收发字符串,根本不知道这些字符串代表的是系统命令。
std::unique_ptr<TcpServer> tsvr = std::make_unique<TcpServer>(ServerPort);
// 3. 将业务模块“注入”到网络模块中 (解耦的灵魂所在)
// tsvr->Init 需要一个 function<string(string)> 类型的回调函数。
// 这里使用了 C++11 的 Lambda 表达式:
// - [&excute]:通过引用捕获外部作用域的 excute 智能指针对象。
// - (std::string cmdstring) -> std::string:定义了输入和输出参数,完美契合 callback_t。
// 运行时表现:当 TcpServer 收到数据时,会执行大括号里的内容,即把数据交给 excute 去执行,并把结果拿回来。
tsvr->Init([&excute](std::string cmdstring)->std::string{
return excute->Excute(cmdstring);
});
// 4. 启动网络服务器,进入 accept 阻塞循环,正式开始提供服务
tsvr->Start();
return 0; // 程序正常退出(实际上因为 Start 是死循环,一般不会执行到这里)
}
四. V4 线程池版本:高并发 Echo 服务器实现
多线程版本虽然解决了并发问题,但在短连接高并发场景下,频繁创建销毁线程会带来大量的系统开销,同时无限制创建线程会导致系统调度压力剧增。而线程池模型正是解决这个问题的最优方案。
4.1 线程池核心设计思想
线程池本质上是生产者 - 消费者模型的经典应用,核心设计如下:
- 预创建线程:服务器启动时,预先创建固定数量的工作线程,避免频繁创建销毁线程的开销。
- 任务队列:主线程(accept 线程)作为生产者,将客户端连接处理任务封装后放入任务队列。
- 线程调度:工作线程作为消费者,循环从任务队列中取出任务执行,没有任务时通过条件变量挂起等待。
- 单例模式:采用懒汉模式 + 双检查锁实现线程池单例,保证全局唯一实例,避免资源重复占用。
- 优雅退出:停止线程池时,唤醒所有工作线程,处理完剩余任务后安全退出。
4.2 线程池模板类深度解析(ThreadPool.hpp)
我们实现的线程池是模板类,支持任意类型的任务,具备极高的通用性,同时集成了之前封装的互斥锁、条件变量、线程类,是工业级的实现方案。
#ifndef THREADPOOL_HPP
#define THREADPOOL_HPP
// 可以看到我们直接使用了很多之前自己造的轮子
#include <iostream>
#include <memory>
#include <pthread.h>
#include <vector>
#include <queue>
#include "Thread.hpp"
#include "Logger.hpp"
#include "Mutex.hpp"
#include "Cond.hpp"
using namespace LogModule;
const static int gDefaultCnt = 5;
/**
* @brief 线程池单例模板类
* 采用了“懒汉模式”实现,即在第一次调用 GetInstance 时才进行实例化。
*/
template<typename T>
class ThreadPool
{
private:
// 内部逻辑:判定队列状态
bool IsEmptyQueue()
{
return _queue.empty();
}
// 内部逻辑:原子化提取任务(调用前需持有锁)
T PopHelper()
{
T t = _queue.front();
_queue.pop();
return t;
}
/**
* @brief 消费者核心执行流
* 运行于子线程栈中,通过条件变量实现高效的任务等待与唤醒。
*/
void ThreadRoutine()
{
char name[64];
pthread_getname_np(pthread_self(), name, sizeof(name));
while(true)
{
T task; // 任务对象
// 临界区作用域:确保锁的持有时间最短化
{
LockGuard lockGuard(&_mutex); // 加锁保护
// 1. 任务队列为空 && 线程处于运行状态(不退出) -- 允许休眠
/**
* 深度解析:
* 此处的 while 循环不仅解决了“虚假唤醒”,还配合单例模式
* 确保了多个子线程在竞争唯一任务队列时的逻辑严密性。
*/
while(IsEmptyQueue() && _isrunning) // 防止伪唤醒
{
LOG(LogLevel::DEBUG) << "没有任务,线程休眠: " << "|" << name << "|";
_sleeper_cnt++;
_cond.Wait(_mutex); // 核心:释放锁 -> 挂起 -> 被唤醒 -> 重获锁
_sleeper_cnt--;
LOG(LogLevel::DEBUG) << "有任务,线程唤醒: " << "|" << name << "|";
}
// 2. 任务队列为空 && 线程不处于运行状态(要退出) -- 允许退出
if(IsEmptyQueue() && !_isrunning)
{
LOG(LogLevel::INFO) << "Thread: " << name << "quit";
break;
}
// 3. 任务队列不为空,无论运行状态如何,都要提取任务处理
task = PopHelper();
}
// 任务执行放在锁外,这是实现真正并发、避免线程池退化为单线程的关键
task();
}
}
// 单例模式防御:私有化构造函数,杜绝外部随意创建对象
private:
ThreadPool(int num = gDefaultCnt): _num(num), _isrunning(false), _sleeper_cnt(0)
{
for(int i = 0; i < num; i++)
{
// 利用lambda表达式捕捉this指针,不然会出现参数不匹配的问题
// 跟Thread.hpp中有关系
_threads.emplace_back([this](){
this->ThreadRoutine();
});
}
}
// 将拷贝和赋值语句去掉
/**
* 补充建议:
* 虽然这里用了 = default,但在标准的单例模式中,
* 拷贝构造和赋值运算符通常应该设为 = delete,以防止实例被“克隆”。
*/
ThreadPool(const ThreadPool<T>& ) = delete;
ThreadPool<T>& operator =(const ThreadPool<T>&) = delete;
public:
// 定义成静态的:全局唯一访问点
/**
* @brief 获取单例对象的静态接口
* 采用了“双检查锁 (Double-Checked Locking)”机制。
*/
static ThreadPool<T>* GetInstance()
{
// 第一层判断:为了提高性能。如果实例已存在,直接返回,避免不必要的加锁开销。
if(_instance == nullptr)
{
// 加锁:保证创建实例过程的原子性,防止多个线程同时执行 new 操作
LockGuard lockGuard(&_signalton_lock);
// 第二层判断:为了保证唯一性。在获得锁后再次检查,
// 确认在此期间没有其他线程提前创建了实例。
if(_instance == nullptr)
{
LOG(LogLevel::DEBUG) << "首次创建,创建成功" ;
_instance = new ThreadPool<T>(); // 只会创建一次
_instance->Start(); // 创建出来运行一次
}
}
return _instance;
}
// 启动线程服务
void Start()
{
LockGuard lockGuard(&_mutex);
if(_isrunning)
return;
_isrunning = true;
for(auto& thread: _threads)
thread.start();
}
// 生产者下发任务
void Enqueue(const T& task)
{
LockGuard lockGuard(&_mutex);
if(!_isrunning) // 如果当前处于停止状态,禁止继续加任务
return;
_queue.push(task);
// 唤醒一个来线程来执行任务
// 优化策略:只有存在正在睡觉的工人才发通知
if(_sleeper_cnt > 0)
_cond.NotifyOne();
}
// 优雅停止线程池
void Stop()
{
LockGuard lockGuard(&_mutex);
if(_isrunning)
{
LOG(LogLevel::DEBUG) << "关闭线程池";
_isrunning = false; // 改变状态,作为 ThreadRoutine 退出的触发信号
// 唤醒所有的去执行, 保证所有线程都能意识到状态改变并正确 break
if(_sleeper_cnt > 0)
_cond.NotifyAll();
}
}
// 阻塞式资源回收
void Wait()
{
// join 操作本身阻塞,且不涉及临界资源修改,故无需加锁
for(auto& thread: _threads)
thread.join();
}
~ThreadPool()
{}
private:
// 线程池管理组件
std::vector<Thread> _threads; // 管理线程对象的容器
int _num; // 线程池规模
bool _isrunning; // 全局生命周期开关
int _sleeper_cnt; // 记录当前空闲工人的数量
std::queue<T> _queue; // 共享任务队列
Mutex _mutex; // 保护任务队列的互斥锁
Cond _cond; // 协调生产/消费节奏的条件变量
// 单例模式静态成员
static ThreadPool<T> *_instance; // 全局唯一实例指针
static Mutex _signalton_lock; // 保护单例实例化的静态锁
};
// 静态成员变量在类外初始化:
// 静态指针在 main 运行前初始化为 null,保证 GetInstance 的逻辑起点正确。
template<typename T>
ThreadPool<T>* ThreadPool<T>::_instance = nullptr;
template <typename T>
Mutex ThreadPool<T>::_signalton_lock;
#endif
4.3 基于线程池的 TCP Echo 服务器实现
Echo 服务器是网络编程的经典案例,客户端发送什么内容,服务端就原样返回,非常适合验证线程池的高并发处理能力。注意:这里我们采用短连接设计,适配线程池的适用场景。
4.3.1 服务器核心实现(TcpEchoServer.hpp)
#ifndef __TCP__ECHOSERVER__HPP
#define __TCP__ECHOSERVER__HPP
#include <csignal>
#include <cstdint>
#include <pthread.h>
#include <string>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <functional>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "InetAddr.hpp"
#include "Logger.hpp"
#include "ThreadPool.hpp"
using namespace LogModule;
static const uint16_t gdefaultport = 8080;
static const int gbacklog = 32;
// 定义一个任务
// 架构解析:利用 C++11 的 std::function 将业务逻辑统一封装成无参无返回值的可调用对象。
// 这样线程池内部的线程只需无脑执行 task_t(),实现了“任务派发”与“任务执行”的完全解耦。
using task_t = std::function<void()>;
class TcpEchoServer
{
private:
// sockfd: 既可以支持读,又可以支持写, TCP socket也是全双工的.
// 这是由线程池中某个具体的工作线程(Worker Thread)来执行的函数
void Service(int sockfd, InetAddr client)
{
char name[128];
// 获取当前执行此任务的线程名称(如 "Worker-1", "Worker-2" 等)
// 作用:在日志中打印出线程名,方便追踪多线程并发状态下,是哪个底层线程服务了哪个客户端。
pthread_getname_np(pthread_self(), name, sizeof(name));
// 长连接服务
// 我们的线程池版本不适合长连接服务,这里改成短连接
// 核心痛点:如果继续保留死循环的长连接,当并发连接数超过线程池容量(如 5 个)时,
// 所有线程都会卡在循环里出不来,导致第 6 个及以后的客户端永远得不到响应(即被“饿死”)。
// 短连接服务:处理完一次读写请求后,立刻结束函数,释放当前线程归还给线程池。
// while (true)
// {
char inbuffer[1024];
// 1. 读取
// TCP 面向字节流,此时只读取一次请求包
int n = read(sockfd, inbuffer, sizeof(inbuffer) - 1);
if (n > 0) {
inbuffer[n] = 0; // 手动转换为 C 风格字符串,防止打印越界乱码
LOG(LogLevel::INFO) << name << " : " << client.StringAddress() << " say# " << inbuffer;
} else if (n == 0) {
LOG(LogLevel::INFO) << client.StringAddress()
<< " close sockfd: " << sockfd << ", me too!";
} else {
LOG(LogLevel::ERROR) << "read socket error";
}
// 加工处理数据
std::string echo_string = "server echo# ";
echo_string += inbuffer;
// 2. 写回数据
int m = write(sockfd, echo_string.c_str(), echo_string.size());
if (m < 0) {
LOG(LogLevel::ERROR) << "write socket error";
}
// }
// 极其重要:作为短服务,处理完一次业务后必须主动关闭套接字 (四次挥手)
// 一方面释放了 Linux 系统的文件描述符资源,另一方面也告知客户端本次服务已结束。
close(sockfd);
}
public:
TcpEchoServer(uint16_t port = gdefaultport): _port(port), _listensockfd(-1)
{}
void Init()
{
// 1. 创建套接字
// AF_INET: IPv4 网络协议; SOCK_STREAM: TCP 字节流传输协议
_listensockfd = socket(AF_INET, SOCK_STREAM, 0); // 设置成0就可以了
if(_listensockfd < 0)
{
LOG(LogLevel::FATAL) << "create socket error: " << _listensockfd;
exit(2);
}
LOG(LogLevel::INFO) << "create socket success: " << _listensockfd;
// 2.bind
// 我们这里是可以直接使用我们的InetAddr的,但是我们后面再用
// 绑定本机的 IP 地址和指定的端口号,为服务器确定唯一的网络身份
struct sockaddr_in local;
socklen_t len = sizeof(local);
memset(&local, 0, len);
local.sin_family = AF_INET;
local.sin_port = htons(_port); // 主机字节序 -> 网络字节序 (处理端口)
local.sin_addr.s_addr = htonl(INADDR_ANY); // INADDR_ANY: 监听本机所有网卡接收到的数据
int n = bind(_listensockfd, (struct sockaddr*)&local, len);
if(n < 0)
{
LOG(LogLevel::FATAL) << "bind error: " << _listensockfd;
exit(3); // 常见错误原因:该端口已被其他进程占用
}
LOG(LogLevel::INFO) << "bind success: " << _listensockfd;
// 3. 设置成监听
// 将套接字从主动状态转为被动状态,允许底层操作系统接收传入的连接请求并放入全连接队列
n = listen(_listensockfd, gbacklog);
if(n < 0)
{
LOG(LogLevel::FATAL) << "listen error: " << _listensockfd;
exit(3);
}
LOG(LogLevel::INFO) << "listen success: " << _listensockfd;
}
void Start()
{
// 多进程版本等待的最佳实践
// signal(SIGCHLD, SIG_IGN); // 在当前基于线程池的架构中,我们不创建子进程,所以不需要处理此信号
// 主线程 (Main Thread) 的职责发生了转变:
// 它变成了纯粹的“任务生产者”,只负责接待新客人,具体的服务细节统统交给线程池处理。
while(true)
{
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
// accept 属于阻塞式调用,从底层的全连接队列中取出一个建立好三次握手的连接
int sockfd = accept(_listensockfd, (struct sockaddr *)&clientaddr, &len);
if (sockfd < 0)
{
LOG(LogLevel::WARNING) << "accept error";
continue;
}
// 网络转主机,方便后续打印友好的客户端 IP 和 Port
InetAddr clientaddress(clientaddr);
LOG(LogLevel::INFO) << "get a new link: " << clientaddress.StringAddress() << " sockfd: " << sockfd;
// 处理连接, 进行IO通信
// 线程池版本:将任务封装为 Lambda 表达式并推入线程池的任务队列中
// 易错点分析:这里【必须】使用值捕获 [sockfd, clientaddress, this]!
// 如果使用引用捕获 [&],当主线程立刻进入下一次 while 循环并重新 accept 时,
// 局部的 sockfd 和 clientaddr 会被覆盖或销毁,导致线程池里延后执行的任务拿到野数据。
ThreadPool<task_t>::GetInstance()->Enqueue([sockfd, clientaddress, this](){
// 通过 this 指针调用当前类的私有成员函数 Service
Service(sockfd, clientaddress);
});
}
}
~TcpEchoServer(){}
private:
uint16_t _port;
int _listensockfd;
};
#endif
核心解读:
- 短连接适配:
Service函数中,一次读写完成后立即关闭套接字,任务执行完毕后线程立即归还到线程池,可处理下一个任务,完美适配线程池模型 - 任务封装:通过 lambda 表达式捕获连接信息,将
Service调用封装成任务,通过Enqueue放入线程池的任务队列,由线程池调度执行。 - 单例线程池的使用:通过
ThreadPool<task_t>::GetInstance()获取全局唯一的线程池实例,无需手动创建与管理,使用极简。
4.4 线程池版本的核心优势
- 性能提升:避免了频繁创建销毁线程的开销,线程复用率极高,在短连接高并发场景下,性能远超每连接每线程的多线程模型。
- 资源可控:线程池的最大线程数固定,不会因为并发连接数过高导致系统线程数量爆炸,避免系统调度压力过大导致的服务卡顿。
- 流量缓冲:任务队列可以缓冲突发的大量连接请求,避免系统瞬间被打满,提升服务的稳定性。
- 代码解耦:线程池将任务的提交与执行完全解耦,服务器主循环仅需关注连接接收,无需关心任务的执行调度,代码结构更清晰。
五. 核心面试考点与实战踩坑指南(QA问答版)
5.1 高频面试考点
-
TCP 服务器中,listen 函数的第二个参数 backlog 的含义是什么?
- backlog 定义了内核中已完成三次握手的连接队列的最大长度,当客户端的三次握手完成后,会被放入这个队列,等待服务器调用 accept 取出。如果队列满了,新的客户端连接请求会被内核拒绝。
- 为什么多线程版本中,线程入口函数必须是静态成员函数?
- 原生pthread_create要求入口函数的类型是void* (*)(void*),而类的普通成员函数隐含了this指针作为第一个参数,函数签名不匹配。静态成员函数不持有this指针,符合原生接口的参数要求,因此必须用静态成员函数作为线程入口,再通过传入this指针访问对象的成员。
- 原生pthread_create要求入口函数的类型是void* (*)(void*),而类的普通成员函数隐含了this指针作为第一个参数,函数签名不匹配。静态成员函数不持有this指针,符合原生接口的参数要求,因此必须用静态成员函数作为线程入口,再通过传入this指针访问对象的成员。
- 条件变量的 wait 函数为什么必须配合互斥锁使用?两个核心原因:
- 条件变量的等待操作,需要先释放互斥锁,让其他线程修改临界资源,被唤醒后又需要重新持有锁,保证临界资源操作的原子性。
- 避免竞态条件:如果没有互斥锁,线程在调用 wait 前,其他线程可能已经发送了唤醒信号,导致该线程永远挂起,造成死锁。
- 什么是虚假唤醒?如何避免?
- 虚假唤醒是指
pthread_cond_wait函数在没有线程发送唤醒信号的情况下,意外返回。这是操作系统的正常现象,为了避免虚假唤醒导致的逻辑错误,必须用 while 循环(而非 if)判断等待条件,即使出现虚假唤醒,也会再次检查条件,不满足则继续等待。
- 虚假唤醒是指
- 线程池为什么适合短连接,不适合长连接?
- 线程池的工作线程数量是固定的,长连接场景下,一个连接会长期占用一个工作线程,当连接数超过线程池最大线程数时,新的连接将无法被处理,服务完全不可用。而短连接场景下,任务执行完毕后线程立即归还到线程池,可处理新的任务,线程利用率极高。
- popen 函数和 system 函数的区别是什么?
popen会创建管道,fork 子进程执行 shell 命令,可通过管道读取命令的输出或向命令写入输入,执行完毕后需调用pclose回收资源。system会阻塞当前进程,直到 shell 命令执行完毕,无法获取命令的输出,只能获取退出状态码。- 两者都会调用 fork+exec 执行 shell 命令,但
popen支持双向数据交互,更适合需要获取命令执行结果的场景。
- 单例模式的双检查锁中,为什么要做两次检查?
- 第一次非加锁检查:提升性能,绝大多数场景下实例已存在,直接返回,避免频繁加锁的开销。
- 第二次加锁后检查:保证线程安全,避免多个线程同时通过第一次检查,在锁等待期间,其他线程已经创建了实例,导致实例被重复创建,破坏单例特性。

5.2 实战踩坑与避坑方案
-
线程池长连接占用坑
- 坑点:用线程池处理长连接,导致工作线程被长期占用,线程池耗尽后新连接无法处理。
- 避坑:线程池仅用于短连接 / 短服务场景,长连接场景采用多进程 / 多线程 + IO 多路复用(Reactor)模型。
- 文件描述符泄漏坑
- 坑点:TCP 套接字使用完毕后未调用
close关闭,或子进程 / 子线程未关闭不需要的监听套接字,导致文件描述符泄漏,最终耗尽系统文件描述符上限,服务无法建立新连接。 - 避坑:无论函数正常返回还是异常退出,保证每个套接字最终都会被关闭;子进程 / 子线程创建后,立即关闭不需要的监听套接字。
- 坑点:TCP 套接字使用完毕后未调用
- 网络字节序转换坑
- 坑点:端口和 IP 地址未转换为网络字节序(大端),直接传入 bind/connect,导致网络通信异常,客户端无法连接服务器。
- 避坑:所有端口号必须通过
htons转换,IP 地址必须通过inet_addr/htonl转换,严格区分主机字节序与网络字节序。
- 多线程临界区过大坑
- 坑点:线程池中,将任务执行逻辑放在互斥锁的临界区内,导致同一时间只有一个线程能执行任务,线程池退化为单线程,完全失去并发能力。
- 避坑:严格遵循临界区最小化原则,仅在操作共享临界资源时持有锁,业务逻辑 / 任务执行必须放在锁外。
结语
高并发 TCP 服务器开发,是连接底层操作系统内核和上层分布式业务架构的一道桥梁。从单线程的简陋,到多线程的无序,再到线程池模型的成熟,我们在解决问题的同时,也更深刻地理解了系统对资源的管控。
如果你想写出更高级的、百万并发级别的服务器,不妨在此基础上,继续深入探索 epoll + 非阻塞 I/O(Reactor 模式)以及边缘触发(Edge Triggered)的奥秘。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)