【Linux网络】从 0 到 1 实现高性能 UDP 聊天室:深入拆解 Linux 网络编程与线程池架构


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


🎬 博主简介:


文章目录

  • 前言:
  • [一. 项目整体架构与核心理论基础](#一. 项目整体架构与核心理论基础)
    • [1.1 UDP 协议核心特性与聊天室选型](#1.1 UDP 协议核心特性与聊天室选型)
    • [1.2 整体架构设计](#1.2 整体架构设计)
  • [二. 基础工具组件:上层架构的基石](#二. 基础工具组件:上层架构的基石)
    • [2.1 线程安全基石:互斥锁与 RAII 守卫(Mutex.hpp)](#2.1 线程安全基石:互斥锁与 RAII 守卫(Mutex.hpp))
    • [2.2 线程间同步:条件变量封装(Cond.hpp)](#2.2 线程间同步:条件变量封装(Cond.hpp))
    • [2.3 线程管理:C++ 封装 POSIX 线程(Thread.hpp)](#2.3 线程管理:C++ 封装 POSIX 线程(Thread.hpp))
    • [2.4 可扩展日志系统:策略模式 + RAII(Logger.hpp)](#2.4 可扩展日志系统:策略模式 + RAII(Logger.hpp))
    • [2.5 网络地址抽象:InetAddr 封装(InetAddr.hpp)](#2.5 网络地址抽象:InetAddr 封装(InetAddr.hpp))
  • [三. 高性能核心:线程池的设计与实现(ThreadPool.hpp)](#三. 高性能核心:线程池的设计与实现(ThreadPool.hpp))
    • [3.1 线程池核心设计思路](#3.1 线程池核心设计思路)
    • [3.2 源码深度解析](#3.2 源码深度解析)
  • [四. 网络通信层:UdpServer 的封装与实现(UdpServer.hpp)](#四. 网络通信层:UdpServer 的封装与实现(UdpServer.hpp))
    • [4.1 UDP 服务端编程核心流程](#4.1 UDP 服务端编程核心流程)
    • [4.2 源码深度解析](#4.2 源码深度解析)
  • [五. 业务核心:消息路由模块 Route(Route.hpp)](#五. 业务核心:消息路由模块 Route(Route.hpp))
  • [六. 服务端主程序:模块串联与启动流程(ChatMain.cpp)](#六. 服务端主程序:模块串联与启动流程(ChatMain.cpp))
  • [七. 客户端实现:双线程全双工通信(ChatClient.cpp)](#七. 客户端实现:双线程全双工通信(ChatClient.cpp))
  • [八. 核心知识点与踩坑避坑指南(有些之前也说过,还是回顾一下)](#八. 核心知识点与踩坑避坑指南(有些之前也说过,还是回顾一下))
    • [8.1 inet_ntoa 的线程安全问题](#8.1 inet_ntoa 的线程安全问题)
    • [8.2 UDP 本地环回不丢包的底层原因](#8.2 UDP 本地环回不丢包的底层原因)
    • [8.3 临界区的粒度控制](#8.3 临界区的粒度控制)
    • [8.4 条件变量的虚假唤醒问题](#8.4 条件变量的虚假唤醒问题)
    • [8.5 关于windows作为client访问Linux小实验(感兴趣的可以看看)](#8.5 关于windows作为client访问Linux小实验(感兴趣的可以看看))
  • 结尾:

前言:

在 Linux 网络编程中,UDP 协议凭借其无连接、低延迟、高吞吐的特性,在实时通信、游戏帧同步、音视频传输等场景中有着不可替代的地位。很多初学者对 UDP 的理解停留在 "简单的发包收包",却很难将其落地到一个完整的工业级项目中。本文将从 0 到 1 带大家实现一个支持多用户并发的高性能 UDP 聊天室,不仅会完整拆解 UDP 网络编程的全流程,还会深入讲解 C++ 面向对象封装、线程池并发架构、设计模式落地、线程安全等核心技术点。读完本文,你不仅能完成一个可运行的聊天室项目,更能建立起 Linux 系统编程与网络编程的完整知识体系。


一. 项目整体架构与核心理论基础

1.1 UDP 协议核心特性与聊天室选型

首先我们先明确 UDP 协议的核心特性,以及为什么聊天室场景适合使用 UDP:

  • 无连接:无需像 TCP 那样经历三次握手建立连接,客户端首次发包即可完成 "上线",服务端无需维护连接状态,极大降低了服务端资源开销。
  • 全双工:同一个 socket 文件描述符可同时进行读写操作,无需像半双工那样收发互斥,非常适合聊天室的收发分离场景。
  • 不可靠性:不保证数据包的有序、无重复、不丢失,这是 UDP 被诟病最多的点,但在局域网 / 本地环回场景下,UDP 几乎不会丢包;同时我们也可以在应用层补充心跳、重传机制来弥补。

对于聊天室场景,UDP 的低延迟、无连接特性完美匹配需求:用户无需复杂的连接建立,发送消息即可上线,服务端只需维护在线用户的地址信息,即可完成消息广播,架构轻量且高效。


1.2 整体架构设计

本次项目采用分层架构设计,将不同职责的模块完全解耦,便于扩展和维护,整体分为 6 大核心模块:

模块层级 核心组件 核心职责
客户端层 ChatClient 双线程实现收发分离,完成用户输入与消息展示
网络通信层 UdpServer 封装 UDP socket 的创建、绑定、数据收发,通过回调解耦业务逻辑
业务路由层 Route 在线用户管理、消息广播、用户上下线处理
并发调度层 ThreadPool 单例模式线程池,实现生产者-消费者模型,异步处理消息路由任务
线程同步层 Mutex、Cond、LockGuard 互斥锁、条件变量的 RAII 封装,保证多线程环境下的临界资源安全
基础工具层 InetAddr、Logger、Thread 网络地址转换、日志系统、POSIX 线程的 C++ 封装

核心数据流转流程

  • 客户端通过sendto向服务端发送聊天消息
  • 服务端 UdpServer 通过recvfrom循环接收消息,获取客户端地址与消息内容
  • UdpServer 将消息、客户端地址、socket 封装为异步任务,推入线程池任务队列
  • 线程池中的工作线程消费任务,调用 Route 模块的路由方法
  • Route 模块检测用户是否在线,不在线则加入在线用户列表,随后将消息广播给所有在线用户
  • 客户端的收消息线程持续recvfrom,将收到的广播消息打印到终端

二. 基础工具组件:上层架构的基石

所有上层业务都依赖底层工具组件的支撑,我们先逐一拆解核心工具类的设计与实现。可以简单看看,有些之前都实现过,看过的可以着重看新的。

2.1 线程安全基石:互斥锁与 RAII 守卫(Mutex.hpp)

多线程编程中,临界资源的安全访问是第一要务。我们对 POSIX 互斥锁进行了封装,并通过RAII(资源获取即初始化) 机制实现锁的自动管理,彻底避免手动加解锁导致的死锁、资源泄漏问题。

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

核心设计解读

  1. Mutex 类 :对原生pthread_mutex_t的完整封装,屏蔽了底层 C 接口的使用细节,同时暴露原始锁指针,用于配合条件变量等原生接口。
  2. LockGuard 类:RAII 机制的核心应用。对象构造时立即加锁,离开作用域时析构函数自动解锁,无论代码正常执行还是异常抛出,都能保证锁被释放,彻底避免了手动解锁的遗漏。
  3. 极简用法:在临界区代码中,只需一行LockGuard lockGuard(&_lock);,即可完成整个作用域的加锁保护,无需关心解锁时机。

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 接口的核心逻辑:这是条件变量最容易踩坑的点。pthread_cond_wait必须与已加锁的互斥锁配合使用,底层会自动完成 "释放锁 - 挂起线程 - 被唤醒后重新加锁" 的原子操作,避免竞态条件。
  • NotifyOne 与 NotifyAll 的选型:单任务入队时使用 NotifyOne,只唤醒一个工作线程,避免惊群效应;线程池关闭时使用 NotifyAll,确保所有线程都能感知状态变更并优雅退出

2.3 线程管理:C++ 封装 POSIX 线程(Thread.hpp)

Linux 下原生 pthread 库是 C 语言接口,无法直接适配 C++ 类成员函数,因此我们对其进行面向对象封装,让线程的创建、启动、停止、资源回收更符合 C++ 编程范式。

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

核心设计解读:

  • 静态成员函数作为线程入口:这是 C++ 封装 pthread 的核心难点。通过静态成员函数 + this 指针传递,实现了 C++ 对象与内核线程的绑定,解决了原生接口与类成员函数的兼容性问题。
  • 泛化任务类型 :使用std::function<void()>包装任务,兼容函数指针、仿函数、lambda 表达式,极大提升了接口的灵活性,后续线程池可直接复用该类型。
  • 线程状态机管理:通过枚举类型管理线程生命周期,避免重复启动、重复停止等非法操作,提升代码健壮性。

2.4 可扩展日志系统:策略模式 + RAII(Logger.hpp)

工业级项目中,日志系统是排查问题、监控运行状态的核心。我们实现了一个支持控制台 / 文件双输出、线程安全、可扩展的日志系统,核心采用策略模式 解耦日志的生成与输出,通过RAII 机制实现日志自动刷新。

cpp 复制代码
// Logger.hpp 核心片段
namespace LogModule
{
    // 日志等级枚举
    enum class LogLevel
    {
        DEBUG, INFO, WARNING, ERROR, FATAL
    };

    // 策略模式基类:日志刷新策略
    class LogStrategy
    {
    public:
        virtual ~LogStrategy() = default;
        virtual void SyncLog(const std::string &message) = 0;
    };

    // 控制台输出策略
    class ConsoleLogStrategy: public LogStrategy
    {
    public:
        void SyncLog(const std::string &message) override
        {
            LockGuard logGuard(&_mutex);
            std::cout << message << std::endl;
        }
    private:
        Mutex _mutex;
    };

    // 文件输出策略
    class FileLogStrategy: public LogStrategy
    {
    public:
        void SyncLog(const std::string &message) override
        {
            LockGuard logGuard(&_mutex);
            std::ofstream out("./log/log.txt", std::ios::app);
            if(out.is_open()) out << message << "\n";
            out.close();
        }
    private:
        Mutex _mutex;
    };

    class Logger 
    {
    public:
        // 内部类:单条日志的组装与自动刷新
        class LogMessage
        {
        public:
            LogMessage(LogLevel level, const std::string &filename, int line, Logger &self)
                : _logger(self)
            {
                // 预组装日志前缀:时间、等级、PID、文件名、行号
                std::stringstream ss;
                ss << "[" << GetTimeStamp() << "] "
                   << "[" << LogLevel2String(level) << "] "
                   << "[" << getpid() << "] "
                   << "[" << filename << ":" << line << "] "
                   << "- ";
                _loginfo = ss.str();
            }

            // RAII核心:语句执行完毕,临时对象析构时自动刷新日志
            ~LogMessage()
            {
                if(_logger._strategy)
                {
                    _logger._strategy->SyncLog(_loginfo);
                }
            }

            // 重载<<运算符,支持链式调用,兼容所有数据类型
            template <typename T>
            LogMessage& operator << (const T& info)
            {
                std::stringstream ss;
                ss << info;
                _loginfo += ss.str();
                return *this;
            }   
        private:
            std::string _loginfo;
            Logger &_logger;
        };

        LogMessage operator() (LogLevel level, const std::string filename, int line)
        {
            return LogMessage(level, filename, line, *this);
        }

        void UseConsoleLogStrategy() { _strategy = std::make_unique<ConsoleLogStrategy>(); }
        void UseFileLogStrategy() { _strategy = std::make_unique<FileLogStrategy>(); }
    private:
        std::unique_ptr<LogStrategy> _strategy;
    };

    // 全局日志对象与便捷宏定义
    Logger logger;
    #define LOG(level) logger(level, __FILE__, __LINE__)
    #define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleLogStrategy()
}

核心设计解读:

  • 策略模式解耦:将日志的输出目的地抽象为LogStrategy基类,后续如需扩展网络日志、数据库日志,只需新增策略子类,无需修改核心日志代码,符合开闭原则。
  • RAII 自动刷新机制 :这是整个日志系统最精妙的设计。通过内部类LogMessage的构造函数组装日志前缀,重载<<运算符拼接内容,析构函数中执行最终刷新 。当我们写LOG(INFO) << "hello world";时,语句执行完毕后临时对象析构,自动完成日志刷新,无需手动调用 flush 接口。
  • 线程安全保证:所有输出操作都加了互斥锁,避免多线程环境下日志内容交织、文件写入错乱。

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

UDP 编程中,我们需要频繁处理sockaddr_in结构体、网络 / 主机字节序转换、IP 地址格式化、用户地址判等操作。因此我们将这些逻辑封装到InetAddr类中,彻底屏蔽底层网络细节。

cpp 复制代码
#ifndef __INETADDR__HPP
#define __INETADDR__HPP

#include <cstdint>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

// 宏定义:将具体的 sockaddr_in 指针强制转换为通用的 sockaddr 指针
// 许多套接字系统调用(如 bind, recvfrom)要求传入通用地址结构
#define CONV(addr) (struct sockaddr*)(addr)

class InetAddr 
{
public:
    // 网络转本地:主要用于接收消息后,解析对端的 IP 和 端口
    InetAddr(struct sockaddr_in &addr): _net_addr(addr)
    {
        // ntohs: Network to Host Short,将 16 位网络字节序(大端)转为主机字节序(通常是小端)
        _port = ntohs(_net_addr.sin_port);
        // inet_ntoa: 将 32 位网络数值 IP 转换为点分十进制的字符串形式
        _ip = inet_ntoa(_net_addr.sin_addr);
    }

    // 本地转网络
    // 可以给引用
    // 这里缺省,我们服务端可以直接传个端口就行,允许任意IP地址, INADDR_ANY
    // 客户端的话我们使用的时候需要指定对应的服务端地址 -- ?
    InetAddr(uint16_t port, const std::string ip = "0.0.0.0")
        : _port(port)
        , _ip(ip)
    {
        // 填充 sockaddr_in 结构体
        _net_addr.sin_family = AF_INET;           // 设置为 IPv4 协议族
        // htons: Host to Network Short,将本地主机字节序转为网络字节序
        _net_addr.sin_port = htons(_port);
        // inet_addr: 将字符串 IP 转换为 32 位网络字节序的数值
        _net_addr.sin_addr.s_addr = inet_addr(_ip.c_str()); 
    }

    // 获取主机字节序的端口号
    uint16_t Port() const { return _port; }
    
    // 获取点分十进制字符串 IP
    std::string Ip() const { return _ip; }

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

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

    // 重载判等运算符:通过 IP 和端口唯一标识一个网络端点
    // 常用于在在线用户列表中查找指定客户端
    bool operator==(const InetAddr &who) const
    {
        return (_ip == who._ip) && (_port == who._port);
    }

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

    ~InetAddr()
    {}

private:
    // 本地主机格式的地址信息
    uint16_t _port;
    std::string _ip;
    
    // 原始网络格式的地址结构体
    struct sockaddr_in _net_addr;
};

#endif

核心设计解读

  • 双构造函数适配双向转换 :两个构造函数分别实现了网络 / 主机字节序的双向转换,彻底屏蔽了ntohshtons等底层接口的使用细节,上层代码无需关心字节序转换。
  • 重载 == 运算符实现用户判等 :UDP 无连接,我们通过IP 地址 + 端口号唯一标识一个客户端,重载 == 运算符后,可直接通过if(user == who)判断用户是否在线,代码可读性极大提升。
  • 原生接口适配 :提供Addr()AddrLen()接口,直接适配bindsendto等原生 socket 接口,无需手动类型强转,避免了类型转换的踩坑。

三. 高性能核心:线程池的设计与实现(ThreadPool.hpp)

单线程 UDP 服务器在高并发场景下会出现严重性能瓶颈:recvfrom接收消息后,同步执行消息路由与广播,会阻塞后续消息接收,导致消息堆积、延迟升高。

因此我们实现了一个单例模式的线程池,采用生产者 - 消费者模型,将消息接收(生产者)与消息处理(消费者)完全解耦,实现异步并发处理,极大提升服务端并发能力(注意:之前看过博主线程池那篇博客的,这个基本上差不多,没啥大改动)。

3.1 线程池核心设计思路

  • 单例模式:整个服务进程中线程池全局唯一,采用懒汉模式 + 双检查锁保证线程安全的实例创建。
  • 生产者 - 消费者模型:UdpServer 主线程作为生产者,将消息处理任务推入队列;工作线程作为消费者,循环提取任务执行。
  • 条件变量同步:任务队列为空时工作线程自动挂起,有新任务时唤醒,避免空轮询消耗 CPU。
  • 优雅退出:线程池关闭时,先修改运行状态,再唤醒所有线程,确保处理完剩余任务后正常退出。

3.2 源码深度解析

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();

            LOG(LogLevel::DEBUG) << "2.Enqueue";
    }

    // 优雅停止线程池
    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

核心设计解读

  1. 模板类实现泛型任务 :线程池采用模板类设计,任务类型T可自定义,本次项目中使用std::function<void()>作为任务类型,兼容任意无参无返回值的可调用对象,灵活性拉满。
  2. 双检查锁实现线程安全懒汉单例
    • 第一层非加锁检查:99% 的场景下实例已存在,直接返回,避免每次调用都加锁的性能损耗。
    • 第二层加锁检查:确保多线程并发首次调用时,只有一个线程能创建实例,彻底避免单例重复创建。
  3. 消费者执行流核心细节
    • 临界区最小化:仅在提取任务时持有锁,任务执行放在锁外,这是线程池实现真正并发的关键。如果任务执行在锁内,同一时间只有一个线程能执行任务,线程池会退化为单线程。
    • while 循环防止虚假唤醒 :条件变量的wait接口可能被操作系统虚假唤醒,必须用 while 循环再次检查条件,而不是 if 判断,这是条件变量使用的铁律。
  4. 休眠线程计数优化 :通过_sleeper_cnt记录当前休眠的线程数量,只有存在休眠线程时才发送唤醒通知,避免无效的系统调用,提升性能。

四. 网络通信层:UdpServer 的封装与实现(UdpServer.hpp)

网络通信层是整个服务端的入口,负责 UDP socket 的创建、绑定、循环接收客户端消息,并通过回调函数将消息处理逻辑解耦,让网络通信与业务逻辑完全分离。

4.1 UDP 服务端编程核心流程

Linux 下 UDP 服务端编程的固定流程:

  1. 创建 socket :调用socket(AF_INET, SOCK_DGRAM, 0)创建 UDP 套接字,返回文件描述符。
  2. 填充地址结构体:设置地址族、端口号(主机转网络字节序)、IP 地址(INADDR_ANY 监听所有网卡)。
  3. 绑定 socket :调用bind将 socket 与地址绑定,让操作系统知道该 socket 对应哪个端口。
  4. 循环收发数据 :调用recvfrom阻塞接收客户端消息,处理后调用sendto发送数据。

4.2 源码深度解析

cpp 复制代码
#ifndef __UDP__ECHOSERVER__HPP
#define __UDP__ECHOSERVER__HPP

#include <cstdint>
#include <string>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <functional>
#include "InetAddr.hpp"
#include "Logger.hpp"

using namespace LogModule;

// 参数就是获得的数据,返回值就是处理完数据的结果
// 这是一种典型的"依赖注入",UdpServer 只负责收数据,具体怎么处理由外部提供的 cb 决定
using callback_t = std::function<void (std::string message, InetAddr who, int sockfd)>;

class UdpServer{
public:
    UdpServer(callback_t cb, uint16_t port)
        : _socketfd(-1)
        , _port(port)
        , _cb(cb)
    {}

    void Init()
    {
        // 1. 创建socket, 系统概念
        // AF_INET: 使用 IPv4 协议
        // SOCK_DGRAM: 使用 UDP 数据报服务(无连接、不可靠)
        _socketfd = socket(AF_INET, SOCK_DGRAM, 0);
        if(_socketfd < 0)
        {
            LOG(LogLevel::FATAL) << "create socketfd error";
        }
        LOG(LogLevel::INFO) << "create socketfd success: " << _socketfd; // 3

        // 2. bind
        // 服务器必须绑定固定端口,否则客户端找不到它
        InetAddr local(_port);
        int n = bind(_socketfd, local.Addr(), local.AddrLen());
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socketfd error";
        }
        LOG(LogLevel::INFO) << "bind socketfd success: " << _socketfd; // 3
    }

    void Start()
    {
        char inbuffer[1024]; // 接收数据的缓冲区
        while(true)
        {
            // perr 是输出型参数,recvfrom 会自动填充发送方的地址信息(IP和端口)
            struct sockaddr_in perr;
            socklen_t len = sizeof(perr);

            // 1. 读取网络数据
            // sizeof(inbuffer) - 1: 预留一个位置放 '\0'
            // (struct sockaddr*)&perr: 记录是谁发来的消息
            ssize_t n = recvfrom(_socketfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)&perr, &len);
            if(n < 0)
            {
                LOG(LogLevel::WARNING) << "recvfrom error";
                break;
            }
            // 手动添加字符串结束符,将字节流转化为 C 风格字符串
            inbuffer[n] = 0;

            // 我们从peer里面拿到的肯定是网络序列,我们这里需要的是主机序列
            // 利用 InetAddr 的构造函数自动完成转换工作
            InetAddr clientAddr(perr);
            // LOG(LogLevel::INFO) << "get a message: " << inbuffer
            //                         << ", client addr: " << clientIp << ":" << clientPort;

            // 处理数据
            // 如果外部注册了回调函数,就将收到的消息、发送方地址和套接字传出去
            if(_cb)
            {
               _cb(inbuffer, clientAddr, _socketfd);
            }

            // 我们现在直接在Route里面进行转发的工作
            // // 2. 发送网络数据
            // // 这个len是个输入输出型参数
            // ssize_t m = sendto(_socketfd, result.c_str(), result.size(),  0, (struct sockaddr*)&perr, len);
            // (void)m;
        }
    }

    ~UdpServer()
    {
        // 只有合法的文件描述符才需要关闭
        if(_socketfd >= 0)
        {
            close(_socketfd);
            _socketfd = -1;
        }
    }
private:
    int _socketfd;       // 服务器的套接字文件描述符
    // std::string _ip; // 可以不需要,通常服务端绑定 INADDR_ANY (0.0.0.0)
    uint16_t _port;      // 服务端监听的端口号

    callback_t _cb;      // 外部传入的任务处理回调
};
#endif

核心设计解读

  • 回调函数解耦网络与业务 :这是整个封装的核心。通过callback_t定义业务处理接口,UdpServer 只负责网络数据收发,不关心具体业务逻辑,业务逻辑通过回调函数注入。后续无论是实现回显服务器、字典服务器还是聊天室,都无需修改 UdpServer 代码,只需更换回调函数即可,符合开闭原则。
  • INADDR_ANY 的最佳实践 :绑定地址时使用INADDR_ANY(0.0.0.0),表示监听本机所有网卡的 IP 地址,无论是本地环回、内网还是公网地址,只要发往该端口的数据包都能被接收,避免了绑定固定 IP 导致的多网卡访问问题。
  • recvfrom 的细节处理 :缓冲区大小设置为sizeof(inbuffer) - 1,预留一个字节补字符串结束符\0,避免缓冲区溢出;len参数必须初始化为sizeof(perr),否则会导致获取的客户端地址不完整。

五. 业务核心:消息路由模块 Route(Route.hpp)

路由模块是聊天室的业务核心,负责在线用户管理、用户上下线检测、消息广播三大核心功能,是整个聊天室业务逻辑的载体。

cpp 复制代码
#ifndef __ROUTE__HPP
#define __ROUTE__HPP

#include <vector>
#include "InetAddr.hpp"
#include "Logger.hpp"
#include "Mutex.hpp"
using namespace LogModule;

class Route 
{
private:
    // 检查指定用户是否已经在管理名单中
    // 本质是通过遍历 vector,利用 InetAddr 重载的 == 运算符进行比对
    bool IsOnline(const InetAddr &who)
    {
        for(auto& user : _users)
        {
            if(user == who)
            {
                return true;
            }
        }
        return false;
    }

    // 将新识别到的客户端地址添加到在线列表
    void Adduser(const InetAddr &who)
    {
        _users.push_back(who);
    }

public:
    Route()
    {}

    // 可以上引用试试
    // 核心转发函数:由线程池中的多个线程并发调用
    void RouteMessage(std::string message, InetAddr who, int sockfd)
    {
        LOG(LogLevel::DEBUG) << "3.RouteMessage";
        // 临界区
        // 其实这样加锁的效率是比较低的, 我们可以采用拷贝的方式优化
        {
            // LockGuard 采用 RAII 风格加锁:构造时自动加锁,作用域结束(大括号结束)时自动解锁
            // 保护临界资源 _users,防止多个线程同时修改或读取 vector 导致崩溃
            LockGuard lockGuard(&_lock);

            // 1.检查用户是否在线上,在的话就不管了,不在的话就加入数组
            if(!IsOnline(who))
            {
                Adduser(who); // 加入数组管理起来
            }

            // 2.转发逻辑 -- 我们在这个里面发
            // 遍历当前的在线用户列表,实现"群聊"效果
            for(auto& user : _users)
            {
                // 协议拼接:将发送者的 IP/端口 与 原始消息组合
                // 最终效果如:[127.0.0.1:8888]# Hello World
                std::string sendMessage = who.StringAddress();
                sendMessage += "# ";
                sendMessage += message;

                // 发送数据到每一位在线用户
                // user.Addr() 获取底层的 sockaddr 结构体
                sendto(sockfd, sendMessage.c_str(), sendMessage.size(), 0, user.Addr(), user.AddrLen());
            }
        } // 大括号结束,lockGuard 析构,锁释放
    }

    ~Route()
    {}

private:
    // 在线用户容器:存储所有发送过消息的客户端地址信息
    std::vector<InetAddr> _users;
    // 互斥锁:用于保证多线程环境下对 _users 操作的原子性
    Mutex _lock;
};
#endif

核心设计解读:

  • 无连接的上线逻辑 :UDP 无连接,因此我们将客户端首次发送消息 视为上线,无需额外的登录请求。通过IsOnline检测用户是否在线,不在则自动加入在线列表,实现极简的上线逻辑。
  • 线程安全的临界区保护 :在线用户列表_users会被多个工作线程同时访问,属于临界资源。通过LockGuard加锁保护整个路由操作,确保同一时间只有一个线程能修改和访问在线用户列表,避免多线程并发导致的迭代器失效、数据错乱。
  • 性能优化思路:当前加锁方式将整个广播逻辑放在临界区内,锁的持有时间较长。优化方案是:在临界区内先将在线用户列表拷贝到临时变量,解锁后再遍历临时变量进行广播,锁的持有时间仅为列表拷贝的时间,极大缩短临界区长度,提升并发性能。



六. 服务端主程序:模块串联与启动流程(ChatMain.cpp)

主程序是整个服务端的入口,负责将底层的网络接收(UdpServer)、中层的并发控制(ThreadPool)以及上层的业务逻辑(Route) 这三大核心模块完美地串联在了一起。,完成服务的初始化与启动。

cpp 复制代码
#include <functional>
#include <memory>
#include "Route.hpp"
#include "UdpServer.hpp"
#include "ThreadPool.hpp"

// 打印程序的正确使用方法,提醒用户需要传入端口号
void Usage(const std::string &name)
{
    std::cerr << "Usage: " << name << " port" << std::endl;
}

using task_t = std::function<void()>; // 定义一个任务:包装成包装器的无参无返回函数

// ./ChatServer 8080
// 我们不直接绑定固定IP,增强服务器的适配性(绑定 0.0.0.0)
int main(int argc, char *argv[])
{
    // 参数检查:必须提供一个端口号
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(0);
    }

    // 初始化日志系统(例如:开启控制台输出)
    ENABLE_CONSOLE_LOG_STRATEGY();

    // std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[1]); // 将命令行输入的字符串端口转为整数
    
    // 1. 创建业务路由模块:负责在线用户管理和广播
    std::unique_ptr<Route> r = std::make_unique<Route>();

    // 方法1: 利用两个lambda表达式 -- 最佳实践
    // 外层 Lambda:作为 UdpServer 的回调。当网络层收到数据包时,主线程(IO线程)会立即执行这里。
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(
        [&r](std::string message, InetAddr who, int sockfd){
            LOG(LogLevel::INFO) << "1. get a message: " << message
                                    << ", client addr: " << who.Ip() << ":" << who.Port();

            // 我们把包装出一个任务,然后入线程池 -- 这个地方有点复杂
            // 内层 Lambda (task):这才是真正要交给线程池处理的逻辑。
            // 为什么要套娃?为了让主线程只负责"发单",让线程池的 Worker 线程负责具体的"跑腿转发"。
            auto task = [&r, &message, &who, &sockfd]()
            {
                // 注意:这里执行的是耗时的网络转发工作
                r->RouteMessage(message, who, sockfd);
            };

            // 典型的生产者-消费者模型:主线程生产任务,Enqueue 入队,线程池消费任务
            ThreadPool<task_t>::GetInstance()->Enqueue(task);
    }, server_port);

    // 2. 初始化服务器:创建 socket,绑定端口
    usvr->Init();

    // 3. 启动服务器:进入 recvfrom 的死循环
    usvr->Start();

    // 方法2: 利用std::bind (另一种实现方式,效果等同于 Lambda)
    // std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(
    //     [&r](std::string message, InetAddr who, int sockfd){
    //         task_t task = std::bind(&Route::RouteMessage, r.get(), message, who, sockfd);
    //         ThreadPool<task_t>::GetInstance()->Enqueue(task);
    // }, server_port);

    return 0;
}

核心设计解读:

  • lambda 表达式实现闭包 :回调函数中使用 lambda 表达式捕获路由模块的智能指针r,并将消息、客户端地址、socket 封装为异步任务,实现了变量的生命周期管理与参数传递。
  • 任务封装与入队 :通过嵌套的 lambda 表达式,将路由方法的调用封装为无参的task_t任务,推入线程池的任务队列,实现了同步接收消息到异步处理消息的转换。
  • 智能指针管理资源 :使用std::unique_ptr管理 Route 和 UdpServer 实例,无需手动释放内存,避免内存泄漏,符合现代 C++ 编程规范。



七. 客户端实现:双线程全双工通信(ChatClient.cpp)

聊天室客户端需要同时完成发送消息接收消息 两个操作,如果使用单线程,会因为cin输入阻塞导致无法及时接收服务端的广播消息。因此我们利用 UDP 的全双工特性,采用双线程设计,一个线程专门负责发送消息,一个线程专门负责接收消息,实现收发完全分离。

cpp 复制代码
// 客户端我们就不封装了,也不使用日志了
#include "InetAddr.hpp"
#include "Thread.hpp"
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>

// 初始化为全局的方便在函数中去使用
int sockfd = -1;
std::string server_ip;
uint16_t server_port = 0;

// 打印命令行使用手册
void Usage(const std::string &name)
{
    std::cerr << "Usage: " << name << " server_ip server_port" << std::endl;
}

// 初始化客户端:申请套接字资源
void InitClient()
{
    // 1. 创建 sockefd
    // 注意:客户端不需要手动 bind,操作系统会在首次 sendto 时自动分配随机端口
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cerr << "create client socketfd error" << std::endl;
        exit(1);
    }
}

// 发送线程:专门负责从键盘读取输入并推送到网络
void sendMessage()
{
    // 2. 构建目标服务器socket信息
    // 利用封装好的类,将服务器的 IP 和 端口 转化为网络字节序结构
    InetAddr server(server_port, server_ip);

    // 3. 发送数据和读取数据
    std::string inbuffer;
    while(true)
    {
        std::cout << "Please Enter# ";
        // 使用 getline 支持包含空格的消息内容
        std::getline(std::cin, inbuffer);

        // (1). 发送数据
        // 通过 sockfd 将数据发往 server 指定的地址
        ssize_t n = sendto(sockfd, inbuffer.c_str(), inbuffer.size(), 0, server.Addr(), server.AddrLen());
        (void)n;
    }
}

// 接收线程:专门负责从网络读取数据并打印到屏幕
void RecvMessage()
{
    while(true)
    {
        // (2). 接收数据
        struct sockaddr_in temp; // 仅作为占位,UDP 接收时需要知道是谁发的(虽然本例中通常是服务端)
        socklen_t tempLen = sizeof(temp);
        char buffer[1024];

        // 阻塞等待网络消息
        ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&temp, &tempLen);
        if(m > 0)
            buffer[m] = 0; // 序列化为 C 字符串

        // 2 打印, 往标准错误打
        // 核心技巧:往 stderr (2) 打,是为了方便在启动时通过重定向(2>/dev/pts/x)分离显示窗口
        std::cerr << buffer << std::endl; 
    }
}

// 程序入口:./UdpEchoClient 127.0.0.1 8080
int main(int argc, char *argv[])
{
    // 校验命令行参数:需要程序名、服务端 IP、服务端端口
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }

    // 得到我们的服务端IP和Port
    server_ip = argv[1];
    server_port = std::stoi(argv[2]);

    // 初始化客户端
    InitClient(); 

    // 创建两个线程,分别绑定发送和接收函数
    // 这样 sendMessage 的 cin 阻塞就不会影响 RecvMessage 的接收
    Thread sender(sendMessage);
    Thread recver(RecvMessage);

    // 启动线程,正式进入全双工通信状态
    sender.start();
    recver.start();

    // 等待线程回收(实际上聊天客户端通常是手动关闭)
    sender.join();
    recver.join();
}

核心设计解读:

  • 双线程实现全双工通信:UDP 协议本身是全双工的,同一个 socket 可以同时进行读写操作。通过两个线程分别负责收发,彻底解决了单线程下输入阻塞导致无法接收消息的问题,实现了真正的实时聊天。
  • 标准错误输出的小技巧 :接收的消息通过std::cerr打印到标准错误流,用户输入的提示语通过std::cout打印到标准输出流。在 Linux 终端中,可通过重定向将标准错误输出到另一个终端,实现输入和输出的完全分离,避免内容交织。
  • 客户端无需显式 bind :UDP 客户端无需像服务端那样显式调用 bind 绑定端口,操作系统会在客户端首次调用 sendto 时,自动为 socket 分配一个随机的空闲端口并完成 bind,这是因为客户端的端口无需固定,而服务端的端口必须固定让客户端访问。


  • 我们这里可以看看如果使用单线程是怎么样的


八. 核心知识点与踩坑避坑指南(有些之前也说过,还是回顾一下)

8.1 inet_ntoa 的线程安全问题

inet_ntoa函数将网络字节序的 IP 地址转换为点分十进制字符串,但其返回的字符串存储在静态分配的缓冲区中 ,后续调用会覆盖之前的结果。在多线程环境下,多个线程同时调用inet_ntoa会导致字符串内容错乱,不是线程安全的函数。

解决方案 :多线程环境下,推荐使用inet_ntop函数,该函数由调用者提供缓冲区存储结果,不会出现静态缓冲区覆盖的问题,是线程安全的。

8.2 UDP 本地环回不丢包的底层原因

本地环回地址(127.0.0.1)通信时,UDP 几乎不会丢包,但真实网络中却会出现丢包,核心原因有 3 点:

  • 无物理链路风险:本地环回通信时,数据包根本没有流向网卡,在内核的 IP 层就被直接截获,重新封装后送回接收方协议栈,没有物理网络的电磁干扰、链路中断等问题。
  • 内存拷贝而非信号传输:本地通信的数据包传递本质是内核内存的拷贝,内存拷贝出错的概率极低,只要接收端缓冲区没有被塞满,数据就不会丢失。
  • 无拥塞与碰撞 :环回接口没有带宽限制,不存在多设备竞争链路的问题,不会因为网络拥塞导致丢
    包。

8.3 临界区的粒度控制

多线程编程中,临界区的粒度控制是性能与安全的平衡点:

  • 粒度过粗:锁的持有时间过长,导致多线程串行执行,并发性能下降,甚至退化为单线程。
  • 粒度过细:频繁的加锁解锁会增加系统调用开销,同时可能因为临界区拆分不当导致线程安全问题。

最佳实践:只对必须互斥访问的临界资源操作加锁,非临界区的耗时操作(如 IO、计算、业务逻辑)必须放在锁外执行。

8.4 条件变量的虚假唤醒问题

条件变量的wait接口可能会被操作系统虚假唤醒(即没有线程调用notifywait却返回了),因此必须使用 while 循环检查条件是否满足,而不是 if 判断。

错误写法:

cpp 复制代码
if(IsEmptyQueue() && _isrunning)
{
    _cond.Wait(_mutex);
}

正确写法:

cpp 复制代码
while(IsEmptyQueue() && _isrunning)
{
    _cond.Wait(_mutex);
}

即使被虚假唤醒,while 循环会再次检查条件,不满足则继续等待,确保逻辑正确性。

8.5 关于windows作为client访问Linux小实验(感兴趣的可以看看)





结尾:

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

结语:

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

相关推荐
咖啡里的茶i1 小时前
实验一 数据库定义
数据库·oracle
basketball6161 小时前
C++ iomanip 常用函数
开发语言·c++
IT 行者1 小时前
Qdrant vs Milvus 向量数据库对比选型指南
数据库·milvus·qdrant
一个数据大开发1 小时前
DB-GPT + StarRocks 实现企业级智能问数:从 Text-to-SQL 到指标治理落地方案
数据库·sql·gpt
赏金术士1 小时前
Kotlin 习题集 · 进阶篇
java·数据库·kotlin
05候补工程师1 小时前
ROS 2 入门:从零实现小海龟 (Turtlesim) 的手动控制与自动化绘圆
运维·经验分享·python·ubuntu·机器人·自动化
sanqima1 小时前
C++里strcpy()拷贝的3种写法
c++·字符串拷贝
努力的小帅1 小时前
Ubuntu 使用 avahi/mDNS 实现固定连接(VSCode Remote SSH + NoMachine)
linux·vscode·ubuntu·ssh·linux入门
艾莉丝努力练剑1 小时前
【Linux网络】Linux 网络编程:应用层自定义协议与序列化(2)序列化与反序列化
linux·运维·服务器·c++·网络协议·序列化