
🔥草莓熊Lotso: 个人主页
❄️个人专栏: 《C++知识分享》 《Linux 入门到实践:零基础也能懂》
✨生活是默默的坚持,毅力是永久的享受!
🎬 博主简介:

文章目录
- 前言:
- [一. TCP 服务器核心模型演进](#一. TCP 服务器核心模型演进)
- [二. 自研基础组件轮子深度解析](#二. 自研基础组件轮子深度解析)
-
- [2.1 互斥锁与 RAII 锁守卫(Mutex.hpp)](#2.1 互斥锁与 RAII 锁守卫(Mutex.hpp))
- [2.2 条件变量封装(Cond.hpp)](#2.2 条件变量封装(Cond.hpp))
- [2.3 线程封装(Thread.hpp)](#2.3 线程封装(Thread.hpp))
- [2.4 策略模式日志系统(Logger.hpp)](#2.4 策略模式日志系统(Logger.hpp))
- [2.5 网络地址封装(InetAddr.hpp)](#2.5 网络地址封装(InetAddr.hpp))
- [三. V3-1 多进程版本:远程命令执行服务器实战](#三. V3-1 多进程版本:远程命令执行服务器实战)
-
- [3.1 需求背景与整体设计](#3.1 需求背景与整体设计)
- [3.2 业务层:命令执行模块深度解析(ExcuteCommand.hpp)](#3.2 业务层:命令执行模块深度解析(ExcuteCommand.hpp))
- [3.3 网络层:多线程 TCP 服务器深度解析](#3.3 网络层:多线程 TCP 服务器深度解析)
-
- [3.3.1 核心类型与成员定义](#3.3.1 核心类型与成员定义)
- [3.3.2 服务端完整代码解析(TcpServer.hpp)](#3.3.2 服务端完整代码解析(TcpServer.hpp))
- [3.3.3 客户端完整代码解析](#3.3.3 客户端完整代码解析)
- [3.3.4 服务端主函数代码(TcpServer.cc)](#3.3.4 服务端主函数代码(TcpServer.cc))
- [四. V4 线程池版本:高并发 Echo 服务器实现](#四. V4 线程池版本:高并发 Echo 服务器实现)
-
- [4.1 线程池核心设计思想](#4.1 线程池核心设计思想)
- [4.2 线程池模板类深度解析(ThreadPool.hpp)](#4.2 线程池模板类深度解析(ThreadPool.hpp))
- [4.3 基于线程池的 TCP Echo 服务器实现](#4.3 基于线程池的 TCP Echo 服务器实现)
-
- [4.3.1 服务器核心实现(TcpEchoServer.hpp)](#4.3.1 服务器核心实现(TcpEchoServer.hpp))
- [4.3.2 服务器启动入口](#4.3.2 服务器启动入口)
- [4.4 线程池版本的核心优势](#4.4 线程池版本的核心优势)
- [五. 核心面试考点与实战踩坑指南](#五. 核心面试考点与实战踩坑指南)
-
- [5.1 高频面试考点](#5.1 高频面试考点)
- [5.2 实战踩坑与避坑方案](#5.2 实战踩坑与避坑方案)
- 结尾:
前言:
在 Linux 后端开发中,TCP 服务器是网络编程的核心基石,从入门级的单进程 Echo 服务器,到生产环境支撑高并发的线程池服务器,其演进过程不仅是代码的优化,更是对操作系统进程 / 线程模型、网络 IO、并发编程的深度理解。而远程命令执行作为 TCP 服务器的经典业务场景,更是复刻了 SSH、运维管控平台的核心实现逻辑,同时也是后端开发面试中的高频手写题与深度问答考点。本文将从自研基础组件封装出发,完整拆解多线程远程命令执行服务器 与线程池高并发 Echo 服务器的工业级实现,逐行解析核心源码,梳理 TCP 服务器模型的演进逻辑,同时覆盖并发编程的核心坑点与面试高频考点,让你不仅能写得出,更能懂底层、讲明白。
一. TCP 服务器核心模型演进
在正式进入代码实战前,我们先理清 TCP 服务器的四大核心模型的演进逻辑,明确每个模型的优劣与适用场景,这也是面试中最基础的必考题。
| 服务器模型 | 核心实现 | 核心优势 | 核心劣势 | 适用场景 |
|---|---|---|---|---|
| 单进程模型 | accept 后串行处理客户端连接 | 代码简单、无并发安全问题 | 一次只能处理一个连接,完全不支持并发 | 入门学习、单客户端固定场景 |
| 多进程模型 | 每个客户端连接 fork 一个子进程处理 | 进程地址空间隔离,稳定性极高 | 进程创建/销毁开销大,并发上限低 | 长连接、低并发、高稳定性要求场景 |
| 多线程模型 | 每个客户端连接创建一个线程处理 | 线程开销远小于进程,共享地址空间通信便捷 | 频繁创建销毁线程有开销,线程过多会导致系统调度压力剧增 | 中等并发、中短连接场景 |
| 线程池模型 | 预创建固定数量线程,任务入队后线程池调度执行 | 避免线程频繁创建销毁开销,限制最大线程数,控制系统调度压力 | 不适合长连接场景(长连接会长期占用工作线程) | 短连接、高并发、突发流量场景 |
核心关键结论 :线程池版本仅适合短服务 / 短连接场景。如果用线程池处理长连接,一个连接会长期占用一个工作线程,当线程池线程耗尽后,新的客户端将完全无法建立连接,这是新手最容易踩的坑。
二. 自研基础组件轮子深度解析
本文所有服务器实现,均基于自研的 Linux 系统编程组件封装,这些组件不仅屏蔽了原生 C 接口的繁琐细节,更是解决了并发安全、资源泄漏等经典问题。
2.1 互斥锁与 RAII 锁守卫(Mutex.hpp)
互斥锁是并发编程的基石,用于保护临界资源的原子访问;而 RAII 风格的锁守卫,是 C++ 中避免锁泄漏、死锁的最佳实践。
cpp
#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)
条件变量用于线程间的通知机制,配合互斥锁实现生产者 - 消费者模型,是线程池的核心同步组件。
cpp
#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 获取、状态管理等实用能力。
cpp
#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 机制实现日志消息的自动刷新,是服务器调试与问题定位的核心工具。
核心设计亮点:
- 策略模式解耦:将日志的生成与输出目的地分离,新增输出方式(如网络、数据库)只需新增策略子类,无需修改原有代码
- RAII 自动刷新:日志临时对象生命周期结束时,自动调用策略接口刷新日志,保证日志必然输出
- 多线程安全:所有输出操作都通过互斥锁保护,避免多线程打日志出现字符交织
- 丰富的日志上下文:自动携带时间戳、日志等级、PID、文件名、行号,方便问题定位
2.5 网络地址封装(InetAddr.hpp)
对sockaddr_in结构体与字节序转换进行封装,屏蔽了网络字节序与主机字节序的转换细节,大幅简化 TCP 服务器中bind、connect、accept的地址操作。
核心能力:
- 自动完成主机字节序与网络字节序的转换(
htons/ntohs) - 自动完成字符串 IP 与 32 位整型 IP 的转换(
inet_addr/inet_ntoa) - 提供统一的地址指针与长度获取接口,适配原生 socket 系统调用
- 重载判等运算符,支持客户端地址的唯一标识与查找
三. V3-1 多进程版本:远程命令执行服务器实战
3.1 需求背景与整体设计
我们要实现一个类 SSH 的远程命令执行服务器,核心能力如下:
- 客户端与服务端建立 TCP 连接后,可发送 Linux 命令字符串
- 服务端对命令进行安全校验,仅允许执行白名单内的命令
- 服务端执行命令后,将执行结果返回给客户端
- 支持多客户端并发连接,每个连接由独立线程处理,互不干扰
整体架构设计:采用分层设计思想,将网络通信与业务处理完全解耦
- 网络通信层:多线程 TCP 服务器,负责 socket 创建、监听、连接管理、数据收发
- 业务处理层:命令执行模块,负责命令安全校验、命令执行、结果封装
- 解耦方案 :通过
std::function回调函数,将业务处理注入到网络层,网络层无需关心业务细节

3.2 业务层:命令执行模块深度解析(ExcuteCommand.hpp)
命令执行模块是业务的核心,负责命令的安全校验与执行,核心解决两个问题:如何在 C++ 中执行 Linux 命令并获取输出、如何避免恶意命令执行的安全风险。
cpp
#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 核心类型与成员定义
cpp
// 业务回调函数类型:输入命令字符串,返回执行结果
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)
cpp
#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 客户端完整代码解析
cpp
#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)
cpp
#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)
我们实现的线程池是模板类,支持任意类型的任务,具备极高的通用性,同时集成了之前封装的互斥锁、条件变量、线程类,是工业级的实现方案。
cpp
#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)
cpp
#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.3.2 服务器启动入口
cpp
客户端代码跟上面一样


4.4 线程池版本的核心优势
- 性能提升:避免了频繁创建销毁线程的开销,线程复用率极高,在短连接高并发场景下,性能远超每连接每线程的多线程模型。
- 资源可控:线程池的最大线程数固定,不会因为并发连接数过高导致系统线程数量爆炸,避免系统调度压力过大导致的服务卡顿。
- 流量缓冲:任务队列可以缓冲突发的大量连接请求,避免系统瞬间被打满,提升服务的稳定性。
- 代码解耦:线程池将任务的提交与执行完全解耦,服务器主循环仅需关注连接接收,无需关心任务的执行调度,代码结构更清晰。
五. 核心面试考点与实战踩坑指南
5.1 高频面试考点
-
TCP 服务器中,listen 函数的第二个参数 backlog 的含义是什么?
- backlog 定义了内核中已完成三次握手的连接队列的最大长度,当客户端的三次握手完成后,会被放入这个队列,等待服务器调用 accept 取出。如果队列满了,新的客户端连接请求会被内核拒绝。
-
为什么多线程版本中,线程入口函数必须是静态成员函数?
- 原生
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)模型。
-
命令注入安全坑
- 坑点:使用黑名单机制过滤命令,无法抵御
ls && rm -rf /、;rm -rf /等命令注入攻击,导致服务器被入侵。 - 避坑:采用白名单机制,仅允许执行明确指定的安全命令,从根源避免命令注入风险。
- 坑点:使用黑名单机制过滤命令,无法抵御
-
文件描述符泄漏坑
- 坑点:TCP 套接字使用完毕后未调用
close关闭,或子进程 / 子线程未关闭不需要的监听套接字,导致文件描述符泄漏,最终耗尽系统文件描述符上限,服务无法建立新连接。 - 避坑:无论函数正常返回还是异常退出,保证每个套接字最终都会被关闭;子进程 / 子线程创建后,立即关闭不需要的监听套接字。
- 坑点:TCP 套接字使用完毕后未调用
-
网络字节序转换坑
- 坑点:端口和 IP 地址未转换为网络字节序(大端),直接传入 bind/connect,导致网络通信异常,客户端无法连接服务器。
- 避坑:所有端口号必须通过
htons转换,IP 地址必须通过inet_addr/htonl转换,严格区分主机字节序与网络字节序。
-
多线程临界区过大坑
- 坑点:线程池中,将任务执行逻辑放在互斥锁的临界区内,导致同一时间只有一个线程能执行任务,线程池退化为单线程,完全失去并发能力。
- 避坑:严格遵循临界区最小化原则,仅在操作共享临界资源时持有锁,业务逻辑 / 任务执行必须放在锁外。
结尾:
html
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!
结语:本文从基础组件封装出发,完整实现了多线程远程命令执行服务器与线程池高并发 Echo 服务器两大工业级 TCP 服务器,逐行解析了核心源码的设计思想与底层细节,同时梳理了 TCP 服务器模型的演进逻辑、面试高频考点与实战踩坑方案。这些内容不仅是 Linux 后端开发的核心技能,更是校招、社招面试中的必考内容。掌握这些内容,你不仅能写出稳定、高效的 TCP 服务器,更能深度理解操作系统的并发模型、网络编程的底层逻辑,为后续学习 Reactor 高并发模型、nginx、muduo 等开源网络库打下最坚实的基础。网络编程的学习,从来不是简单的 API 调用,而是对操作系统、计算机网络、数据结构与算法、设计模式的综合理解。希望本文能带你真正走进 Linux 高性能服务器开发的大门。
✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど
