云计算百科
云计算领域专业知识百科平台

04.基于C++实现多线程TCP服务器与客户端通信

基于C++实现多线程TCP服务器与客户端通信

目录

  • 一、项目背景与目标
  • 二、从零开始理解网络通信
  • 三、相关技术背景知识
    • 1. 守护进程(Daemon Process)
    • 2. 线程池(Thread Pool)
    • 3. RAII设计模式
  • 四、项目整体结构与逻辑
  • 五、核心模块详细分析
    • 1. TCP服务器模块
    • 2. 线程池模块
    • 3. 任务处理模块
    • 4. 日志模块
    • 5. 守护进程模块
    • 6. 锁管理模块
  • 六、从实践到理论:关键设计模式与技术
  • 七、进阶主题与扩展思考
  • 八、总结与展望

一、项目背景与目标

在网络编程中,TCP协议因其可靠性和稳定性被广泛应用于各类网络服务。本项目使用C++语言,基于Linux平台实现了一个完整的TCP服务器与客户端通信程序,服务器端采用了线程池技术实现高效并发处理,支持守护进程运行,并实现了完整的日志系统。

本项目的目标是:

  • 掌握TCP协议的基本编程方法
  • 掌握线程池的设计与实现
  • 学习守护进程的创建与管理
  • 掌握日志系统的设计与实现
  • 理解RAII设计模式在资源管理中的应用

二、从零开始理解网络通信

网络通信的本质

