《Linux网络编程》TCP 网络编程(下):基于 TCP 多线程通用字典服务 + 远程命令执行实战

🔥小叶-duck个人主页

❄️个人专栏《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》

《Linux系统从入门到实践》《Linux网络从入门到实践》

《Qt 方寸极境》 《MySQL》

未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游


目录

前言

[一、TCP 服务器核心模型演进](#一、TCP 服务器核心模型演进)

二、项目前置公共基础组件深度解析

[2.1 通用工具头文件:Common.hpp](#2.1 通用工具头文件:Common.hpp)

[2.2 互斥锁与 RAII 锁守卫(Mutex.hpp)](#2.2 互斥锁与 RAII 锁守卫(Mutex.hpp))

[2.3 条件变量封装(Cond.hpp)](#2.3 条件变量封装(Cond.hpp))

[2.4 线程封装(Thread.hpp)](#2.4 线程封装(Thread.hpp))

[2.5 网络地址封装(InetAddr.hpp)](#2.5 网络地址封装(InetAddr.hpp))

三、服务端头文件完整代码解析(TcpServer.hpp)

四、客户端完整代码解析(TcpClient.cc)

三、实战1:多线程通用字典服务

[3.1 需求分析](#3.1 需求分析)

[3.2 词典文件 Dict.txt](#3.2 词典文件 Dict.txt)

[3.3 字典类实现 Dict.hpp(复用)](#3.3 字典类实现 Dict.hpp(复用))

[3.4 服务端主函数 TcpServer.cpp](#3.4 服务端主函数 TcpServer.cpp)

[3.5 代码运行展示](#3.5 代码运行展示)

[四、实战2: 远程命令执行服务](#四、实战2: 远程命令执行服务)

[4.1 前置知识:管道与重定向(命令执行的底层原理)](#4.1 前置知识:管道与重定向(命令执行的底层原理))

[4.1.1 原生管道:pipe + dup2 + exec](#4.1.1 原生管道:pipe + dup2 + exec)

完整流程解析:

[4.1.2 封装接口:popen / pclose](#4.1.2 封装接口:popen / pclose)

[4.2 需求背景与整体设计](#4.2 需求背景与整体设计)

[4.3 业务层:命令执行模块深度解析(Command.hpp)](#4.3 业务层:命令执行模块深度解析(Command.hpp))

[4.4 网络层:服务端主程序代码(TcpServer.cc)](#4.4 网络层:服务端主程序代码(TcpServer.cc))

[4.5 代码运行展示](#4.5 代码运行展示)

五、核心面试考点与实战踩坑指南

[5.1 高频面试考点](#5.1 高频面试考点)

[5.2 实战踩坑与避坑方案](#5.2 实战踩坑与避坑方案)

结束语


前言

经过上一篇 TCP 网络编程内容的学习,我们已经掌握了 Socket 基础 API、TCP 连接建立与断开等网络理论与编码实践。但基础 API 仅仅只是起点,想要落地真实业务,还需要结合 C++ 工程组件、Linux 系统调用,去解决业务封装、并发处理、字节流特性、命令执行等一系列现实问题。

本篇将继续推进 TCP 网络编程实战,一方面沉淀一套可复用的自研基础组件,把互斥锁、条件变量、线程、日志、网络地址等通用能力进行封装,为后续项目提供底层支撑;另一方面落地两大实战案例:通用字典服务,以及风险点颇多的远程命令执行服务器。我们会深入剖析pipedup2execpopen等进程管道与程序替换接口,搞懂 shell 命令执行底层原理,同时重点讨论命令执行场景下的安全防护,对比白名单、黑名单两种安全方案的优劣。同时我们也不会忽略 TCP 本身的特性,专门梳理 TCP 字节流带来的各类经典坑点,包括粘包问题、IO 读写不完全、缓冲区行为、read 返回值等高频易错点。

一、TCP 服务器核心模型演进

在正式进入代码实战前,我们先理清 TCP 服务器的四大核心模型的演进逻辑,明确每个模型的优劣与适用场景,这也是面试中最基础的必考题。

模型 核心实现 核心优势 核心劣势 适用场景
单进程模型 accept 后串行处理客户端连接 代码简单、无并发安全问题 一次只能处理一个连接,完全不支持并发 入门学习、单客户端固定场景
多进程模型 每个客户端连接 fork 一个子进程处理 进程地址空间隔离,稳定性极高 进程创建/销毁开销大,并发上限低 长连接、低并发、高稳定性要求场景
多线程模型 每个客户端连接创建一个线程处理 线程开销远小于进程,共享地址空间通信便捷 频繁创建销毁线程有开销,线程过多会导致系统调度压力剧增 中等并发、中短连接场景
线程池模型 预创建固定数量线程,任务入队后线程池调度执行 避免线程频繁创建销毁开销,限制最大线程数,控制系统调度压力 不适合长连接场景(长连接会长期占用工作线程) 短连接、高并发、突发流量场景

**核心关键结论:**线程池模型更加适配短服务、短连接的业务。如果拿线程池去处理长连接业务,一条长连接会持续霸占一个工作线程,一旦线程池内全部工作线程被占满,后续新的客户端任务就没有线程可以处理,新连接业务直接卡死,这是网络编程开发中新手极易踩中的典型坑点。

而这次我们所写的通用字典服务以及远程命令执行都属于长服务,所以使用线程池模型不是很适配,由于线程池的线程数量是有限的,当一个客户端使用翻译模块后就会一直占据一个线程,当线程被使用完那么其他客户端就无法进行访问了。

所以我们会使用多线程模型来实现接下来的两个服务代码。

二、项目前置公共基础组件深度解析

本文所有服务器实现,均基于自研的 Linux 系统编程组件封装,这些组件不仅屏蔽了原生 C 接口的繁琐细节,更是解决了并发安全、资源泄漏等经典问题。

2.1 通用工具头文件:Common.hpp

Common.hpp 封装项目通用基础能力,统一定义程序退出错误码与不可拷贝基类,收拢项目所需各类头文件依赖,是整个网络项目的公共基础头文件。

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <memory>
#include <cstdlib>
#include <unistd.h>
#include <functional>

#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>

enum ExitCode
{
    OK = 1,
    USAGE_ERR,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR,
    FORK_ERR
};

// 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
// delete掉唯一父类的拷贝构造和赋值拷贝,则所有子类服务器也就全都不能拷贝了
class Nocopy
{
public:
    Nocopy() {}
    ~Nocopy() {}
    // 禁止父类的拷贝构造和赋值拷贝
    Nocopy(const Nocopy &) = delete;
    const Nocopy &operator=(const Nocopy &) = delete;
};

源码核心解读:

  • 定义一套统一的程序退出错误码 ExitCode:用于标识 socket、bind、listen、fork 等各类系统调用失败场景,统一项目异常退出标识;
  • 实现 Nocopy 禁止拷贝基类:继承该类的子类自动禁用拷贝构造与赋值重载,避免对象拷贝引发的资源管理问题,网络服务类可以直接继承 Nocopy。

Common.hpp 集中收纳 C++ 标准库、Linux 系统网络编程头文件,项目其余代码只需要引入这一个头文件,就可以获得网络开发需要的基础依赖,简化项目头文件引入工作。

2.2 互斥锁与 RAII 锁守卫(Mutex.hpp)

互斥锁是并发编程当中最基础的组件,主要用来保障临界资源能够被原子化访问,规避多线程同时读写带来的数据竞争问题。而基于 RAII 思想实现的锁守卫,则是 C++ 开发中用来规避锁泄露、降低死锁风险的工程最佳实践。

cpp 复制代码
// 互斥锁的封装
#ifndef MUTEX_HPP
#define MUTEX_HPP

#include <iostream>
#include <pthread.h>
#include <string>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
            // std::cout << "mutex init success" << std::endl;
        }

        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_lock false" << std::endl;
            }
        }

        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_unlock false" << std::endl;
            }
        }

        ~Mutex()
        {
            int n = pthread_mutex_destroy(&_mutex);
            if (n != 0)
            {
                std::cerr << "pthread_mutex_destroy false" << std::endl;
            }
            else
            {
                // std::cout << "mutex destroy success" << std::endl;
            }
        }

        pthread_mutex_t *GetMutex()
        {
            return &_mutex;
        }

    private:
        pthread_mutex_t _mutex;
    };

    // RAII风格的互斥锁的封装(实现自动解锁)
    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex) : _mutex(mutex)
        {
            _mutex.Lock();
        }

        ~LockGuard()
        {
            _mutex.Unlock();
        }

    private:
        Mutex &_mutex;
    };
}

#endif

源码核心解读:

  • 极简接口封装 :对原生pthread_mutex的核心操作做面向对象封装,额外暴露Origin()接口,可以直接取出底层原生锁,方便和条件变量这类 C 原生接口协同工作。
  • RAII 自动管理锁生命周期:LockGuard 对象构造阶段自动完成加锁,对象销毁时自动执行解锁。不管函数是正常执行完毕返回,还是中途抛出异常,锁都一定会被释放,从根源杜绝锁泄漏问题。
  • 典型使用场景:线程池任务队列、日志文件写入等全部临界资源访问场景,都可以借助 LockGuard 完成保护,开发人员不需要手动调用 unlock,代码健壮度大幅提升。

2.3 条件变量封装(Cond.hpp)

条件变量承担线程之间事件通知的能力,必须搭配互斥锁一起使用,是实现生产者‑消费者模型的核心部件,也是线程池实现里面不可或缺的同步组件。

cpp 复制代码
// 条件变量的封装
#ifndef COND_HPP
#define COND_HPP

#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"

using namespace MutexModule;

namespace CondModule
{
    class Cond
    {
    public:
        Cond()
        {
            pthread_cond_init(&_cond, nullptr);
        }

        void Wait(Mutex &mutex)
        {
            int n = pthread_cond_wait(&_cond, mutex.GetMutex());
            if (n != 0)
            {
                std::cerr << "Failed to Wait" << std::endl;
            }
        }

        void Signal()
        {
            int n = pthread_cond_signal(&_cond);
            if (n != 0)
            {
                std::cerr << "Failed to Signal" << std::endl;
            }
        }

        void Broadcast()
        {
            int n = pthread_cond_broadcast(&_cond);
            if (n != 0)
            {
                std::cerr << "Failed to Broadcast" << std::endl;
            }
        }

        ~Cond()
        {
            pthread_cond_destroy(&_cond);
        }

    private:
        pthread_cond_t _cond;
    };
}

#endif

源码核心解读:

  • Wait 函数底层三步核心逻辑(面试高频考点)
    1. 自动释放传入的互斥锁,其他线程才有机会修改临界条件资源
    2. 将当前执行线程挂起,加入条件变量内部的等待队列,进入休眠
    3. 线程被唤醒之后,会重新竞争并拿到互斥锁,wait 函数才返回
  • 两类唤醒接口区分NotifyOne适合普通任务投递,唤醒单个等待线程处理任务;NotifyAll多用于线程池优雅退出等场景,唤醒全部处于等待状态的线程。
  • 编码规范 :条件变量等待操作必须使用while循环包裹条件判断,不能直接使用if,用来防御虚假唤醒,该规则会在线程池实战部分进一步体现。

2.4 线程封装(Thread.hpp)

对 POSIX 原生线程库做 C++ 面向对象封装,解决 C++ 类普通成员函数无法直接充当线程入口的参数不匹配问题,在此基础之上拓展线程命名、获取 LWP 轻量级进程 ID、线程状态管控等实用功能。

cpp 复制代码
// 线程封装
#ifndef __THREAD_HPP
#define __THREAD_HPP

#include <iostream>
#include <string>
#include <cstdio>
#include <functional>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/syscall.h>

enum TSTAYUS
{
    THREAD_NEW,     // 创建但没有运行状态
    THREAD_RUNNING, // 运行状态
    THREAD_STOPPED, // 退出状态
};

static int gnum = 1;
using func_t = std::function<void()>;

namespace ThreadModule
{
    class Thread
    {
    private:
        static void *routine(void *args)
        {
            Thread *self = static_cast<Thread *>(args);
            self->get_pid();
            self->get_lwpid();
            // 获取线程名字:pthread_setname_np
            // int pthread_setname_np(pthread_t thread, const char *name);
            pthread_setname_np(pthread_self(), self->_name.c_str());
            self->_func(); // 回调处理
            return nullptr;
        }

        void get_pid()
        {
            _pid = getpid();
        }

        void get_lwpid()
        {
            _lwpid = syscall(SYS_gettid); // syscall陷入内核获取LWP轻量级进程ID
        }

    public:
        Thread(func_t func)
            : _tid(0), _joinable(true), _status(TSTAYUS::THREAD_NEW), _func(func)
        {
            _name = "thread-" + std::to_string(gnum++);
        }

        bool Start()
        {
            if (_status == TSTAYUS::THREAD_RUNNING)
            {
                std::cerr << "thread is already running" << std::endl;
                return false;
            }
            // 重点
            int n = pthread_create(&_tid, nullptr, routine, this);
            if (n != 0)
            {
                std::cerr << "pthread_create failed" << std::endl;
                return false;
            }
            else
            {
                std::cout << _name << " create success" << std::endl;
                _status = TSTAYUS::THREAD_RUNNING;
                return true;
            }
        }

        void Detach()
        {
            if (_joinable)
            {
                int n = pthread_detach(_tid);
                if (n != 0)
                {
                    std::cerr << "pthread_detach failed" << std::endl;
                }
                else
                {
                    std::cout << _name << " detach success" << std::endl;
                    _joinable = false;
                }
            }
            else // 已经处于分离状态,不可再次分离
            {
                std::cerr << "detach failed, because thread is detached" << std::endl;
            }
        }

        void Stop()
        {
            if (_status == TSTAYUS::THREAD_RUNNING)
            {
                int n = pthread_cancel(_tid);
                if (n != 0)
                {
                    std::cerr << "pthread_cancel failed" << std::endl;
                }
                else
                {
                    std::cout << _name << " stop success" << std::endl;
                    _status = TSTAYUS::THREAD_STOPPED;
                }
            }
        }

        void Join()
        {
            if (_joinable)
            {
                int n = pthread_join(_tid, nullptr);
                if (n != 0)
                {
                    std::cerr << "pthread_join failed" << std::endl;
                }
                else
                {
                    printf("lwp: %d, name: %s, thread join success\n", _lwpid, _name.c_str());
                }
            }
            else // 分离的线程不能join
            {
                std::cerr << "join failed, because thread is detached" << std::endl;
            }
        }

        //获取线程id
        pthread_t TID()
        {
            return _tid;
        }

        ~Thread() {}

    private:
        pthread_t _tid;
        pid_t _pid;
        pid_t _lwpid;
        std::string _name;
        bool _joinable;  // 线程可否join(是否分离)
        TSTAYUS _status; // 线程状态
        func_t _func;    // 回调变量
    };
}

#endif

源码核心解读:

  • 线程入口适配方案 :原生pthread_create强制要求入口函数签名为void* (*)(void*);类普通成员函数会隐式携带this指针,函数签名不匹配。解决方案是使用静态成员函数作为真正的线程入口,把对象的this指针作为参数传入,在静态函数内部还原对象上下文,调用业务成员函数。
  • 线程状态机管控:借助枚举类型记录线程新建、运行、停止等状态,做合法性校验,防止重复启动、重复停止等非法调用。
  • 两种线程资源回收方式 :同时支持detach线程分离模式,交由操作系统自动回收资源;以及join阻塞等待线程结束回收资源,适配不同业务场景。

2.5 网络地址封装(InetAddr.hpp)

sockaddr_in结构体以及大小端字节序转换接口进行封装,屏蔽主机字节序与网络字节序繁琐的转换细节,简化 TCP 编程中 bind、connect、accept 接口的地址处理逻辑。

cpp 复制代码
#ifndef INETADDR_HPP
#define INETADDR_HPP

#include "Common.hpp"

// 网络地址和主机地址之间进行转换的类
class InetAddr
{
public:
    // 构造函数的函数重载:
    // 网络转本地:主要用于接收来自网络客户端消息后,解析获取本地ip地址和端口号
    InetAddr(struct sockaddr_in &addr) : _addr(addr)
    {
        // 网络------>本地
        _port = ntohs(_addr.sin_port);
        _ip = inet_ntoa(_addr.sin_addr);
    }

    // 本地转网络:主要用于客户端已知服务端ip地址和端口号,填充获取struct sockaddr_in _addr,向服务端发送消息
    InetAddr(uint16_t port, const std::string &ip) : _port(port), _ip(ip)
    {
        bzero(&_addr, sizeof(_addr));
        // 填充struct sockaddr_in _addr
        _addr.sin_family = AF_INET;
        // htons: Host to Network Short,将本地主机字节序转为网络字节序
        _addr.sin_port = htons(port);
        // inet_addr: 将字符串 IP 转换为 32 位网络字节序的数值
        _addr.sin_addr.s_addr = inet_addr(ip.c_str());
    }

    // 本地转网络,我们还能再写一个没有ip参数的函数重载
    // 为了方便服务端的使用构建struct sockaddr_in
    InetAddr(uint16_t port) : _port(port), _ip("0")
    {
        bzero(&_addr, sizeof(_addr));
        // 填充struct sockaddr_in _addr
        _addr.sin_family = AF_INET;
        // htons: Host to Network Short,将本地主机字节序转为网络字节序
        _addr.sin_port = htons(port);
        _addr.sin_addr.s_addr = INADDR_ANY;
    }

    // 获取主机字节序的端口号
    uint16_t Port()
    {
        return _port;
    }

    // 获取点分十进制字符串 IP
    std::string Ip()
    {
        return _ip;
    }

    // 获取指向底层 sockaddr 结构的指针,用于 sendto 等系统调用
    struct sockaddr *Addr()
    {
        return (struct sockaddr *)&_addr;
    }

    // 获取底层结构体的大小,用于套接字系统调用时的长度参数
    socklen_t AddrLen()
    {
        return sizeof(_addr);
    }

    // 将地址信息转化为易读的字符串格式,如 [127.0.0.1:8080]
    // 常用于打印日志信息
    std::string StringAddress()
    {
        return "[" + _ip + ":" + std::to_string(_port) + "]";
    }

    // 补充功能:重载运算符==,用于在在线用户列表中查找指定客户端
    bool operator==(const InetAddr &who)
    {
        // 由于InetAddr是一个类无法直接==判断相等,只能通过重载==对类中成员变量依次比较间接判断
        if (Ip() == who._ip && Port() == who._port)
        {
            return true;
        }
        else
            return false;
    }

    ~InetAddr()
    {
    }

private:
    // 本地主机格式的地址信息
    uint16_t _port;  // 客户端的端口号
    std::string _ip; // 客户端的IP地址

    // 原始网络格式的地址结构体
    struct sockaddr_in _addr; // 客户端的struct sockaddr_in
};

#endif

核心能力:

  • 内部自动完成主机字节序与网络字节序转换,封装htonsntohs接口,开发者不用手动处理大小端。
  • 封装字符串 IP 地址和 32 位整型 IP 之间的转换,屏蔽inet_addrinet_ntoa底层 API。
  • 对外提供统一获取地址指针、地址长度的接口,可以直接传入 socket 原生系统调用。
  • 重载判等运算符,支持对客户端地址做相等比对,方便客户端标识、查找等业务。

三、服务端头文件完整代码解析(TcpServer.hpp)

接下来写的两个实战案例,不管是 服务端头文件(TcpServer.hpp) 还是 客户端(TcpClient.cc) 都是一样的,也就是都是采用服务端头文件中的Server函数进行回调处理到 服务端主程序(TcpServer.cc) 中的 lambda表达式,再在 lambda表达式 中调用对应不同服务的执行函数进行服务处理。所以我将 服务端头文件(TcpServer.hpp) 以及 客户端客户端(TcpClient.cc) 分开单独展示了。

cpp 复制代码
#ifndef TCPSERVER_HPP
#define TCPSERVER_HPP

#include "Common.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"

#include <signal.h>
#include <sys/wait.h>

#include <pthread.h>

#include "ThreadPool.hpp"

using namespace LogModule;

// 用于回调到上层
using callback_t = std::function<std::string(const std::string &, InetAddr &)>;

static const int backlog = 8;

// 补充知识:服务器往往是严禁拷贝的
// 解决方法:将拷贝构造和赋值拷贝delete即可
// 那就存在一个问题,如果服务器的数量非常多,我们难道就需要对每个服务器都挨个delete吗?
// 其实是不必要的。还记得C++学习继承的时候有一个知识点:
//               派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
// 也就是说如果我们把所有服务器当作子类,让唯一一个父类对其拷贝构造和赋值拷贝delete
// 那么所有子类服务器也就无法进行拷贝了
class TcpServer : public Nocopy
{
public:
    TcpServer(uint16_t port, callback_t func)
        : _port(port),
          _listensockfd(-1),
          _isrunning(false),
          _func(func)
    {
    }

    void Init()
    {
        // signal(SIGCHLD, SIG_IGN); //方法一:忽略SIG_IGN信号(推荐做法)

        // 1.创建套接字
        _listensockfd = socket(AF_INET, SOCK_STREAM, 0);
        if (_listensockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "create socket success: " << _listensockfd;

        // 2.bind众所周知的端口号
        InetAddr local(_port); // 这样借助InetAddr模块中函数重载我们就可以直接构建出struct sockaddr_in
        int n = bind(_listensockfd, local.Addr(), local.AddrLen());
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::INFO) << "bind success";

        // 3.设置socket状态为listen监听
        n = listen(_listensockfd, backlog);
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "listen error";
            exit(LISTEN_ERR);
        }
        LOG(LogLevel::INFO) << "listen success";
    }

    // 优化Server:回调处理服务
    void Server(int sockfd, InetAddr &peer)
    {
        char buffer[1024];
        while (true)
        {
            // 1.先获取信息
            // a.n > 0 ------> 读取成功
            // b.n < 0 ------> 读取失败
            // c.n == 0 ------> 对端把链接关闭了,读取到文件末尾返回0 -- pipe
            ssize_t n = read(sockfd, &buffer, sizeof(buffer) - 1);
            if (n > 0)
            {
                buffer[n] = 0; // 设置为C风格的字符串,n <= sizeof(buffer) - 1
                LOG(LogLevel::DEBUG) << peer.StringAddress() << "# " << buffer;

                // // 2.回复消息
                // std::string echo = "echo# ";
                // echo += buffer;
                // write(sockfd, echo.c_str(), echo.size());

                // 2.回调到上层处理,结果返回拿到翻译结果,再写回给客户端
                std::string echo_string = _func(buffer, peer);
                write(sockfd, echo_string.c_str(), echo_string.size());
            }
            else if (n == 0)
            {
                LOG(LogLevel::DEBUG) << peer.StringAddress() << " 退出了...";
                close(sockfd);
                break;
            }
            else
            {
                LOG(LogLevel::DEBUG) << peer.StringAddress() << " 异常...";
                close(sockfd);
                break;
            }
        }
    }

    class ThreadData
    {
    public:
        ThreadData(int sockfd, InetAddr &addr, TcpServer *tsvr) : _sockfd(sockfd), _addr(addr), _tsvr(tsvr)
        {
        }

        int _sockfd;
        InetAddr _addr;
        TcpServer *_tsvr;
    };

    static void *Routine(void *args)
    {
        // 分离线程防止join阻塞等待
        pthread_detach(pthread_self());
        ThreadData *td = static_cast<ThreadData *>(args);
        td->_tsvr->Server(td->_sockfd, td->_addr);
        delete td;
        return nullptr;
    }

    void Start()
    {
        _isrunning = true;
        while (_isrunning)
        {
            // 1.获取连接
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            int sockfd = accept(_listensockfd, (struct sockaddr *)&peer, &len);
            if (sockfd < 0)
            {
                // 获取连接失败,则继续进行下次获取
                LOG(LogLevel::WARNING) << "accept error";
                continue;
            }
            InetAddr peer_addr(peer);
            LOG(LogLevel::INFO) << "accept success, peer addr: " << peer_addr.StringAddress();

            // 多线程
            pthread_t tid;
            ThreadData *td = new ThreadData(sockfd, peer_addr, this);
            pthread_create(&tid, nullptr, Routine, td);
        }
    }

    ~TcpServer()
    {
    }

private:
    uint16_t _port;
    int _listensockfd; // 监听socket

    bool _isrunning; // 运行状态

    callback_t _func; // 回调参数
};

#endif

TcpServer 是面向业务的 TCP 服务端封装类,基于原生 Socket API 完成服务端完整流程:创建套接字、bind 绑定端口、listen 监听、accept 接收客户端连接,采用多线程模型处理客户端 IO,支持上层业务回调,继承Nocopy禁止拷贝,不允许对象拷贝复制。

源码核心解读:

类成员与类型别名:

  • task_t:为线程池任务类型;
  • callback_t:业务回调类型,实现网络层与业务层解耦;
  • 私有成员:端口_port、监听套接字_listensockfd、服务运行状态_isrunning、业务回调_func

关键成员函数:

  • Init()初始化函数:依次执行 socket 创建套接字、bind 绑定端口、listen 开启监听,系统调用出错直接使用预定义错误码退出。
  • Server()会话处理函数:循环读取客户端数据,调用上层回调处理业务,将结果写回客户端;区分 read 返回的三种情况,处理对端关闭与 IO 异常,关闭套接字结束会话。
  • Start()服务主循环:循环调用 accept 等待新连接;连接到来后构造 ThreadData 参数,创建子线程处理客户端;accept 发生错误仅打印警告,继续循环等待后续连接。
  • ThreadData参数结构体:专门用于向 pthread 线程入口传递参数,保存通信 fd、客户端地址、服务端对象指针。
  • Routine线程入口:使用静态成员适配 pthread_create 函数签名,设置线程分离,调用会话处理函数,处理完成释放堆上 ThreadData,防止内存泄漏。

设计要点(面试重点)

  • 继承 Nocopy 基类,禁止 TcpServer 对象拷贝,规避套接字资源重复释放风险。
  • 线程采用分离属性detach,无需外部调用 pthread_join,线程结束自动回收资源。
  • 通过回调解耦网络与业务:网络模块只负责数据收发,业务逻辑交给外部传入回调。
  • read 返回值三种分支必须完整处理:大于 0 读到数据;等于 0 对端正常关闭;小于 0 IO异常,均要关闭通信 fd。

四、客户端完整代码解析(TcpClient.cc)

cpp 复制代码
#include "Common.hpp"
#include "Log.hpp"
#include "InetAddr.hpp"

using namespace LogModule;

// ./tcpclient ip port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        std::cerr << argv[0] << " ip port" << std::endl;
        exit(USAGE_ERR);
    }
    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);

    // 1.创建套接字
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0)
    {
        std::cerr << "create sockfd error" << std::endl;
        exit(SOCKET_ERR);
    }

    // bind吗?需要bind,需要显式bind吗?不需要也不能!依然是系统随机一个端口号
    // 那我们要做什么?accept?listen?都不需要!
    // 2. 直接向目标服务器发起建立连接的请求connect
    InetAddr server(server_port, server_ip); //本地转网络
    int n = connect(sockfd, server.Addr(), server.AddrLen());
    if(n < 0)
    {
        std::cerr << "connect error" << std::endl;
        exit(CONNECT_ERR);
    }

    // 3.echo client
    while(true)
    {
        std::string line;
        std::cout << "Please Enter# ";
        std::getline(std::cin, line);
        if(line == "quit")
        {
            std::cout << "Client端退出..." << std::endl;
            break;
        }
        write(sockfd, line.c_str(), line.size());

        char buffer[1024];
        ssize_t n = read(sockfd, buffer, sizeof(buffer));
        if(n > 0)
        {
            buffer[n] = 0;
            std::cout << "server echo# " << buffer << std::endl;
        }
    }
    close(sockfd); // 关闭连接
    return 0;
}

三、实战1:多线程通用字典服务

在前面学习 UDP 的时候我们已经实现了一个字典服务,而这次我们依然会复用上次 Dict.hpp 的代码,我们实现这个多线程通用字典服务主要目的还是熟悉TCP的接口使用以及回调处理解耦的思想。

3.1 需求分析

在实际工业级开发中,必须实现网络通信与业务逻辑的解耦 。接下来我们实现 TCP 在线英译汉字典服务,通过回调函数将业务逻辑与网络通信分离;同时实现词典文件读取解析,对外提供单词翻译能力。

  • 服务端启动时,加载本地Dict.txt词典文件,将英文单词与对应的中文翻译、例句存入哈希表;
  • 客户端发送英文单词,服务端收到后查询词典,返回对应的中文翻译与例句;
  • 单词不存在时,返回「未知」提示;
  • 网络通信层与翻译业务层完全解耦,服务端可通过更换回调函数,快速适配其他业务场景。

3.2 词典文件 Dict.txt

3.3 字典类实现 Dict.hpp(复用)

cpp 复制代码
#ifndef DICT_HPP
#define DICT_HPP

#include <iostream>
#include <unordered_map> //用于字典中英文映射
#include "Log.hpp"
#include <string>
#include <fstream> //文件操作

const std::string default_path = "./Dictionary.txt";
const std::string sep = ": "; // 定义分隔符,用于分隔中英文

using namespace LogModule;

#include "InetAddr.hpp"
class Dict
{
public:
    Dict(const std::string &path = default_path) : _dict_path(path)
    {
    }

    // 加载字典:打开文件获取每行字符串通过分隔符分隔中英文放入哈希表_dict
    bool LoadDict()
    {
        std::fstream in(_dict_path);
        if (!in.is_open())
        {
            LOG(LogLevel::DEBUG) << "打开字典" << _dict_path << "错误";
            return false;
        }
        // 文件打开成功
        // 循环获取每行的字符串
        std::string line;
        while (std::getline(in, line)) // 换行符作为分隔每次获取一行字符串
        {
            //"apple: 苹果"
            // 通过分隔符sep将中英文进行分隔
            auto pos = line.find(sep);
            if (pos == std::string::npos)
            {
                // 若pos=npos说明: 分隔符格式存在问题
                LOG(LogLevel::WARNING) << "解析:" << line << "失败";
                continue; // 继续往后获取
            }
            std::string english = line.substr(0, pos); // substr截断字符串范围:[0, pos)
            std::string chinese = line.substr(pos + sep.size());
            if (english.empty() || chinese.empty())
            {
                // 说明当前一行翻译存在缺漏
                LOG(LogLevel::WARNING) << "没有有效内容:" << line;
                continue;
            }

            // 将中英文映射到哈希表中
            _dict.insert(std::make_pair(english, chinese));
            LOG(LogLevel::DEBUG) << "加载:" << line;
        }

        // 最后关闭输入流
        in.close();
        return true;
    }

    // 获取翻译结果函数(传入类InetAddr对象,提取客户端的端口号和IP地址)
    std::string Translate(const std::string word, InetAddr &client)
    {
        auto iter = _dict.find(word);
        if (iter == _dict.end())
        {
            // 查询的单词在字典中不存在
            // LOG(LogLevel::DEBUG) << "[" << client.Ip() << ":" << client.Port() << "]" << "进入到翻译模块: " << word << "->" << "None";
            LOG(LogLevel::DEBUG) << client.StringAddress() << "进入到翻译模块: " << word << "->" << "None";
            return "None";
        }
        // 日志打印是哪个客户端在使用翻译模块
        // LOG(LogLevel::DEBUG) << "[" << client.Ip() << ":" << client.Port() << "]" << "进入到翻译模块: " << word << "->" << iter->second;
        LOG(LogLevel::DEBUG) << client.StringAddress() << "进入到翻译模块: " << word << "->" << iter->second;
        return iter->second;
    }

    ~Dict()
    {
    }

private:
    std::string _dict_path;                             // 路径+文件名
    std::unordered_map<std::string, std::string> _dict; // 使用哈希表映射文件中的中英文
};

#endif

代码解析

  • 采用unordered_map存储单词与释义,查询时间复杂度 O (1),性能极高;
  • 加载文件时做了完善的异常处理,格式错误的行仅打印警告,不影响整体加载;
  • 完全独立于网络逻辑,可在任何 C++ 项目中单独使用,符合单一职责原则。

3.4 服务端主函数 TcpServer.cpp

cpp 复制代码
#include "TcpServer.hpp"
#include "Dict.hpp"

// 回调函数(用于测试代码)
std::string default_handler(const std::string &word, InetAddr &addr)
{
    LOG(LogLevel::DEBUG) << "回调到了default_handler";
    std::string s = "hello, ";
    s += word;
    return s;
}

// 字典翻译
// ./tcpserver port
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        exit(USAGE_ERR);
    }
    uint16_t port = std::stoi(argv[1]);

    // 1.构建翻译模块
    Dict dict;
    // 加载字典
    dict.LoadDict();

    // 传入回调函数
    // std::unique_ptr<TcpServer> server = std::make_unique<TcpServer>(port, default_handler);

    std::unique_ptr<TcpServer> server = std::make_unique<TcpServer>(port, [&dict]
        (const std::string &word, InetAddr &addr)->std::string{
            return dict.Translate(word, addr);
        });

    server->Init();
    server->Start();
    return 0;
}

核心关键点

  1. 回调解耦思想TcpServer只负责 socket 网络收发,完全不关心翻译逻辑;业务逻辑通过外部 lambda 回调注入,网络层调用回调拿到结果再写回客户端。
  2. lambda 捕获注意 :这里用引用捕获&dict ,必须保证dict生命周期长于 TcpServer,本代码中 dict 在 main 栈上,server 在 main 内,生命周期匹配,安全。
  3. 对比方案:既可以传入独立全局函数default_handler,也可以传入 lambda;lambda 可以直接捕获业务对象,不用全局变量,工程上更推荐。

3.5 代码运行展示

四、实战2: 远程命令执行服务

4.1 前置知识:管道与重定向(命令执行的底层原理)

要实现远程命令执行,我们需要先掌握在本地执行命令并获取输出的方法,这离不开管道和重定向。

4.1.1 原生管道:pipe + dup2 + exec

以命令ls -a | grep txt为例,这是 Linux 中非常常见的管道用法,其底层实现完全依赖这三个系统调用。

  • pipe():创建一个管道,返回两个文件描述符pipefd[0](读端)和pipefd[1](写端)。
  • dup2():复制文件描述符,实现重定向。例如dup2(pipefd[1], STDOUT_FILENO)会将进程的标准输出重定向到管道的写端。
  • exec():执行新的程序,替换当前进程的地址空间,但文件描述符会被保留。
完整流程解析:

1、父进程创建管道pipe(pipefd)

2、fork出第一个子进程(执行ls -a):

  • 关闭管道读端close(pipefd[0])
  • 将标准输出重定向到管道写端dup2(pipefd[1], STDOUT_FILENO)
  • 关闭原管道写端close(pipefd[1])
  • exec执行ls -a,其输出会写入管道。

3、fork出第二个子进程(执行grep txt):

  • 关闭管道写端close(pipefd[1])
  • 将标准输入重定向到管道读端dup2(pipefd[0], STDIN_FILENO)
  • 关闭原管道读端close(pipefd[0])
  • exec 执行grep txt,它会从管道中读取ls的输出并过滤。

4.1.2 封装接口:popen / pclose

手动pipe + fork + dup2 + exec流程繁琐,标准库提供了popen来简化这一过程。

cpp 复制代码
#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
  • 用法
    • FILE* fp = popen("ls -l", "r");:以读模式执行命令,命令的标准输出会通过FILE*流返回给父进程。
    • 然后可以用fgets(fp, buf, sizeof(buf))读取命令的输出结果。
  • 配套 :使用完后必须调用pclose(fp),它会调用waitpid等待子进程退出,回收资源,避免产生僵尸进程。

一句话总结popenpipe + fork + exec + shell的封装。

4.2 需求背景与整体设计

我们要实现一个类 SSH 的远程命令执行服务器,核心能力如下:

  • 客户端与服务端建立 TCP 连接后,可发送 Linux 命令字符串
  • 服务端对命令进行安全校验,仅允许执行白名单内的命令
  • 服务端执行命令后,将执行结果返回给客户端
  • 支持多客户端并发连接,每个连接由独立进程处理,互不干扰

整体架构设计:采用分层设计思想,将网络通信与业务处理完全解耦

  • 网络通信层:多进程 TCP 服务器,负责 socket 创建、监听、连接管理、数据收发
  • 业务处理层:命令执行模块,负责命令安全校验、命令执行、结果封装
  • 解耦方案:通过 std::function 回调函数,将业务处理注入到网络层,网络层无需关心业务细节

4.3 业务层:命令执行模块深度解析(Command.hpp)

命令执行模块是业务的核心,负责命令的安全校验与执行,核心解决两个问题:如何在 C++ 中执行 Linux 命令并获取输出、如何避免恶意命令执行的安全风险。

cpp 复制代码
#ifndef COMMAND_HPP
#define COMMAND_HPP

#include <iostream>
#include <string>
#include <cstdio>
#include <set>
#include "InetAddr.hpp"

class Command
{
public:
    Command()
    {
        _white_listcommands.insert("ls");
        _white_listcommands.insert("pwd");
        _white_listcommands.insert("ls -l");
        _white_listcommands.insert("cat Common.hpp");
        _white_listcommands.insert("whoami");
    }

    bool IsSafeCommand(const std::string &cmd)
    {
        auto iter = _white_listcommands.find(cmd);
        return iter != _white_listcommands.end();
    }

    std::string Execute(const std::string &cmd, InetAddr &addr)
    {
        if(!IsSafeCommand(cmd))
        {
            return "输入的命令是禁止执行的";
        }

        // 把标准错误合并到标准输出
        std::string shell_cmd = cmd + " 2>&1";

        FILE *fp = popen(shell_cmd.c_str(), "r");
        // popen内部行为:a.fork() 创建子进程;b.子进程调用 exec 执行传入的 shell 命令;
        // c.创建一条匿名管道,把子进程 stdout/stderr(默认只连通 stdout)重定向到管道;
        // d.返回 FILE*,父进程可以像读文件一样读取命令打印的内容。
        if (fp == nullptr)
        {
            return "创建执行环境失败:" + cmd;
        }

        std::string result;
        char buffer[1024];
        while (fgets(buffer, sizeof(buffer), fp))
        {
            result += buffer;
        }
        pclose(fp);
        return addr.StringAddress() + " execute done, result: \n" + result;
    }

    ~Command()
    {
    }

private:
    // 受限制的远程执行: 约束命令
    std::set<std::string> _white_listcommands;
};

#endif

源码核心解读:

  • 安全设计:白名单优先原则
    • 不采用黑名单机制(无法覆盖所有恶意命令,如 ls && rm -rf / 命令注入),而是采用白名单机制,仅允许执行明确指定的安全命令,从根源避免命令注入风险。
    • 非法命令直接返回 UnSafe,不执行任何系统调用,最大程度保证服务器安全。
  • popen 函数的核心价值
    • popen底层会自动创建管道、fork 子进程、调用 exec 执行 shell 命令,将命令的标准输出重定向到管道中,一行代码完成 shell 调用与结果读取,避免了手动调用 pipe+fork+exec 的繁琐流程。
    • 第二个参数 "r" 表示读取命令输出,若为"w"则表示向命令标准输入写入数据。
    • 必须调用 pclose 关闭管道,否则会产生文件描述符泄漏与僵尸进程。

结果读取逻辑:通过 fgets 循环读取管道中的输出,拼接成完整的结果字符串返回给客户端,缓冲区逐行清空,避免数据残留。

  1. 白名单 vs 黑名单: 黑名单只能拦截已知危险指令,攻击者可以拼接&&|;等 shell 符号构造绕过;白名单只放行预先登记的指令,其余全部拒绝,安全性更高,是远程命令类服务的标准做法。
  2. popen 底层行为: popen会启动 shell 解析字符串,即便用白名单过滤,只要传入的字符串包含 shell 特殊符号,依然存在注入隐患,所以必须在校验阶段直接拦截带特殊符号的输入。
  3. 资源坑点: popen打开的 FILE*,不能用 fclose 关闭,必须 pclosepclose内部会等待子进程退出,回收僵尸进程;使用 fclose 只会关闭文件流,子进程残留,出现 fd 泄漏、大量僵尸进程。
  4. 输出读取边界: fgets按行读取,要循环直到读到 NULL;每次读取完成清空缓冲区,旧数据不要残留,拼接全部输出再返回网络层,给到 TCP 回调。

4.4 网络层:服务端主程序代码(TcpServer.cc

cpp 复制代码
#include "TcpServer.hpp"
#include "Command.hpp"

// 命令执行
// ./tcpserver port
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        exit(USAGE_ERR);
    }
    uint16_t port = std::stoi(argv[1]);

    // 1.创建命令执行模块
    Command command;

    // 传入回调函数
    // std::unique_ptr<TcpServer> server = std::make_unique<TcpServer>(port, default_handler);

    std::unique_ptr<TcpServer> server = std::make_unique<TcpServer>(port, [&command]
        (const std::string &cmd, InetAddr &addr)->std::string{
            return command.Execute(cmd, addr);
        });
    server->Init();
    server->Start();
    return 0;
}

4.5 代码运行展示

五、核心面试考点与实战踩坑指南

5.1 高频面试考点

  • TCP 服务器中,listen 函数的第二个参数 backlog 的含义是什么?
    • backlog 定义了内核中已完成三次握手 的连接队列的最大长度。客户端完成三次握手后的连接会存入该队列,等待服务端调用accept取出。当队列满,新来的客户端连接请求会被内核拒绝。
  • 为什么多线程版本中,线程入口函数必须是静态成员函数?
    • pthread_create要求入口函数签名为void* (*)(void*);普通类成员函数会隐式携带this指针作为第一个参数,函数签名不匹配。
    • 静态成员函数不存在this指针,符合接口签名;再把对象this指针作为参数传入,即可在静态函数内部访问类的成员。
  • 线程池为什么适合短连接,不适合长连接?
    • 线程池工作线程总数固定。长连接场景中一条连接会长期占用一条工作线程;当并发连接超过线程池上限,新连接无法处理,服务直接不可用。
    • 短连接业务任务执行完线程立刻归还线程池,复用于后续任务,线程利用率高。
  • popen 函数和 system 函数的区别是什么?
    • popen内部创建管道,fork 子进程执行 shell 命令;可以读写命令的输入输出,结束后必须调用pclose回收资源。
    • system阻塞当前进程直到命令执行完毕,拿不到命令输出内容,仅能获取进程退出状态码。
    • 二者底层都会fork+exec拉起 shell;popen支持双向交互,适合需要获取命令输出结果的场景。

5.2 实战踩坑与避坑方案

  • 粘包问题
    • 现象 :TCP 是字节流协议,没有消息边界。如果客户端连续发送两次数据,服务端可能一次 read 就读到了两次发送的数据,这就是 "粘包"。

    • 解决:应用层需要自己定义消息边界,常见方案:

      • 定长包:每次都发送固定长度的数据。
      • 分隔符:用特殊字符(如 \n)分隔不同的消息。
      • 长度字段:发送前先发送一个表示后续消息长度的字段,服务端先读长度,再读对应长度的数据。
  • 线程池长连接占用坑
    • 坑点:线程池处理长连接,工作线程被长期占用;线程池耗尽,新连接无法处理。
    • 避坑:线程池优先用于短连接、短任务;长连接场景选用多进程 / 多线程 + IO 多路复用(Reactor)模型。
  • 命令注入安全坑
    • 坑点:黑名单过滤命令,攻击者可以拼接&&;等 shell 符号绕过,执行ls && rm -rf /这类高危指令,服务器被入侵。
    • 避坑:使用白名单机制,只放行预先登记的安全命令,从源头阻断命令注入风险。
  • 文件描述符泄漏坑
    • 坑点:socket 使用完毕没有close;子进程 / 子线程没有关闭不需要的监听套接字,fd 持续泄漏,耗尽系统文件描述符上限,无法新建连接。
    • 避坑:正常、异常分支都要保证套接字关闭;子进程、子线程创建后,立刻关闭自身不需要的监听 socket。
  • 多线程临界区过大坑
    • 坑点:线程池把完整业务任务逻辑放在互斥锁临界区,同一时刻只能有 1 个线程跑任务,线程池退化成单线程,丧失并发能力。
    • 避坑:遵守临界区最小化原则,只在操作共享资源的时候加锁;业务计算、任务执行逻辑放到锁的外面。

结束语

到这里,本篇 TCP 网络编程(下)的内容就全部结束了。我们先完成了一套自研基础组件的封装,将锁、条件变量、线程、日志、网络地址这些高频复用能力抽离出来,体会工程化开发中组件解耦的设计思想;接着完整实现远程命令执行服务器,透过pipedup2exec理解管道与进程程序替换的底层逻辑,也认识到远程命令执行潜藏的安全风险,明白了白名单机制在业务防护中的实际价值。

同时我们梳理了 TCP 字节流带来的各类经典问题,粘包现象、读写不能一次性完成、缓冲区行为、read 返回 0 这些面试高频考点,本质上都来源于 TCP 面向字节流的核心特性。网络编程不能只停留在调用 socket API 完成收发,更要理解内核缓冲区对数据收发带来的种种约束。远程命令执行服务属于多线程并发模型的实战落地,也暴露出多线程版本服务器固有的短板:线程数量受系统资源限制,高并发场景下会出现线程膨胀,无法应对大量客户端同时接入。这也为后续 IO 多路复用的学习埋下伏笔。

相关推荐
陈让然1 小时前
VS Code C/C++ 插件 cpptools 内存占用异常
linux·运维
2401_894915534 小时前
GEO 优化源码全解析:从搜索引擎到 AI 引擎的底层改写逻辑
java·服务器·前端·数据库·人工智能·分布式·搜索引擎
huainingning8 小时前
R4930-G7服务器(RAID-LSI-9560-LP-8i-4GB)网页方式做Raid方法
服务器
ltl10 小时前
QUIC 握手实现:Initial 到 1-RTT 的最小路径
linux
80s77710 小时前
什么是动态IP?静态IP和动态IP有什么区别?
网络·网络协议·tcp/ip
小杨不想秃头11 小时前
信息安全工程师教程第二章密码学
运维·服务器·密码学
I'mChloe11 小时前
WGCLOUD怎么监控多台服务器?从Server、Agent 部署到监控面板实战
运维·服务器
Android系统攻城狮11 小时前
Linux PipeWire深度解析之pw_init调用流程与实战(八十三)
linux·运维·服务器·音频进阶·pipewire音频进阶
M78佐菲12 小时前
Linux学习笔记:进程
linux·笔记·学习·算法