目录
[1. InetAddr.hpp 描述客户端](#1. InetAddr.hpp 描述客户端)
[构造函数1 :](#构造函数1 :)
[构造函数2 :](#构造函数2 :)
[2. UserManager.hpp 组织客户端](#2. UserManager.hpp 组织客户端)
[代码 :](#代码 :)
[AddUser 客户端上线登记](#AddUser 客户端上线登记)
[DelUser 客户端下线处理](#DelUser 客户端下线处理)
[SearchUse 判断客户端是否已在线](#SearchUse 判断客户端是否已在线)
[ModUser 更新用户信息](#ModUser 更新用户信息)
[Users() 获取所有在线用户列表](#Users() 获取所有在线用户列表)
[与 InetAddr 的联动关系:](#与 InetAddr 的联动关系:)
[3. Route.hpp 路由转发模块](#3. Route.hpp 路由转发模块)
[CheckUser 客户端上线登记](#CheckUser 客户端上线登记)
[OfflineUser 客户端下线处理](#OfflineUser 客户端下线处理)
[Broadcast 聊天室核心 ------ 消息广播](#Broadcast 聊天室核心 —— 消息广播)
[4. 引出线程池 ThreadPool.hpp](#4. 引出线程池 ThreadPool.hpp)
[5. UdpServer.hpp 网络模块 + 线程池任务投递](#5. UdpServer.hpp 网络模块 + 线程池任务投递)
[代码 :](#代码 :)
[Init() 创建 socket 并绑定端口](#Init() 创建 socket 并绑定端口)
[RegisterService() 把业务逻辑注册进来](#RegisterService() 把业务逻辑注册进来)
[Start() 主循环](#Start() 主循环)
[6. ChatServerMain.cc](#6. ChatServerMain.cc)
[Route 路由模块:](#Route 路由模块:)
[UdpServer 网络模块:](#UdpServer 网络模块:)
[RegisterService 把业务逻辑 "注册" 到网络层:](#RegisterService 把业务逻辑 “注册” 到网络层:)
[Start() 启动服务,循环接收消息:](#Start() 启动服务,循环接收消息:)
[回调的逻辑 :](#回调的逻辑 :)
[7. Clinent.cc 客户端模块](#7. Clinent.cc 客户端模块)
[8. 运行结果 :](#8. 运行结果 :)
在我们网络编程学习的过程中,我们并不是一下子写出复杂项目,而是一步一步迭代、一层一层升级架构 。从最简单的 UDP 回声服务器开始,到解耦分层的字典翻译服务器,再到今天我们将要实现的多线程并发聊天室服务器,每一次升级,都是为了解决上一版架构暴露的真实问题。
我们先回顾一下最开始的纯回声服务器代码,最开始,我们写的是最简单的 UDP 回声服务器。整个程序只有一个进程、也就是一个主线程。
服务端逻辑非常简单:1. 创建 socket,2. bind 固定端口 8080,3. while 死循环 recvfrom 收数据,4. 收到数据之后,直接原地拼接字符串,5. 原路 sendto 返回。
它的特点是所有代码写在一起,网络逻辑、业务逻辑是耦合在一起的。整个过程是串行执行的,一次只能处理一个客户端。同时缺点就会显现出来 : 1. 不能扩展新功能,想改业务就必须改网络层代码,2. 如果我们想多个客户端进行消息聊天的话也不行。
因为回声服务器太简陋,于是我们进行了第一次大型重构,升级成了字典翻译服务器。我们当时把程序进行了分层:
- 网络层 UdpServer:只负责 socket、bind、循环收数据,不用管理业务
- 业务层 Dict 类:专门负责加载字典、查询单词、返回翻译结果
并且引入回调机制:思想就是网络层不知道自己要做什么业务,外部给什么函数,网络层就回调什么函数,从而实现了网络和业务的解耦,如果换业务的话就不用改网络代码,代码规范、模块化、可扩展。
但是它依然有一个解决不了的硬伤,就是它还是单线程、串行执行!哪怕架构好看了、分层了、用回调了:此时同一时间只能处理一个客户端,不能多人同时聊天,不能广播消息给所有人,一旦业务稍微复杂一点,服务端直接卡住,所以,我们必须进行第二次架构大升级,也就是我们今天要写的:多线程并发聊天室服务器。
一、多线程并发聊天室服务器
设计思路
基于前两版服务器的迭代经验,我们先明确本次聊天室项目的核心目标:不仅要延续网络与业务解耦的设计原则,更要解决单线程串行执行带来的并发瓶颈,实现多客户端同时在线、消息实时广播的群聊功能。
首先,我们先梳理聊天室服务器的核心业务需求。一个合格的 UDP 聊天室,需要具备三个核心能力:一是能够持续接收多个客户端发送的消息,不会因单个客户端的请求阻塞整体服务;二是能够识别每一个客户端的网络身份,区分不同在线用户;三是当收到任意一条客户端消息时,可以将这条消息同步转发给所有当前在线的客户端,实现消息广播。
基于这些需求,我们首先要拆解前两版架构无法满足需求的核心痛点。在回声服务器和字典翻译服务器中,服务端始终采用「单线程串行处理」的运行模式,整个服务生命周期内,仅有一条主线程负责「接收消息 --- 处理业务 --- 返回结果」的全流程。这种模式下,主线程一旦开始执行消息处理或数据转发逻辑,就无法同步接收新的客户端请求。当多个客户端同时发送消息时,请求会在内核缓冲区排队等待,不仅会造成消息延迟,更无法实现 "收到消息后同步广播给所有人" 的核心逻辑,这也是我们必须进行架构升级的根本原因。
明确痛点与需求后,我们开始进行模块拆分与架构设计。我们遵循单一职责原则,将聊天室的核心能力拆解为多个独立模块,每个模块只负责一项核心工作,模块之间通过标准化接口协同工作,既保证代码的可读性与可维护性,也为后续功能扩展预留空间。
第一个需要设计的模块,是客户端用户管理模块 UserManager。要实现消息广播,我们首先需要明确 "要把消息转发给谁"。这个模块的核心职责,就是维护所有在线客户端的网络信息。当新客户端首次向服务端发送消息时,模块会自动登记客户端的 IP 地址与端口信息;当客户端离线后,模块会及时剔除无效的用户信息。同时,模块需要对外提供在线用户列表的查询接口,为后续的消息广播提供精准的转发目标,解决多客户端身份识别与在线状态管理的问题。
第二个需要设计的模块,是路由转发模块 Route。这个模块是聊天室的业务核心,专门处理消息的广播逻辑。它会依赖用户管理模块获取所有在线客户端信息,当收到待转发的消息时,会遍历在线用户列表,通过网络接口将消息逐一发送给每一个客户端。同时,路由模块会封装客户端上线登记的逻辑,与用户管理模块联动,完成新用户的自动注册,将 "用户管理" 与 "消息转发" 两大业务能力串联起来。
第三个需要重点设计的模块,是线程池模块 ThreadPool。解决单线程并发瓶颈的核心,就是实现 "消息接收" 与 "业务处理" 的逻辑分离。我们不再让主线程同时承担收消息和转发消息两项工作,而是将耗时的消息广播、业务处理任务交给线程池中的工作线程并发执行。线程池会维护一组预先创建的工作线程与任务队列,主线程接收消息后,只需将广播任务提交至任务队列,无需等待任务执行完成,便可立即回到消息接收逻辑。线程池中的空闲工作线程会自动争抢队列中的任务,并发完成消息转发,从根本上解决了单线程串行的性能瓶颈。
最后,我们保留并升级原有的 UDP 网络服务模块 UdpServer。延续字典翻译服务器的回调解耦设计,网络层依然只专注于底层网络 IO 操作,负责创建 Socket、绑定端口、循环接收客户端数据。网络层不再绑定任何具体业务逻辑,而是通过注册回调函数的方式,对接用户管理模块与路由转发模块:收到客户端网络信息时,回调触发用户上线登记逻辑;收到客户端消息时,回调将广播任务提交至线程池。网络层、业务模块、线程池三者完全解耦,各司其职。
至此,整个聊天室服务器的架构设计完成:以 UdpServer 作为网络入口,负责纯 IO 消息接收;以 UserManager 作为数据底座,管理在线用户;以 Route 作为业务中枢,实现消息广播逻辑;以 ThreadPool 作为并发引擎,支撑多任务异步执行。各模块协同工作,既延续了前两版项目解耦、模块化的设计优势,又实现了多客户端并发、消息实时广播的核心能力,完成了从单线程服务器到多线程并发服务器的关键升级。
1. InetAddr.hpp 描述客户端
在正式落地聊天室架构之前,我们首先要解决一个聊天室业务带来的问题:聊天室会同时存在多个客户端,我们必须要识别并管理每一个在线用户。毋庸置疑,使用的方法依旧是 先描述再组织。
在之前的回声服务器、字典翻译服务器中,我们的业务逻辑非常简单:服务端收到谁的消息,就原路给谁回复。每次通过 recvfrom 临时拿到客户端地址,不需要记录、区分、长期管理客户端。
但聊天室场景完全不同:同一时间,会有多个客户端同时在线、同时发消息;服务端收到任意一个客户端的消息后,也必须主动转发给所有其他在线用户,实现群聊广播;这就意味着服务端必须记住所有在线客户端的 IP 和端口,知道谁在线、要把消息发给谁。
要做到这一点,我们分两步走:
第一步:先定义单个客户端 ------ 我们需要一个类,把客户端的 IP、端口统一封装起来,方便后续的存储以及对客户端的增删查改,这就是 InetAddr.hpp;
第二步:实现多个客户端的批量管理 ------ 基于封装好的客户端对象,设计用户管理模块,统一处理上线、下线、遍历所有用户,这是 UserManager.hpp 的内容。这个文件我们后面再说。
为什么要先封装 InetAddr.hpp?
因为原生 C 语言的 sockaddr_in 是一个面向系统调用的底层结构体,直接在业务代码里裸用,会带来三个我们在聊天室场景下无法接受的问题,这也是我们必须封装 InetAddr 的核心原因。
1. 网络字节序与主机字节序的转换
我们的电脑 CPU 存储数字用的是主机字节序(小端序),而网络传输协议规定,所有跨主机通信的端口、IP 等整数,必须统一使用网络字节序(大端序)。如果我们不做转换,直接把主机序的端口号填进 sockaddr_in,发送到网络上就会变成乱码,对端无法识别,导致通信会失败。
在之前的简单服务器中,我们每次收发消息时都要手动调用 htons() 、ntohs() 来回转换,代码零散且易出错。而 InetAddr 在构造阶段就一次性完成字节序的封装转换,上层业务后续使用时不用再关心底层字节序差异,从而避免了字节序错误带来的通信异常。
2. 重载 == 相等判断运算符
因为我们要管理大量客户端,核心操作就是判断两个网络地址是否指向同一个用户。原生的 sockaddr_in 是 C 语言结构体,C++ 不会默认帮我们做结构体内容的逐成员比较,直接用 == 对比只会比较内存地址,无法判断两个结构体里的 IP、端口是否完全一致。我们重载 == 运算符,本质就是规定了:两个客户端,只有当 IP 和端口完全相同时,才判定为同一个客户端。
3. 为什么一定要判断两个地址是不是同一个客户端?
这个问题的答案非常明确:为了客户端上线去重。当同一个客户端反复向服务端发送消息时,服务端会多次收到同一个 sockaddr_in 地址。如果我们没有相等判断的能力,就会重复将同一个客户端多次加入在线列表,导致用户列表冗余、广播消息重复发送。
正是因为重载了 ==,我们的用户管理模块才能精准判断:这个客户端是否已经在线?如果已经存在,就不再重复登记;如果是全新客户端,再加入列表。这是实现多客户端在线管理的基础。
综上, InetAddr 封装的核心价值,就是为多客户端聊天室业务提供了一套标准化、可对比、可存储的客户端身份描述方案。解决了底层字节序的繁琐转换、结构体对比的语法缺陷、多客户端身份识别的业务痛点。
有了 InetAddr 可以精准描述单个客户端之后,我们就可以进一步设计 UserManager 用户管理模块,对所有在线客户端进行统一的增删查改,真正实现多用户的批量管理。
前面我们讲清了聊天室场景下,为什么必须对客户端地址进行面向对象封装。现在,我们来落地实现 InetAddr 类,并逐行解析它的设计逻辑。
代码:

头文件依赖解析:每一个头文件都有明确作用:
私有成员变量

我们提供了两个重载的构造函数,分别对应聊天室项目中客户端地址的两种来源:
构造函数1 :
构造函数1的触发场景是当服务端调用 recvfrom 收到客户端消息时,会直接获取一个 sockaddr_in 结构体,此时必须使用这个构造函数,完成客户端地址的初始化。
初始化列表会直接拷贝系统层的 _address 和 _len,先将网络字节序的 IP 转换为字符串 IP,存入_ip,方便可读;再将网络序端口转换为主机序端口,存入_port。
此时后续业务层对比客户端、打印日志时,直接使用 _ip 和 _port,就不用再调用字节序转换函数。
构造函数2 :
构造函数2的触发场景是客户端初始化服务端地址、服务端配置监听地址时使用,通过可读的 IP + 端口反向构建网络地址。
初始化列表先初始化业务层的 _ip 和 _port,直接使用传入的主机序参数;将主机序端口转换为网络字节序端口;再将字符串 IP转换为内核可识别的 4 字节网络字节序 IP。
重载==运算符
原生 C 语言的 sockaddr_in 是结构体,C++ 不会默认重载==,直接对比只会比较内存地址,无法判断结构体内部 IP、端口是否一致,无法用于客户端身份判断。
所以我们需要重载==运算符,重载的逻辑就是只有 IP 地址和端口号同时完全匹配,才判定为同一个客户端。因为 UDP 通信中,IP + 端口是客户端身份的唯一标识。
这是聊天室实现客户端上线去重的底层基础。后续 UserManager 模块判断 "该客户端是否已在线"做铺垫。
ToString()函数:业务日志专用,生成人类可读地址 :
网络调试时,直接打印 sockaddr_in 结构体是乱码,无法识别客户端。这个函数将业务层的 _ip 和_port 拼接为标准格式字符串,日志打印时一眼就能识别客户端身份。后续用户上线日志、消息广播日志、异常调试日志,全部依赖这个接口输出标准化地址,极大降低多客户端场景下的问题排查难度。
GetNetAddress()函数:对接系统调用,暴露底层网络地址:
业务层不需要直接操作系统层变量,但 sendto、bind 等系统调用,必须接收原生 sockaddr_in* 参数。这个函数安全地暴露底层网络地址,供系统调用使用。上层业务只操作InetAddr对象,调用系统调用时通过这个接口获取底层地址,既保持了面向对象封装性,又完美兼容 Linux 网络编程原生接口。
Len()函数:匹配系统调用参数,返回地址长度:
recvfrom、sendto 等系统调用,第二个必传参数就是地址长度。这个函数直接返回预初始化的_len,匹配系统调用参数格式。
析构函数 :
当前类中没有动态内存分配,默认析构即可满足需求,显式声明保证类结构完整性,为后续扩展预留空间。
2. UserManager.hpp 组织客户端
我们通过 InetAddr 类,已经实现了单个客户端身份的标准化封装。但回到聊天室核心业务,我们需要同时管理数十个、上百个在线客户端,要实现:客户端上线自动登记、下线自动删除、消息广播时遍历所有在线用户。
此时新的业务需求直接出现:我们如何批量存储所有 InetAddr 对象?如何快速查询某个客户端是否在线?如何高效遍历所有在线用户?
这就需要在 InetAddr 的基础上,搭建专门的用户管理模块 UserManager.hpp ,实现多客户端的增删查改全生命周期管理,这也是我们下一步要落地的核心模块。
我们之前的回声服务器、字典翻译服务器中,recvfrom 拿到 sockaddr_in 后,直接就用或者直接丢弃了。但在聊天室架构中,我们收到客户端地址后,第一件事就是把原生结构体包装成 InetAddr 对象,这是整个业务逻辑的起点。
代码 :

私有成员变量
我们用 vector 数组作为底层存储的数据结构构造了一个数组 _users,数组里的元素对象就是 InetAddr 对象,没有直接对底层 sockaddr_in 结构体进行使用。
每个 InetAddr 对象,都包含了每个客户端的 IP、端口信息,并且重载了 == 运算符,支持直接对比;后续的增删查改操作,就全部基于每个 InetAddr 元素对象完成,屏蔽了底层网络地址的细节。
AddUser 客户端上线登记
当服务端收到客户端的首次消息时,就需要调用该方法完成上线登记。
先调用 SearchUser 判断该客户端是否已在线;如果已存在,直接返回,避免重复添加;如果是新客户端,将 InetAddr 对象加入 _users 数组中。
DelUser 客户端下线处理
当客户端离线后,调用该方法从在线列表中删除用户。
遍历 _users 列表,用重载 == 运算符对比 InetAddr 对象;找到匹配的客户端后,调用 erase 方法从列表中删除;删除后直接 break,避免无效遍历。遍历过程中,直接用 *iter == addr 判断客户端身份,依赖 InetAddr 重载的 == 运算符实现精准删除。
SearchUse 判断客户端是否已在线
上线登记前的去重判断、消息发送前的在线状态校验。
遍历 _users 列表,用 == 运算符对比 InetAddr 对象,存在则返回 true,否则返回 false。
ModUser 更新用户信息
客户端 IP / 端口变化后,更新用户信息。先删除旧的用户对象,再添加新的用户对象,实现信息更新。
Users() 获取所有在线用户列表
路由转发模块广播消息时,需要遍历所有在线客户端。此时调用这个函数就会返回 _users 列表的引用,供外部模块遍历使用。
返回的数组里的元素,全部都是 InetAddr 对象,外部模块可以直接遍历,调用 GetNetAddress() 和 Len() 方法,直接传给 sendto 系统调用,实现消息广播。
与 InetAddr 的联动关系:
整个 UserManager 模块,建立在 InetAddr 的基础上 :
-
_users 列表的元素类型是 InetAddr,所有客户端身份的描述,全部标准化为 InetAddr 对象;
-
SearchUser、DelUser 方法中,客户端身份的对比,依赖 InetAddr 重载的 == 运算符;
-
所有增删查改方法的入参、出参,都是 InetAddr 对象,中间不需要我们再做其他的转换工作;
-
Users() 方法返回的 InetAddr 列表,供路由转发模块遍历,直接调用 GetNetAddress() 和 Len() 方法,传给 sendto 系统调用,实现消息广播。
3. Route.hpp 路由转发模块
我们通过 InetAddr 标准化了单个客户端身份,又通过 UserManager 实现了所有在线用户的批量管理,至此我们解决了两个核心前置问题:如何描述客户端、如何管理客户端。
现在我们回到聊天室最核心的业务目标:服务端收到任意一条客户端消息后,必须转发给所有在线用户,实现全员广播,从而实现我们的聊天功能。
这个「接收消息 → 校验用户 → 遍历用户列表 → 循环发送广播」的完整业务流程,我们需要单独抽离出来,形成一个专门的业务模块,这就是我们接下来要实现的 Route 路由转发模块。
很多同学这里会和线程池混淆,我们先把边界讲清楚:
✅ Route 模块 = 只负责聊天室业务逻辑 :用户上线登记、消息广播转发,它回答的问题是:收到消息后,业务上具体要做什么。
✅ 线程池 = 只负责并发调度执行 :分配线程、执行任务队列,它回答的问题是:这个业务任务由谁来跑、怎么并发跑、怎么不阻塞主线程。
在架构上,Route 是业务层,线程池是调度层;必须先有业务,才有要调度的任务,所以我们先实现 Route,再引出线程池。
设计思路:
Route 是整个聊天室的业务中枢,承上启下:
-
对上对接网络层 UdpServer:接收网络层传过来的客户端地址和客户端消息;
-
对内对接 UserManager:调用用户管理模块,完成客户端上线登记、获取在线用户列表;
-
对下输出广播动作:封装广播逻辑,形成一个可被调度执行的业务任务。
代码:

头文件依赖
<sys/socket.h>:提供 sendto 系统调用,是实现消息广播的底层依赖。 "UserManager.hpp":Route 的核心依赖,广播逻辑的数据源,提供所有在线用户列表。
"Mutex.hpp":互斥锁头文件,用于保护多线程场景下对 UserManager 的并发读写,避免数据竞争
私有成员变量
_uma 是一个 UserManager 的智能指针,我们前面写 InetAddr 标准化描述一个客户端;然后写 UserManager 用 vector 数组存所有 InetAddr 批量管理客户端;现在的 Route 可以通过 _uma 这个指针,去指挥 UserManager 管理这个用户数组。从而拿到所有在线客户端,完成聊天室的上线登记和消息广播业务。如果没有 _uma 这个指针,Route 就无法访问用户列表,就实现不了广播。
私有成员变量还有一把互斥锁 Mutex _lock ,保护对 UserManager 的所有操作。因为后续我们会引入线程池,多个线程可能同时调用 Route 类里的 CheckUser、Broadcast 等方法,读写 UserManager 里的用户列表,这把锁是实现线程安全的关键。因为引入了锁,所以这个 Route 文件中就得包含 Mutex.hpp 头文件,这个 Mutex.hpp 也是我们之前封装过的。
使用智能指针的优点就是 :
生命周期可控:堆上动态创建销毁,灵活管理对象生存周期。
多态扩展:指针可指向子类实现,未来可灵活替换用户管理策略。
内存安全:unique_ptr自动管理内存,避免栈拷贝开销、内存泄漏。
职责分离:Route 仅通过指针调用接口,不持有数据本体,严格遵循单一职责。
构造函数
当我们在代码中声明一个 Route 对象时,程序会自动调用 Route 的构造函数,无需我们手动执行任何初始化操作。在构造函数内部,通过 std::make_unique<UserManager>() 在堆上创建了一个 UserManager 实例,并让私有成员变量的智能指针 _uma 持有这个对象。也就是说,只要 Route 对象被创建,它内部就会自动生成一个用户管理器,我们后续所有对在线用户的增删查改、消息广播,全部都通过 _uma 这个智能指针来访问和调用。
这样设计的好处十分明确:构造函数完成了对象依赖的自动初始化,Route 本身不负责用户数据的存储,只通过指针调用 UserManager 提供的接口,既实现了模块间的职责分离,又通过智能指针保证了内存安全,避免了手动管理堆内存带来的泄漏风险。
CheckUser 客户端上线登记
当服务端收到一个客户端的消息时,就调用这个函数,把这个客户端登记添加到在线用户数组里。函数通过我们之前讲的智能指针 _uma,调用 UserManager 的 AddUser 方法;把传进来的客户端对象 addr,添加到 UserManager 内部维护的 _users 数组中;
这个函数,就是我们整个聊天室项目里,客户端上线登记的唯一入口。只要客户端给服务端发了第一条消息,就会被记录为在线用户,后续就能收到所有人的广播消息。
那是谁给这个函数传的 addr?
是网络层的 UdpServer 模块。我们回忆 UdpServer 的工作流程:
UdpServer 主线程执行 recvfrom,阻塞等待客户端消息;一旦收到客户端数据,recvfrom 会拿到两个关键信息:客户端发的消息内容、客户端原生的 sockaddr_in 地址;服务端就是把客户端原生的 sockaddr_in 地址,封装成 InetAddr 对象;网络层通过我们之前注册的回调函数,自动调用 CheckUser,并把封装好的 InetAddr 对象,作为 addr 参数传进去。
OfflineUser 客户端下线处理
当客户端长时间不活跃时调用。和 CheckUser 相反,它调用 UserManager 的 DelUser 方法,将客户端从在线列表中移除。
Broadcast 聊天室核心 ------ 消息广播
首先进入函数先执行 LockGuard lockguard(_lock),开启互斥锁保护;通过智能指针 _uma 调用 UserManager 的 Users() 方法,以引用的形式拿到在线的用户数组,赋值给局部变量 users;接下来关键的来了 : 通过 for 循环遍历整个在线用户数组,对每一个 InetAddr 客户端对象,调用 sendto 系统调用,逐条发送聊天消息;这正是聊天室实现全员广播的核心。最后函数执行结束,LockGuard 对象销毁,自动释放互斥锁。
这里一定要明确一点:整个广播动作,是由服务端主动执行发送的。不是客户端互相发消息,而是任意一个客户端发来消息后,网络层把消息交给业务层 Route,由服务端作为中转,统一调用 sendto,循环把这条消息转发给当前所有在线的客户端。
那谁调用了 Broadcast?参数是谁传进来的?
和 CheckUser 一样,Broadcast 也是由网络层 UdpServer 调用的。当服务端收到任意一个客户端发来的聊天消息时:UdpServer 拿到 socket 文件描述符 sockfd 和客户端发来的原始聊天信息 message;直接把这两个参数传入 Broadcast 函数。简单说:只要有人在聊天室发消息,网络层就会调用这个广播函数,把这条消息转发给所有人。
为什么要加锁?
加锁的核心原因是:未来我们会引入线程池,多线程会并发操作这个用户数组,这个用户数组相当于就是共享资源。比如说一个线程正在 Broadcast 遍历用户列表(读操作);同时另一个线程可能正在执行 CheckUser/OfflineUser,对列表进行添加 / 删除的写操作;如果不加锁保护,就会出现:一边遍历、一边修改的并发冲突,直接导致程序崩溃和数据错乱。
我们提前加锁,就是为了保证:同一时刻,用户数组只能被读或者被写,不能同时读写;保证多线程环境下,用户数据的绝对安全。
到这里,我们的业务逻辑已经全部闭环:UdpServer 网络层收到客户端消息 → 调用 Route::CheckUser 完成上线登记 → 调用 Route::Broadcast 遍历所有在线用户完成广播转发。
但此时整个程序依然是单线程串行执行。主线程既要不断调用 recvfrom 等待新消息,又要同步执行 Broadcast 里的循环发送逻辑。当在线用户变多、广播循环耗时变长时,主线程会被长时间阻塞在 sendto 循环里,导致新客户端消息无法及时接收,造成服务卡顿、丢包。
简单来说:业务逻辑我们已经写好了,但业务逻辑太重,继续和 IO 接收挤在同一个线程里,程序性能和稳定性都会被严重限制。
4. 引出线程池 ThreadPool.hpp
解决这个问题的唯一方案,就是引入线程池 ThreadPool。我们不再让主线程执行耗时的广播业务,而是把 Route::Broadcast 这类耗时任务打包成任务,交给线程池里的工作线程去异步并发执行。主线程从此只做一件事:专心接收网络数据;真正的业务计算、消息转发全部交给后台线程处理。
这里关于线程池的具体内容我们就不作太过详细的介绍了,因为在之前的文章中小编已经讲解过了,所以这里我们只是简单回顾一下线程池的基本核心思想 :
线程池,本质就是提前创建好一批固定数量的工作线程,组成一个 "线程池子"。池子初始化时,会一次性创建 N 个工作线程(这里我们创建了 5 个线程),这些线程会先阻塞在任务队列上,等待任务分配;程序运行中,主线程(网络 IO 线程)不再直接执行业务,而是把 Broadcast 这类耗时的广播任务,打包成任务对象,投递进线程池的任务队列;
池子里空闲的工作线程,会自动从队列里取出任务,并发执行;任务执行完毕,线程也不会退出,而会自动回到池子里继续等待下一个任务。
放到我们聊天室项目里,线程池的作用非常明确:把耗时的广播业务,从主线程剥离出去。主线程只专心做一件事:recvfrom 接收网络数据;广播、转发这类耗时操作,全部交给线程池里的后台线程并发执行。
线程池和我们前面写好的模块是完全兼容的,它不会改动 Route、UserManager、InetAddr 的业务逻辑,只是改变了任务的执行方式。
5. UdpServer.hpp 网络模块 + 线程池任务投递
前面我们依次完成了:InetAddr 标准化客户端,UserManager 管理在线用户数组,Route 封装广播业务逻辑,ThreadPool 提供并发执行能力。
UdpServer.hpp
现在我们来到整个项目的最后一个模块:UdpServer。它作为网络端的入口,除了负责创建套接字、阻塞接收客户端消息外,最关键的职责,就是在这里完成业务任务的异步调度。当服务端收到一条聊天消息,我们不再同步调用广播函数阻塞主线程,而是使用 std::bind 将 Route::Broadcast 连同它的入参一起封装成一个可执行任务对象,调用线程池的 PushTask 接口,将任务投递至线程池的任务队列中。主线程投递任务后立即返回,继续专注于网络 IO 接收;后台工作线程则自动竞争获取任务、并发执行广播逻辑。至此,我们彻底实现了IO 接收和业务处理的线程解耦,整个 UDP 聊天室的架构闭环正式完成。
代码 :

先看整体结构

整个类的核心逻辑只有两步:
- Init():创建 UDP socket 并绑定端口,让服务端能监听客户端消息;
- Start():循环调用 recvfrom 接收消息,触发我们注册的业务回调。
回调函数类型定义
这两行定义了两个函数签名:
- handler_addr_t:接收 const InetAddr & 类型参数,无返回值,用来绑定 Route::CheckUser;
- handler_msg_t:接收 int sockfd 和 std::string msg 两个参数,无返回值,用来绑定 Route::Broadcast。
这就是典型的回调解耦 :UdpServer 不直接依赖 Route,只依赖这两个函数签名,后续换成别的业务实现,只要符合这两个签名,就能无缝接入。
Init() 创建 socket 并绑定端口
这里我们用到了前面 InetAddr 的第二个构造函数,直接通过端口号创建服务端本地地址对象,再调用 GetNetAddress() 和 Len() 方法,完美适配 bind 系统调用。
RegisterService() 把业务逻辑注册进来
把业务层的 Route::CheckUser 和 Route::Broadcast 绑定到 UdpServer 上。
Start() 主循环
recvfrom 拿到客户端原生地址 peer,用 InetAddr 封装成标准化客户端对象;调用 _handler_addr(clientaddress),也就是 Route::CheckUser,把用户加入在线列表;
调用 _handler_msg(_sockfd, message),也就是 Route::Broadcast,把消息广播给所有在线用户。
6. ChatServerMain.cc
代码:

创建单例线程池:
这里用的是单例模式,拿到全局唯一的线程池实例;线程池在程序启动时就会创建好一批工作线程,随时准备接任务;后面我们所有的广播任务,都会丢给这个 thread_pool 去处理。
Route 路由模块:
创建一个 Route 对象 r;它的构造函数会自动创建 UserManager,初始化在线用户列表;后面所有的用户上线登记、消息广播,都要通过这个 r 对象来调用。
UdpServer 网络模块:
先创建服务端对象,绑定我们指定的端口;在初始化函数中执行 socket() 创建套接字、bind() 绑定端口,让服务端进入 "监听就绪" 状态。
到这里,三个核心模块都创建好了,但它们之间还没有任何联系。接下来的 RegisterService,就是把它们联系在一起的关键。
RegisterService 把业务逻辑 "注册" 到网络层:
这里我们给 UdpServer 注册了两个回调函数,分别对应:1. 用户上线登记,2. 消息广播转发。
-
第一个回调当 UdpServer 收到客户端消息,拿到 InetAddr 地址后,会自动调用这个回调;直接调用 Route::CheckUser(addr),把客户端加入在线用户列表;这一步是同步执行的,逻辑简单、耗时短,直接在主线程里完成即可。
-
第二个回调就是我们前面说的把广播函数打包成任务、丢进线程池的地方,分两步:
-
打包任务:std::bind(&Route::Broadcast, &r, sockfd, msg) 把 Route::Broadcast 函数,和它需要的参数(&r 路由对象、sockfd 套接字、msg 消息)全部绑定在一起;打包成一个可执行的 task_t 任务对象 t。
-
投递任务:thread_pool->Enqueue(t) 把这个任务对象丢进线程池的任务队列;主线程立刻返回,继续去 recvfrom 收新消息;线程池里的工作线程会自动取出任务,并发执行 Broadcast 广播逻辑。
这里需要特别区分两个极易混淆的 bind:网络编程中用于绑定端口的系统调用 bind(),和 C++ 标准库中用于封装函数与参数的 std::bind()。二者只是同名,功能完全无关。在我们的主函数中,std::bind 的作用是将带参的广播成员函数与运行时参数提前绑定,封装成无参的可执行任务对象,适配线程池的任务队列格式。
Start() 启动服务,循环接收消息:
调用 UdpServer::Start(),进入 while(true) 循环;主线程从此只做一件事:recvfrom 阻塞等待客户端消息;收到消息后,自动触发我们注册好的两个回调:同步执行 CheckUser,登记用户;把 Broadcast 任务丢给线程池异步执行。
回调的逻辑 :
我们把回调的整个逻辑,从 定义 → 注册 → 触发 → 执行,梳理一下 :
定义回调接口
这两行,是给回调函数定 "模板":
handler_addr_t:必须接收一个 const InetAddr & 参数,无返回值
handler_msg_t:必须接收 int 和 std::string 两个参数,无返回值
这相当于给 UdpServer 说:"我只接受符合这两种格式的回调函数"。
在 UdpServer 里预留回调位置
UdpServer 内部定义了两个成员变量,用来存我们注册进来的回调函数。它自己不实现任何业务逻辑,只是预留了两个 "位置",等着别人把函数插进来。
在主函数里注册业务逻辑
我们写了两个 Lambda 函数,正好符合前面定义的两个 handler 格式;
RegisterService 把这两个 Lambda 分别赋值给 _handler_addr 和 _handler_msg;
此时 UdpServer 就知道了:"以后收到消息,我要调用这两个函数"。
存储回调函数
保存回调,把传进来的两个 Lambda 函数,存进 UdpServer 内部的成员变量里,让它以后能调用到。等后面 Start() 里收到消息时,就直接调用这两个存好的函数。
因为 UdpServer 收到消息时,才知道要调用什么函数,但这个函数的实现是主函数里写的;
它不能直接跑到主函数里调用,所以先把函数存到自己的成员变量里;运行时,直接调用自己存好的 _handler_addr 和 _handler_msg,就能执行我们写的业务逻辑了。
UdpServer 自动触发回调
在 UdpServer::Start() 里:当 recvfrom 收到客户端消息后,UdpServer 会做两件事:
调用 _handler_addr(clientaddress) → 也就是注册的第一个 Lambda → 执行 r.CheckUser(addr)
调用 _handler_msg(_sockfd, message) → 也就是注册的第二个 Lambda → 打包任务丢进线程池
回调里的业务逻辑最终执行
第一个回调被 _handler_addr 触发,把客户端地址加入 UserManager 的在线列表。
第二个回调里,线程池取出任务执行,工作线程执行 Broadcast,遍历所有在线用户,完成消息广播。
7. Clinent.cc 客户端模块
前面我们完整实现了服务端,现在来看客户端的代码。它和服务端的逻辑是完全对应的,也是整个聊天室的 "另一半"。
客户端的核心目标只有两个:1. 不断从用户输入获取聊天消息,发给服务端;2. 不断接收服务端广播回来的消息,打印到屏幕上。
为了实现这两个目标,客户端采用了多线程模型:
- 一个线程负责接收服务端消息(RecvMessage);
- 一个线程负责发送用户输入(SendMessage)。
代码:
主函数:初始化与双线程启动
客户端需要知道服务端的 IP 和端口,所以通过命令行参数传入;
客户端的 socket 创建和服务端一样,都是 AF_INET + SOCK_DGRAM;
用两个线程分别处理收发,避免 recvfrom 阻塞影响用户输入。
RecvMessage 线程:接收服务端广播的消息
这个线程循环调用 recvfrom,接收服务端发回来的广播消息;
只要收到消息,就直接打印到控制台,让用户看到其他人发的内容;
这里和服务端的 Broadcast 逻辑是对应的:服务端 sendto 广播,客户端 recvfrom 接收。
SendMessage 线程:发送用户输入给服务端
首先创建 InetAddr 对象,把服务端的 IP 和端口封装起来;
先调用 Online() 发送一条上线消息,让服务端把自己加入在线用户列表;
然后循环获取用户输入,加上自己的昵称,再通过 sendto 发给服务端;
这里和服务端的 CheckUser 逻辑是对应的:客户端发消息触发服务端 CheckUser,把自己加入列表。
Online() :客户端的上线报到函数
Online() 是客户端的上线报到函数。客户端启动后,会主动向服务端发送一条简单消息,消息内容本身并不重要,核心目的是让服务端获取到客户端的地址信息,触发服务端的 CheckUser 回调,将当前客户端添加到在线用户列表中。只有执行了 Online,客户端才算真正加入聊天室,后续才能正常接收服务端的广播消息。它是客户端完成上线、与服务端建立关联的第一步,也是整个 UDP 聊天室通信流程中必不可少的初始化环节。
8. 运行结果 :
1 个服务端终端、3 个客户端终端启动完成;
服务端终端打印了初始化日志:线程池创建成功、socket 创建成功、bind 绑定 8080 端口成功;3 个客户端终端都停留在 Please Set Your Nick Name# 提示处,等待用户输入昵称。
三个客户端依次设置昵称 Liz、Fall、Rein,完成上线;
先上线的 Liz 客户端,收到了后续两个用户 Fall 和 Rein 的上线广播;后上线的 Fall 和 Rein 客户端,只能收到比自己晚上线的用户的上线消息,无法收到之前用户的上线消息;
服务端日志显示,三次上线消息分别被线程池的三个不同工作线程(Slaver-1/2/3)处理。
三个用户都已完成上线,进入群聊状态;
Liz 发送了两条消息 hello ive 和 good eveing,另外两个客户端都成功收到;
Fall 发送了一条 hello,另外两个客户端也都收到;
Rein 发送了一条 hello4,同样被广播给所有客户端;
服务端日志完整记录了每一条消息,并显示每条消息的广播任务都被线程池的不同工作线程处理。
二、完整代码

cpp
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "Thread.hpp"
#include "InetAddr.hpp"
using namespace NS_THREAD_MODULE;
int sockfd = 0;
std::string server_ip;
uint16_t server_port = 0;
std::string nickname;
static void Usage(const std::string &proc)
{
std::cout << "Usage:\n\t";
std::cout << proc << " server_ip server_port" << std::endl;
}
static void Online(InetAddr &serveraddr)
{
std::cout << "Please Set Your Nick Name# ";
std::getline(std::cin, nickname);
std::string online_message = nickname + " online!";
ssize_t n = sendto(sockfd, online_message.c_str(), online_message.size(), 0,
(struct sockaddr *)serveraddr.GetNetAddress(), serveraddr.Len());
(void)n;
}
void RecvMessage()
{
while (true)
{
// recvfrom
char inbuffer[1024] = {0};
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t m = recvfrom(sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&temp, &len);
if (m > 0)
{
inbuffer[m] = 0;
std::cerr << inbuffer << std::endl; // 2
}
}
}
void SendMessage()
{
InetAddr serveraddr(server_port, server_ip);
Online(serveraddr);
while (true)
{
std::string message;
// 1. 获取用户输入
std::cout << "Please Enter# "; // 1
std::getline(std::cin, message);
message = nickname + "# " + message;
// 2. clinet 发送数据给 server,首次发送即自动bind
ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0,
(struct sockaddr *)serveraddr.GetNetAddress(), serveraddr.Len());
(void)n;
}
}
// 我怎么知道server对方的IP和端口啊, 类似IP+Port 是被内置到client的!!!
// ./client_udp server_ip server_port
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
server_ip = argv[1];
server_port = std::stoi(argv[2]);
// 1. 创建socket
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
std::cerr << "socket error" << std::endl;
exit(2);
}
Thread recver(RecvMessage);
Thread sender(SendMessage);
recver.Start();
sender.Start();
recver.Join();
sender.Join();
return 0;
}
cpp
#include "ThreadPool.hpp" // 执行者,执行处理动作的人
#include "Route.hpp" // 任务
#include "UdpServer.hpp" // 获取事件
#include <memory>
static void Usage(const std::string &process)
{
std::cerr << "Usage:\n\t";
std::cerr << process << " local_port" << std::endl;
}
using namespace NS_THREAD_POOL_MODULE;
using task_t = std::function<void()>;
// ./server_udp port
int main(int argc, char *argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(USAGE_ERR);
}
ENABLE_CONSOLE_LOG_STRATEGY();
uint16_t server_port = std::stoi(argv[1]);
// 线程池模块
auto thread_pool = ThreadPool<task_t>::Instance();
// 路由模块
Route r;
// 网络模块
UdpServer usvr(server_port);
usvr.Init();
usvr.RegisterService(
[&r](const InetAddr &addr){
r.CheckUser(addr);
},
[&r, thread_pool](int sockfd, std::string msg){
auto t = std::bind(&Route::Broadcast, &r, sockfd, msg);
thread_pool->Enqueue(t);
}
);
usvr.Start();
return 0;
}
UdpServer.hpp
cpp
#ifndef __ECHOSERVER_HPP
#define __ECHOSERVER_HPP
#include <iostream>
#include <string>
#include <cstdlib>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <functional>
#include <arpa/inet.h>
#include "InetAddr.hpp"
#include "Logger.hpp"
using namespace NS_LOG_MODULE;
const static int default_fd = -1;
const static int default_port = 8888;
using handler_addr_t = std::function<void (const InetAddr &)>;
using handler_msg_t = std::function<void (int sokcfd, std::string msg)>;
enum
{
SUCCESS = 0,
USAGE_ERR,
SOCKET_ERR,
BIND_ERR,
};
class UdpServer
{
public:
// UdpServer(const std::string &ip, uint16_t port = default_port)
UdpServer(uint16_t port = default_port)
: _port(port),
_sockfd(default_fd)
{
}
~UdpServer()
{
close(_sockfd);
}
void Init()
{
// 第一步: 创建socket, 本质: 打开网卡 --- 系统特性
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd < 0)
{
LOG(LogLevel::FATAL) << "create socket error";
exit(SOCKET_ERR);
}
LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;
InetAddr local(_port);
// 第三步:bind socket 信息
int n = bind(_sockfd, (struct sockaddr *)(local.GetNetAddress()), local.Len());
if (n < 0)
{
LOG(LogLevel::FATAL) << "bind socket error";
exit(BIND_ERR);
}
LOG(LogLevel::INFO) << "bind socket success"<< ", port: " << _port;
}
void RegisterService(handler_addr_t handler_addr, handler_msg_t handler_msg)
{
_handler_addr = handler_addr;
_handler_msg = handler_msg;
}
void Start()
{
// 传递的是字符串,echo server
char inbuffer[1024];
while (true)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
// 1. 用户发来的数据
// 2. 用户的socket信息
ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (n > 0)
{
inbuffer[n] = 0;
// 1. 检测新用户
InetAddr clientaddress(peer);
std::string tips = clientaddress.ToString();
std::string message = tips + inbuffer;
LOG(LogLevel::DEBUG) << message;
_handler_addr(clientaddress);
// 2. 转发消息
_handler_msg(_sockfd, message);
}
else
{
LOG(LogLevel::ERROR) << "recvfrom error";
}
// sleep(3);
}
}
private:
int _sockfd;
// std::string _ip; // "192.168.2.2"(字符串风格的点分十进制IP地址, 让人看的) && 4字节IP ???
uint16_t _port; // 用户设置好的,server port必须是固定的!
handler_addr_t _handler_addr;
handler_msg_t _handler_msg;
};
#endif
InetAddr.hpp
cpp
// #pragma once
// #include <iostream>
// #include <string>
// #include <strings.h>
// #include <sys/socket.h>
// #include <netinet/in.h>
// #include <arpa/inet.h>
// // 对客户端进行先描述
// class InetAddr
// {
// public:
// InetAddr(const struct sockaddr_in &address):_address(address), _len(sizeof(address))
// {
// _ip = inet_ntoa(_address.sin_addr);
// _port = ntohs(_address.sin_port);
// }
// InetAddr(uint16_t port, const std::string &ip = "0.0.0.0"):_ip(ip), _port(port)
// {
// bzero(&_address, sizeof(_address));
// _address.sin_family = AF_INET;
// _address.sin_port = htons(_port); // h->n
// _address.sin_addr.s_addr = inet_addr(_ip.c_str()); // 1. 字符串ip->4字节IP 2. hton
// _len = sizeof(_address);
// }
// bool operator == (const InetAddr &addr)
// {
// return (this->_ip == addr._ip) && (this->_port == addr._port);
// }
// std::string ToString()
// {
// return "[" + _ip + ":" + std::to_string(_port) + "]";
// }
// InetAddr()
// {}
// struct sockaddr_in *GetNetAddress()
// {
// return &_address;
// }
// socklen_t Len()
// {
// return _len;
// }
// ~InetAddr()
// {}
// private:
// // net address
// struct sockaddr_in _address;
// socklen_t _len;
// // host address
// std::string _ip;
// uint16_t _port;
// };
#pragma once
#include <iostream>
#include <string>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// 对客户端网络地址进行面向对象封装,标准化描述单个客户端身份
class InetAddr
{
public:
// 构造函数1:从服务端recvfrom获取的原生sockaddr_in结构体初始化
InetAddr(const struct sockaddr_in &address):_address(address), _len(sizeof(address))
{
_ip = inet_ntoa(_address.sin_addr);
_port = ntohs(_address.sin_port);
}
// 构造函数2:通过IP字符串+端口号,反向构建客户端网络地址
InetAddr(uint16_t port, const std::string &ip = "0.0.0.0"):_ip(ip), _port(port)
{
bzero(&_address, sizeof(_address));
_address.sin_family = AF_INET;
_address.sin_port = htons(_port);
_address.sin_addr.s_addr = inet_addr(_ip.c_str());
_len = sizeof(_address);
}
// 重载==运算符:定义「同一个客户端」的判定规则
bool operator== (const InetAddr &addr)
{
return (this->_ip == addr._ip) && (this->_port == addr._port);
}
// 生成日志可读的地址字符串
std::string ToString()
{
return "[" + _ip + ":" + std::to_string(_port) + "]";
}
// 默认空构造函数
InetAddr(){}
// 获取底层网络地址结构体,供系统调用使用
struct sockaddr_in *GetNetAddress()
{
return &_address;
}
// 获取网络地址长度,匹配系统调用参数要求
socklen_t Len()
{
return _len;
}
// 析构函数
~InetAddr(){}
private:
// ========== 底层系统层成员变量:专门给bind、sendto、recvfrom等系统调用使用 ==========
struct sockaddr_in _address;
socklen_t _len;
// ========== 上层业务层成员变量:专门给业务逻辑对比、日志打印、身份判断使用 ==========
std::string _ip;
uint16_t _port;
};
UserManager.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include "InetAddr.hpp"
#include "Logger.hpp"
using namespace NS_LOG_MODULE;
// 增删查改
class UserManager
{
public:
UserManager(){}
void AddUser(const InetAddr &addr)
{
if(SearchUser(addr))
return;
_users.push_back(addr);
}
void DelUser(const InetAddr &addr)
{
// if(!SearchUser(addr))
// return;
for(auto iter = _users.begin(); iter != _users.end(); iter++)
{
if(*iter == addr)
{
_users.erase(iter);
break;
}
}
}
bool SearchUser(const InetAddr &addr)
{
for(auto &user : _users)
{
if(user == addr)
{
return true;
}
}
return false;
}
bool ModUser(const InetAddr &addr)
{
// 简单一点
DelUser(addr);
AddUser(addr);
return true;
}
std::vector<InetAddr> &Users()
{
return _users;
}
~UserManager(){}
private:
// "ip:port" -> InetAddr
std::vector<InetAddr> _users;
};
Route.hpp
cpp
#pragma once
#include <iostream>
#include <memory>
#include <string>
#include <sys/socket.h>
#include "Mutex.hpp"
#include "UserManager.hpp"
class Route
{
public:
Route():_uma(std::make_unique<UserManager>())
{}
void CheckUser(const InetAddr &addr)
{
LockGuard lockguard(_lock);
_uma->AddUser(addr);
}
void OfflineUser(const InetAddr &addr)
{
LockGuard lockguard(_lock);
_uma->DelUser(addr);
}
void Broadcast(int sockfd, std::string message)
{
LockGuard lockguard(_lock);
auto &users = _uma->Users();
for(auto &user : users)
{
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)user.GetNetAddress(), user.Len());
}
}
// void Sendto(int sockfd, std::string message, InetAddr & who)
// {
// }
~Route()
{}
private:
std::unique_ptr<UserManager> _uma;
// std::queue<std::string> _q;
Mutex _lock;
};
Makefile
cpp
.PHONY:all
all:client_chat server_chat
server_chat:ChatServerMain.cc
g++ -o $@ $^ -std=c++17
client_chat:ChatClient.cc
g++ -o $@ $^ -std=c++17 -static
.PHONY:clean
clean:
rm -f client_chat server_chat
Cond.hpp
cpp
#ifndef __COND_HPP
#define __COND_HPP
#include <pthread.h>
#include "Mutex.hpp"
class Cond
{
public:
Cond()
{
pthread_cond_init(&_cond, nullptr);
}
void Wait(Mutex &mutex)
{
int n = pthread_cond_wait(&_cond, mutex.Ptr());
(void)n;
}
void Signal()
{
int n = pthread_cond_signal(&_cond);
(void)n;
}
void Broadcast()
{
int n = pthread_cond_broadcast(&_cond);
(void)n;
}
~Cond()
{
pthread_cond_destroy(&_cond);
}
private:
pthread_cond_t _cond;
};
#endif
Mutex.hpp
cpp
#pragma once
#include <iostream>
#include <pthread.h>
class Mutex
{
public:
Mutex()
{
pthread_mutex_init(&_lock, nullptr);
}
void Lock()
{
pthread_mutex_lock(&_lock);
}
pthread_mutex_t *Ptr()
{
return &_lock;
}
void Unlock()
{
pthread_mutex_unlock(&_lock);
}
~Mutex()
{
pthread_mutex_destroy(&_lock);
}
private:
pthread_mutex_t _lock;
};
class LockGuard // RAII风格代码
{
public:
LockGuard(Mutex &lock):_lockref(lock)
{
_lockref.Lock();
}
~LockGuard()
{
_lockref.Unlock();
}
private:
Mutex &_lockref;
};
Thread.hpp
cpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
#include <pthread.h>
namespace NS_THREAD_MODULE
{
static int gnumber = 1;
using callback_t = std::function<void()>;
enum class TSTATUS
{
THREAD_NEW,
THREAD_RUNNING,
THREAD_STOP
};
std::string Status2String(TSTATUS s)
{
switch (s)
{
case TSTATUS::THREAD_NEW:
return "THREAD_NEW";
case TSTATUS::THREAD_RUNNING:
return "THREAD_RUNNING";
case TSTATUS::THREAD_STOP:
return "THREAD_STOP";
default:
return "UNKNOWN";
}
}
std::string IsJoined(bool joinable)
{
return joinable ? "true" : "false";
}
class Thread
{
private:
void ToRunning()
{
_status = TSTATUS::THREAD_RUNNING;
}
void ToStop()
{
_status = TSTATUS::THREAD_STOP;
}
static void *ThreadRoutine(void *args)
{
Thread *self = static_cast<Thread *>(args);
pthread_setname_np(self->_tid, self->_name.c_str());
self->_cb();
self->ToStop();
return nullptr;
}
public:
Thread(callback_t cb)
: _tid(-1), _status(TSTATUS::THREAD_NEW), _joinable(true), _cb(cb), _result(nullptr)
{
_name = "Slaver-" + std::to_string(gnumber++);
}
bool Start()
{
int n = pthread_create(&_tid, nullptr, ThreadRoutine, this);
if (n != 0)
return false;
ToRunning();
return true;
}
void Join()
{
if (_joinable)
{
int n = pthread_join(_tid, &_result);
if (n != 0)
{
std::cerr << "join error: " << n << std::endl;
return;
}
(void)_result;
_status = TSTATUS::THREAD_STOP;
}
else
{
std::cerr << "error, thread join status: " << IsJoined(_joinable) << std::endl;
}
}
// 暂停
// void Stop() // restart()
// {
// // 让线程暂停
// }
void Die()
{
if (_status == TSTATUS::THREAD_RUNNING)
{
pthread_cancel(_tid);
_status = TSTATUS::THREAD_STOP;
}
}
void Detach()
{
if (_status == TSTATUS::THREAD_RUNNING && _joinable)
{
pthread_detach(_tid);
_joinable = false;
}
else
{
std::cerr << "detach " << _name << " failed" << std::endl;
}
}
void PrintInfo()
{
std::cout << "thread name : " << _name << std::endl;
std::cout << "thread _tid : " << _tid << std::endl;
std::cout << "thread _status : " << Status2String(_status) << std::endl;
std::cout << "thread _joinable : " << IsJoined(_joinable) << std::endl;
}
~Thread()
{
}
private:
std::string _name;
pthread_t _tid;
TSTATUS _status;
bool _joinable;
// 线程要有自己的任务处理,即回调函数
callback_t _cb;
// 线程退出信息
void *_result;
};
}
THreadPool.hpp
cpp
#pragma once
#include <iostream>
#include <vector>
#include <queue>
#include "Logger.hpp"
#include "Thread.hpp"
#include "Mutex.hpp"
#include "Cond.hpp"
namespace NS_THREAD_POOL_MODULE
{
using namespace NS_LOG_MODULE;
using namespace NS_THREAD_MODULE;
const int defaultnum = 5;
// 线程池要不要对多个线程进行管理呢??
// 先描述,在组织!
template <typename T>
class ThreadPool
{
private:
void HandlerTask()
{
char name[128];
pthread_getname_np(pthread_self(), name, sizeof(name));
while (true)
{
T task;
{
// 保护临界区
LockGuard lockguard(_mutex);
// 检测任务。不休眠:1. 队列不为空 2. 线程池退出 -> 队列为空 && 线程池不退出
while (_tasks.empty() && _isrunning)
{
// 没有任务, 休眠
_slaver_sleep_count++;
_cond.Wait(_mutex);
_slaver_sleep_count--;
}
// 线程池退出了-> while 就要break -> 不能
// 1. 线程池退出 && _tasks empty
if (!_isrunning && _tasks.empty())
{
_mutex.Unlock();
break;
}
// 有任务, 取任务,本质:把任务由公共变成私有
// T -> task*
task = _tasks.front();
_tasks.pop();
}
// 处理任务, 约定
// 处理任务需要再临界区内部处理吗?不需要
LOG(LogLevel::INFO) << name << "处理任务:";
task();
}
// 线程退出
LOG(LogLevel::INFO) << name << " quit...";
}
ThreadPool(int slaver_num = defaultnum) : _isrunning(false), _slaver_sleep_count(0), _slaver_num(slaver_num)
{
// ThreadPool对象已经存在了
for (int idx = 0; idx < _slaver_num; idx++)
{
_slavers.emplace_back([this]()
{ this->HandlerTask(); });
}
}
// 赋值 拷贝构造禁止
ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;
ThreadPool(const ThreadPool<T> &) = delete;
public:
// 如果多线程获取这个单例呢.加锁
// 多线程安全了,但是效率比较低,双if判断
static ThreadPool<T> *Instance()
{
if(nullptr == _instance) // 双if判断
{
// 多线程
LockGuard lockguard(_lock);
if (nullptr == _instance)
{
// 第一次调用
_instance = new ThreadPool<T>();
_instance->Start();
LOG(LogLevel::INFO) << "第一次使用线程池,创建线程池对象";
}
}
return _instance;
}
void Start()
{
if (_isrunning)
{
LOG(LogLevel::WARNING) << "Thread Pool Is Already Running";
return;
}
_isrunning = true;
for (auto &slave : _slavers)
{
slave.Start();
}
}
void Stop()
{
// 1. _isrunning = false
// 2. 处理完成tasks所有的任务
// 线程状态: 休眠,正在处理任务 -> 让所有线程全部唤醒
// HandlerTask自动break
_mutex.Lock();
_isrunning = false;
if (_slaver_sleep_count > 0)
_cond.Broadcast();
_mutex.Unlock();
}
void Wait()
{
for (auto &slave : _slavers)
{
slave.Join();
}
}
void Enqueue(T in)
{
_mutex.Lock();
_tasks.push(in);
if (_slaver_sleep_count > 0)
_cond.Signal();
_mutex.Unlock();
}
~ThreadPool()
{
}
private:
bool _isrunning;
int _slaver_num;
std::vector<Thread> _slavers;
std::queue<T> _tasks; // 任务队列,临界资源
Mutex _mutex;
Cond _cond;
int _slaver_sleep_count;
// 添加单例模式
static ThreadPool<T> *_instance;
static Mutex _lock; // 保证单例的安全
};
template <typename T>
ThreadPool<T> *ThreadPool<T>::_instance = nullptr;
template <typename T>
Mutex ThreadPool<T>::_lock;
}
三、总结
至此,我们基于 UDP 协议实现的多线程、高可用、异步广播聊天室项目已全部开发、讲解与验证完成。
本项目从网络基础、模块化设计、线程池异步、回调解耦、并发安全等多个角度,完整构建了一套轻量但结构清晰的服务端 + 客户端通信系统。我们通过 UdpServer 实现网络数据收发,通过 InetAddr 统一地址封装,通过 UserManager 管理在线用户,通过 Route 封装业务逻辑,通过 ThreadPool 实现任务异步执行,最终在主函数中将所有模块 "粘合",形成一套可直接运行、可扩展、可维护的完整项目。
从代码编写到原理讲解,从模块拆分到整体串联,从运行测试到现象分析,我们一步步走完了一个真实后端小项目的完整生命周期。它不仅是一个练手项目,更是对 网络编程、多线程、异步任务、模块化思想 最直观、最落地的实践。
希望这篇完整的讲解,能帮助你真正理解 UDP 聊天室的运行原理、回调的本质、线程池的作用,以及服务端与客户端之间的完整通信链路。未来无论学习 TCP、HTTP、后端框架,还是分布式服务,这套基础思想都将成为你最扎实的根基。
谢谢大家的观看!




网络调试时,直接打印 sockaddr_in 结构体是乱码,无法识别客户端。这个函数将业务层的 _ip 和_port 拼接为标准格式字符串,日志打印时一眼就能识别客户端身份。后续用户上线日志、消息广播日志、异常调试日志,全部依赖这个接口输出标准化地址,极大降低多客户端场景下的问题排查难度。
业务层不需要直接操作系统层变量,但 sendto、bind 等系统调用,必须接收原生 sockaddr_in* 参数。这个函数安全地暴露底层网络地址,供系统调用使用。上层业务只操作InetAddr对象,调用系统调用时通过这个接口获取底层地址,既保持了面向对象封装性,又完美兼容 Linux 网络编程原生接口。

我们用 vector 数组作为底层存储的数据结构构造了一个数组 _users,数组里的元素对象就是 InetAddr 对象,没有直接对底层 sockaddr_in 结构体进行使用。

上线登记前的去重判断、消息发送前的在线状态校验。

<sys/socket.h>:提供 sendto 系统调用,是实现消息广播的底层依赖。 "UserManager.hpp":Route 的核心依赖,广播逻辑的数据源,提供所有在线用户列表。
_uma 是一个 UserManager 的智能指针,我们前面写 InetAddr 标准化描述一个客户端;然后写 UserManager 用 vector 数组存所有 InetAddr 批量管理客户端;现在的 Route 可以通过 _uma 这个指针,去指挥 UserManager 管理这个用户数组。从而拿到所有在线客户端,完成聊天室的上线登记和消息广播业务。如果没有 _uma 这个指针,Route 就无法访问用户列表,就实现不了广播。
当我们在代码中声明一个 Route 对象时,程序会自动调用 Route 的构造函数,无需我们手动执行任何初始化操作。在构造函数内部,通过 std::make_unique<UserManager>() 在堆上创建了一个 UserManager 实例,并让私有成员变量的智能指针 _uma 持有这个对象。也就是说,只要 Route 对象被创建,它内部就会自动生成一个用户管理器,我们后续所有对在线用户的增删查改、消息广播,全部都通过 _uma 这个智能指针来访问和调用。
当服务端收到一个客户端的消息时,就调用这个函数,把这个客户端登记添加到在线用户数组里。函数通过我们之前讲的智能指针 _uma,调用 UserManager 的 AddUser 方法;把传进来的客户端对象 addr,添加到 UserManager 内部维护的 _users 数组中;

这两行定义了两个函数签名:
把业务层的 Route::CheckUser 和 Route::Broadcast 绑定到 UdpServer 上。
这里用的是单例模式,拿到全局唯一的线程池实例;线程池在程序启动时就会创建好一批工作线程,随时准备接任务;后面我们所有的广播任务,都会丢给这个 thread_pool 去处理。


这两行,是给回调函数定 "模板":

保存回调,把传进来的两个 Lambda 函数,存进 UdpServer 内部的成员变量里,让它以后能调用到。等后面 Start() 里收到消息时,就直接调用这两个存好的函数。


客户端需要知道服务端的 IP 和端口,所以通过命令行参数传入;
首先创建 InetAddr 对象,把服务端的 IP 和端口封装起来;
Online() 是客户端的上线报到函数。客户端启动后,会主动向服务端发送一条简单消息,消息内容本身并不重要,核心目的是让服务端获取到客户端的地址信息,触发服务端的 CheckUser 回调,将当前客户端添加到在线用户列表中。只有执行了 Online,客户端才算真正加入聊天室,后续才能正常接收服务端的广播消息。它是客户端完成上线、与服务端建立关联的第一步,也是整个 UDP 聊天室通信流程中必不可少的初始化环节。
1 个服务端终端、3 个客户端终端启动完成;
三个客户端依次设置昵称 Liz、Fall、Rein,完成上线;
三个用户都已完成上线,进入群聊状态;