想象一下,当你给朋友发送一条短信时,这条信息是如何从你的手机传递到朋友的手机的?这个过程涉及:

  • 你的手机将信息编码
  • 通过无线信号发送到基站
  • 基站将信息路由到目标手机
  • 目标手机接收并解码信息
  • 计算机网络通信也遵循类似的原理,只是更加复杂和规范化。TCP/IP协议就像是计算机之间沟通的"语言规则",确保信息能够正确传递。

    套接字(Socket):网络通信的基础

    套接字可以理解为网络通信的"插座",就像家里的电源插座连接电器一样,套接字连接网络中的应用程序。

    应用程序 <—> 套接字 <—> 网络 <—> 套接字 <—> 应用程序

    在我们的项目中:

    // 创建套接字
    _sock = socket(AF_INET, SOCK_STREAM, 0);

    这行代码就像是安装了一个"网络插座",其中:

    • AF_INET表示使用IPv4地址
    • SOCK_STREAM表示使用TCP协议
    • 0表示使用默认协议

    三、相关技术背景知识

    1. 守护进程(Daemon Process):服务器的"隐形模式"

    想象一下,如果你的手机应用必须保持前台运行才能接收消息,那将是多么不便!守护进程就像是手机的"后台应用",即使你关闭了终端窗口,它仍然在默默工作。

    守护进程的创建过程可以类比为一个员工的"独立":

  • 创建子进程并退出父进程:就像员工从公司分离出来成立自己的工作室
  • 创建新会话:员工不再接受原公司的直接管理
  • 重定向输入输出:员工建立了自己的沟通渠道
  • 更改工作目录:员工搬到了新的办公地点
  • // 创建守护进程的关键步骤
    if (fork() > 0) exit(0); // 父进程退出
    pid_t n = setsid(); // 创建新会话

    2. 线程池(Thread Pool):高效的"工作团队"

    想象一家餐厅:

    • 如果每来一位客人就雇佣一名新服务员,成本会非常高
    • 如果只有一名服务员,客人可能需要长时间等待
    • 最佳方案是维持一个固定数量的服务员团队,随时准备服务新客人

    线程池就是这样的"服务员团队":

    • 预先创建多个线程,等待任务分配
    • 当新任务到来时,从线程池中分配一个空闲线程处理
    • 任务完成后,线程返回池中等待下一个任务

    // 线程池的核心:等待并处理任务
    while (true) {
    T t;
    {
    LockGuard lockguard(td->threadpool->mutex());
    while (td->threadpool->isQueueEmpty()) {
    td->threadpool->threadWait(); // 等待新任务
    }
    t = td->threadpool->pop(); // 获取任务
    }
    t(); // 执行任务
    }

    3. RAII(Resource Acquisition Is Initialization):智能资源管理

    RAII就像是一个自动化的"资源管家"。想象你去图书馆:

    • 进门时,你借了一本书(获取资源)
    • 离开时,你必须归还这本书(释放资源)
    • 如果你忘记归还,图书馆会有麻烦

    RAII确保:

    • 当你"进门"(创建对象)时,自动借书(获取资源)
    • 当你"离开"(对象销毁)时,自动还书(释放资源)
    • 即使发生意外(如异常),也能确保书被归还

    // RAII的典型应用:自动管理锁
    {
    LockGuard lockguard(&_mutex); // 构造时自动加锁
    _task_queue.push(in);
    pthread_cond_signal(&_cond);
    } // 离开作用域时自动解锁

    四、项目整体结构与逻辑

    项目模块关系图

    +————-+
    | tcpServer.cc|
    +——+——+
    |
    v
    +———-+ +——+——-+ +———–+
    | daemon.hpp|<—–| tcpServer.hpp|—–>| Task.hpp |
    +———-+ +——+——-+ +—–+—–+
    | |
    v v
    +——+——-+ +——+——+
    |ThreadPool.hpp|<—-|serviceIO() |
    +——+——-+ +————-+
    |
    v
    +——+——-+
    | Thread.hpp |
    +——+——-+
    |
    v
    +——+——-+
    | LockGuard.hpp|
    +————-+

    项目整体运行流程

    想象一个餐厅的运作流程:

  • 餐厅开业(服务器启动):

    • 准备场地(创建套接字)
    • 挂出营业牌(绑定端口)
    • 组建服务团队(初始化线程池)
    • 开始迎接客人(监听连接)
  • 客人到来(客户端连接):

    • 服务员引导入座(accept接受连接)
    • 分配一名服务员(从线程池分配线程)
    • 开始点餐服务(处理客户端请求)
  • 服务过程(数据交换):

    • 客人点餐(客户端发送数据)
    • 服务员记录并确认(服务器处理并回应)
    • 上菜(服务器返回结果)
  • 就餐结束(连接关闭):

    • 客人离开(客户端断开连接)
    • 服务员清理桌面(关闭socket)
    • 准备服务下一位客人(线程返回池中)
  • 五、核心模块详细分析

    1. TCP服务器模块 (tcpServer.hpp、tcpServer.cc)

    设计思路:建立通信的"桥梁"

    TCP服务器就像是一个电话总机,负责接听来电并将其转接给合适的接线员。其主要职责是:

    • 创建通信渠道(套接字)
    • 公布联系方式(绑定地址和端口)
    • 等待来电(监听连接)
    • 接听并转接(接受连接并提交给线程池)
    关键代码解析

    void initServer() {
    // 1. 创建通信渠道
    _listensock = socket(AF_INET, SOCK_STREAM, 0);

    // 2. 绑定地址和端口(公布联系方式)
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port);
    local.sin_addr.s_addr = INADDR_ANY;
    bind(_listensock, (struct sockaddr *)&local, sizeof(local));

    // 3. 开始监听(等待来电)
    listen(_listensock, gbacklog);
    }

    void start() {
    // 4. 准备接线员团队(初始化线程池)
    ThreadPool<Task>::getInstance()->run();

    for (;;) {
    // 5. 接听来电
    struct sockaddr_in peer;
    socklen_t len = sizeof(peer);
    int sock = accept(_listensock, (struct sockaddr *)&peer, &len);

    // 6. 转接给接线员(提交任务到线程池)
    ThreadPool<Task>::getInstance()->push(Task(sock, serviceIO));
    }
    }

    实现要点与技巧
    • 错误处理的重要性:网络编程中,各种意外情况都可能发生(端口被占用、连接突然断开等)。良好的错误处理能让程序更加健壮。

    • 为什么使用INADDR_ANY:使用INADDR_ANY(值为0.0.0.0)允许服务器监听所有网络接口,无论客户端从哪个网卡连接都能接受。

    • backlog参数的意义:listen(_listensock, gbacklog)中的gbacklog表示等待连接队列的最大长度。当连接请求过多时,超过这个值的连接会被拒绝。

    2. 线程池模块 (ThreadPool.hpp、Thread.hpp)

    设计思路:高效的"工作团队"

    线程池就像一个高效的工作团队:

    • 预先组建团队(创建线程)
    • 统一分配任务(任务队列)
    • 团队成员互相协作(线程同步)
    • 避免重复招聘(线程复用)
    关键代码解析

    // 线程的工作循环
    static void *handlerTask(void *args) {
    ThreadData<T> *td = (ThreadData<T> *)args;
    while (true) {
    T t;
    {
    // 1. 等待任务分配
    LockGuard lockguard(td->threadpool->mutex());
    while (td->threadpool->isQueueEmpty()) {
    td->threadpool->threadWait(); // 没有任务时等待
    }
    // 2. 领取任务
    t = td->threadpool->pop();
    }
    // 3. 执行任务
    t();
    }
    return nullptr;
    }

    // 添加新任务
    void push(const T &in) {
    // 1. 锁定任务队列
    LockGuard lockguard(&_mutex);
    // 2. 添加任务
    _task_queue.push(in);
    // 3. 通知等待的线程
    pthread_cond_signal(&_cond);
    }

    实现要点与技巧
    • 为什么使用条件变量:条件变量允许线程在特定条件满足前进入睡眠状态,避免了忙等待(不断检查条件)带来的CPU资源浪费。

    • 单例模式的优势:整个程序只需要一个线程池实例,单例模式确保了资源的统一管理,避免了重复创建带来的开销。

    • 双重检查锁定:在getInstance()方法中使用双重检查锁定,既保证了线程安全,又避免了每次获取实例都加锁带来的性能损失。

    • 模板设计的灵活性:使用模板设计线程池,使其能够处理不同类型的任务,提高了代码的复用性。

    3. 任务处理模块 (Task.hpp)

    设计思路:统一的任务接口

    任务处理模块就像是一个标准化的"工作指南":

    • 定义了工作内容(处理客户端连接)
    • 提供了统一的执行方式(operator())
    • 封装了具体实现细节(回调函数)
    关键代码解析

    // 具体的任务处理函数
    void serviceIO(int sock) {
    char buffer[1024];
    while (true) {
    // 1. 接收客户端数据
    ssize_t n = read(sock, buffer, sizeof(buffer) 1);
    if (n > 0) {
    // 2. 处理数据
    buffer[n] = 0;
    std::cout << "recv message: " << buffer << std::endl;

    // 3. 准备响应
    std::string outbuffer = buffer;
    outbuffer += " server[echo]";

    // 4. 发送响应
    write(sock, outbuffer.c_str(), outbuffer.size());
    }
    else if (n == 0) {
    // 5. 客户端断开连接
    logMessage(NORMAL, "client quit, me too!");
    break;
    }
    }
    // 6. 关闭连接
    close(sock);
    }

    // 任务封装类
    class Task {
    using func_t = std::function<void(int)>;

    public:
    Task(int sock, func_t func)
    : _sock(sock), _callback(func) {}

    // 统一的任务执行接口
    void operator()() {
    _callback(_sock);
    }

    private:
    int _sock;
    func_t _callback;
    };

    实现要点与技巧
    • 为什么使用std::function:std::function提供了一种类型安全的函数封装,可以存储、复制和调用任何可调用目标(函数、lambda表达式、函数对象等)。

    • 为什么重载operator():重载operator()使Task对象可以像函数一样被调用,符合线程池对任务的要求,同时提供了更清晰的接口。

    • read/write vs recv/send:本项目使用read/write而非recv/send,因为前者更符合Unix “一切皆文件” 的哲学,可以统一处理文件、管道、套接字等I/O操作。

    • 为什么接收用char[]而发送用string:

      • 接收数据时使用固定大小的char[]缓冲区,可以直接与系统调用配合,避免动态内存分配
      • 发送数据时使用string,便于字符串操作(如拼接)
      • 最后通过c_str()转换回C风格字符串进行发送

    4. 日志模块 (log.hpp)

    设计思路:系统的"黑匣子"

    日志系统就像飞机的黑匣子,记录系统运行的各种状态和事件:

    • 不同级别的日志(从调试信息到致命错误)
    • 详细的时间和上下文信息
    • 持久化存储,便于后期分析
    关键代码解析

    void logMessage(int level, const char *format, ...) {
    // 1. 构建日志前缀
    char logprefix[NUM];
    snprintf(logprefix, sizeof(logprefix), "[%s][%ld][pid: %d]",
    to_levelstr(level), (long int)time(nullptr), getpid());

    // 2. 处理可变参数
    char logcontent[NUM];
    va_list arg;
    va_start(arg, format);
    vsnprintf(logcontent, sizeof(logcontent), format, arg);
    va_end(arg);

    // 3. 选择日志文件
    FILE *log = fopen(LOG_NORMAL, "a");
    FILE *err = fopen(LOG_ERR, "a");

    if(log != nullptr && err != nullptr) {
    FILE *curr = nullptr;
    if(level <= WARNING) curr = log;
    else curr = err;

    // 4. 写入日志
    if(curr) fprintf(curr, "%s%s\\n", logprefix, logcontent);

    fclose(log);
    fclose(err);
    }
    }

    实现要点与技巧
    • 可变参数的处理:使用va_list、va_start、va_end和vsnprintf处理可变参数,实现了类似printf的灵活接口。

    • 日志分级的意义:

      • DEBUG:详细的调试信息,帮助开发者理解程序流程
      • NORMAL:正常操作信息,记录系统的正常活动
      • WARNING:警告信息,表示可能的问题但不影响主要功能
      • ERROR:错误信息,表示功能受到影响但系统仍能运行
      • FATAL:致命错误,表示系统无法继续运行
    • 为什么分文件存储:将普通日志和错误日志分开存储,便于快速定位问题,同时避免重要的错误信息被大量普通日志淹没。

    • 时间戳和进程ID:记录时间戳和进程ID,便于在多进程环境下追踪问题,确定事件发生的顺序。

    5. 守护进程模块 (daemon.hpp)

    设计思路:服务器的"隐形模式"

    守护进程就像是系统的"隐形服务员":

    • 脱离用户控制(不依赖终端)
    • 在后台默默工作(不显示输出)
    • 长期稳定运行(不受用户登录状态影响)
    关键代码解析

    void daemonSelf(const char *currPath = nullptr) {
    // 1. 忽略管道破裂信号
    signal(SIGPIPE, SIG_IGN);

    // 2. 创建子进程,父进程退出
    if (fork() > 0)
    exit(0);

    // 3. 创建新会话,脱离控制终端
    pid_t n = setsid();
    assert(n != 1);

    // 4. 重定向标准输入输出
    int fd = open(DEV, O_RDWR);
    if(fd >= 0) {
    dup2(fd, 0); // 标准输入
    dup2(fd, 1); // 标准输出
    dup2(fd, 2); // 标准错误
    close(fd);
    }

    // 5. 更改工作目录
    if(currPath) chdir(currPath);
    }

    实现要点与技巧
    • 为什么忽略SIGPIPE信号:当写入一个已关闭的管道或套接字时,系统会发送SIGPIPE信号,默认处理是终止进程。忽略此信号可以防止服务器因客户端异常断开而崩溃。

    • 为什么使用fork():使用fork()创建子进程,然后父进程退出,使子进程成为孤儿进程,被init进程收养,从而脱离原来的控制终端。

    • setsid()的作用:setsid()创建一个新的会话,使进程成为会话首进程,没有控制终端,不会接收终端相关的信号。

    • 为什么重定向到/dev/null:重定向标准输入输出到/dev/null,确保进程不会因为读写终端而阻塞,同时避免输出信息干扰系统运行。

    6. 锁管理模块 (LockGuard.hpp)

    设计思路:自动化的"资源管家"

    锁管理模块就像是一个自动化的门禁系统:

    • 进入区域时自动上锁(构造函数中加锁)
    • 离开区域时自动解锁(析构函数中解锁)
    • 确保资源安全,避免冲突(线程安全)
    关键代码解析

    class Mutex {
    public:
    Mutex(pthread_mutex_t *lock_p = nullptr): lock_p_(lock_p) {}

    void lock() {
    if(lock_p_) pthread_mutex_lock(lock_p_);
    }

    void unlock() {
    if(lock_p_) pthread_mutex_unlock(lock_p_);
    }

    private:
    pthread_mutex_t *lock_p_;
    };

    class LockGuard {
    public:
    LockGuard(pthread_mutex_t *mutex): mutex_(mutex) {
    mutex_.lock(); // 构造时自动加锁
    }

    ~LockGuard() {
    mutex_.unlock(); // 析构时自动解锁
    }

    private:
    Mutex mutex_;
    };

    实现要点与技巧
    • RAII的优势:使用RAII模式管理锁资源,无需手动解锁,即使发生异常也能确保锁被释放,避免死锁。

    • 分离Mutex和LockGuard:将Mutex和LockGuard分开实现,提高了代码的复用性和灵活性。Mutex封装了底层锁操作,LockGuard提供了RAII风格的接口。

    • 空指针检查:在lock()和unlock()方法中检查指针是否为空,提高了代码的健壮性,避免空指针异常。

    • 使用示例:

      {
      LockGuard guard(&mutex); // 进入作用域,自动加锁
      // 临界区代码…
      } // 离开作用域,自动解锁

    六、从实践到理论:关键设计模式与技术

    1. 单例模式(Singleton Pattern)

    定义:确保一个类只有一个实例,并提供一个全局访问点。

    应用:线程池使用单例模式,确保整个程序只有一个线程池实例。

    优势:

    • 节约系统资源,避免重复创建
    • 提供全局访问点,方便使用
    • 确保所有组件使用同一个实例

    实现:

    static ThreadPool<T> *getInstance() {
    if (nullptr == tp) {
    _singlock.lock();
    if (nullptr == tp) {
    tp = new ThreadPool<T>();
    }
    _singlock.unlock();
    }
    return tp;
    }

    2. 观察者模式(Observer Pattern)的变体

    定义:定义对象间的一种一对多依赖关系,使得当一个对象状态改变时,所有依赖于它的对象都会得到通知。

    应用:线程池中的条件变量机制实际上是观察者模式的一种变体。

    优势:

    • 解耦了任务生产者和消费者
    • 支持一对多的通知机制
    • 提高了系统的灵活性

    实现:

    // 生产者(通知者)
    void push(const T &in) {
    LockGuard lockguard(&_mutex);
    _task_queue.push(in);
    pthread_cond_signal(&_cond); // 通知等待的线程
    }

    // 消费者(观察者)
    while (td->threadpool->isQueueEmpty()) {
    td->threadpool->threadWait(); // 等待通知
    }

    3. 工厂方法模式(Factory Method Pattern)

    定义:定义一个创建对象的接口,但由子类决定要实例化的类是哪一个。

    应用:Task类使用了工厂方法模式的思想,通过回调函数创建不同的任务处理逻辑。

    优势:

    • 将对象的创建与使用分离
    • 支持扩展,可以轻松添加新的任务类型
    • 提高了代码的可维护性

    实现:

    Task(int sock, func_t func)
    : _sock(sock), _callback(func) {}

    void operator()() {
    _callback(_sock); // 调用工厂方法创建的处理逻辑
    }

    七、进阶主题与扩展思考

    1. 性能优化

    连接池:除了线程池,还可以实现连接池,预先建立和维护一组数据库连接,避免频繁创建和销毁连接的开销。

    零拷贝技术:使用sendfile()等系统调用,减少数据在内核空间和用户空间之间的拷贝,提高文件传输效率。

    事件驱动模型:使用epoll、kqueue等I/O多路复用技术,实现高效的事件驱动模型,支持更多并发连接。

    2. 安全性考虑

    输入验证:对客户端输入进行严格验证,防止缓冲区溢出、SQL注入等攻击。

    加密通信:实现SSL/TLS加密,保护数据传输安全。

    资源限制:对连接数、请求频率等进行限制,防止DoS攻击。

    3. 可扩展性设计

    插件系统:设计插件接口,支持动态加载功能模块。

    配置中心:实现集中式配置管理,支持动态配置更新。

    服务发现:集成服务发现机制,支持分布式部署。

    八、总结与展望

    本项目实现了一个完整的TCP服务器与客户端通信系统,涵盖了网络编程、多线程编程、线程池、守护进程、日志系统等多个核心知识点。通过模块化设计和面向对象编程,我们构建了一个结构清晰、功能完善的网络服务框架。

    从这个项目出发,你可以进一步探索:

    • 实现HTTP/WebSocket等应用层协议
    • 集成数据库访问功能
    • 实现负载均衡和高可用设计
    • 探索异步I/O和协程技术

    网络编程是现代软件开发的基础技能,希望这个项目能够帮助你打开网络编程的大门,为你的技术成长提供坚实的基础。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 04.基于C++实现多线程TCP服务器与客户端通信
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!