【Linux网络】从 0 到工业级:TCP 服务器多线程 / 线程池全实现 + 远程命令执行实战


🔥草莓熊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 服务器中bindconnectaccept的地址操作。

核心能力

  • 自动完成主机字节序与网络字节序的转换(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关闭,或子进程 / 子线程未关闭不需要的监听套接字,导致文件描述符泄漏,最终耗尽系统文件描述符上限,服务无法建立新连接。
    • 避坑:无论函数正常返回还是异常退出,保证每个套接字最终都会被关闭;子进程 / 子线程创建后,立即关闭不需要的监听套接字。
  • 网络字节序转换坑

    • 坑点:端口和 IP 地址未转换为网络字节序(大端),直接传入 bind/connect,导致网络通信异常,客户端无法连接服务器。
    • 避坑:所有端口号必须通过htons转换,IP 地址必须通过inet_addr/htonl转换,严格区分主机字节序与网络字节序。
  • 多线程临界区过大坑

    • 坑点:线程池中,将任务执行逻辑放在互斥锁的临界区内,导致同一时间只有一个线程能执行任务,线程池退化为单线程,完全失去并发能力。
    • 避坑:严格遵循临界区最小化原则,仅在操作共享临界资源时持有锁,业务逻辑 / 任务执行必须放在锁外。

结尾:

html 复制代码
🍓 我是草莓熊 Lotso!若这篇技术干货帮你打通了学习中的卡点:
👀 【关注】跟我一起深耕技术领域,从基础到进阶,见证每一次成长
❤️ 【点赞】让优质内容被更多人看见,让知识传递更有力量
⭐ 【收藏】把核心知识点、实战技巧存好,需要时直接查、随时用
💬 【评论】分享你的经验或疑问(比如曾踩过的技术坑?),一起交流避坑
🗳️ 【投票】用你的选择助力社区内容方向,告诉大家哪个技术点最该重点拆解
技术之路难免有困惑,但同行的人会让前进更有方向~愿我们都能在自己专注的领域里,一步步靠近心中的技术目标!

结语:本文从基础组件封装出发,完整实现了多线程远程命令执行服务器与线程池高并发 Echo 服务器两大工业级 TCP 服务器,逐行解析了核心源码的设计思想与底层细节,同时梳理了 TCP 服务器模型的演进逻辑、面试高频考点与实战踩坑方案。这些内容不仅是 Linux 后端开发的核心技能,更是校招、社招面试中的必考内容。掌握这些内容,你不仅能写出稳定、高效的 TCP 服务器,更能深度理解操作系统的并发模型、网络编程的底层逻辑,为后续学习 Reactor 高并发模型、nginx、muduo 等开源网络库打下最坚实的基础。网络编程的学习,从来不是简单的 API 调用,而是对操作系统、计算机网络、数据结构与算法、设计模式的综合理解。希望本文能带你真正走进 Linux 高性能服务器开发的大门。

✨把这些内容吃透超牛的!放松下吧✨ ʕ˘ᴥ˘ʔ づきらど

相关推荐
汐ya~4 小时前
GELab-Zero:面向 Android 的开源移动端 GUI Agent,让 AI 像人一样用手机
android·人工智能·开源
嵌入式-老费4 小时前
esp32开发与应用(用ai开发esp32)
人工智能
快递鸟社区4 小时前
快递鸟海运查询接口全面解析:从入门到精通,助力跨境物流可视化
java·前端·人工智能
盛世宏博北京4 小时前
物联网赋能档案保护——档案馆“八防”温湿度智能监控系统实施方案
运维·服务器·网络
Benszen4 小时前
docker简介
运维·docker·容器
踩着两条虫4 小时前
可视化设计器组件系统:从交互核心到 AI 智能代理的落地实践
开发语言·前端·人工智能·低代码·设计模式·架构
TG_yunshuguoji4 小时前
云代理商:云端部署的Hermes Agent 如何接入Slack?
人工智能·云计算·ai 智能体·hermes agent
手揽回忆怎么睡4 小时前
云服务器部署
运维·服务器
华纳云IDC服务商4 小时前
站群服务器能带多少个网站?内存和带宽该如何配置?
运维·服务器