Linux 网络套接字编程(四)支持多客户端同时在线、消息能转发给所有人的 UDP 聊天室服务器

目录

一、多线程并发聊天室服务器

设计思路

[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 网络模块 + 线程池任务投递)

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. 构造函数1的触发场景是当服务端调用 recvfrom 收到客户端消息时,会直接获取一个 sockaddr_in 结构体,此时必须使用这个构造函数,完成客户端地址的初始化。

  2. 初始化列表会直接拷贝系统层的 _address 和 _len,先将网络字节序的 IP 转换为字符串 IP,存入_ip,方便可读;再将网络序端口转换为主机序端口,存入_port。

  3. 此时后续业务层对比客户端、打印日志时,直接使用 _ip 和 _port,就不用再调用字节序转换函数。

构造函数2 :
  1. 构造函数2的触发场景是客户端初始化服务端地址、服务端配置监听地址时使用,通过可读的 IP + 端口反向构建网络地址。

  2. 初始化列表先初始化业务层的 _ip 和 _port,直接使用传入的主机序参数;将主机序端口转换为网络字节序端口;再将字符串 IP转换为内核可识别的 4 字节网络字节序 IP。

重载==运算符
  1. 原生 C 语言的 sockaddr_in 是结构体,C++ 不会默认重载==,直接对比只会比较内存地址,无法判断结构体内部 IP、端口是否一致,无法用于客户端身份判断。

  2. 所以我们需要重载==运算符,重载的逻辑就是只有 IP 地址和端口号同时完全匹配,才判定为同一个客户端。因为 UDP 通信中,IP + 端口是客户端身份的唯一标识。

  3. 这是聊天室实现客户端上线去重的底层基础。后续 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 的基础上 :

  1. _users 列表的元素类型是 InetAddr,所有客户端身份的描述,全部标准化为 InetAddr 对象;

  2. SearchUser、DelUser 方法中,客户端身份的对比,依赖 InetAddr 重载的 == 运算符;

  3. 所有增删查改方法的入参、出参,都是 InetAddr 对象,中间不需要我们再做其他的转换工作;

  4. Users() 方法返回的 InetAddr 列表,供路由转发模块遍历,直接调用 GetNetAddress() 和 Len() 方法,传给 sendto 系统调用,实现消息广播。


3. Route.hpp 路由转发模块

我们通过 InetAddr 标准化了单个客户端身份,又通过 UserManager 实现了所有在线用户的批量管理,至此我们解决了两个核心前置问题:如何描述客户端、如何管理客户端。

现在我们回到聊天室最核心的业务目标:服务端收到任意一条客户端消息后,必须转发给所有在线用户,实现全员广播,从而实现我们的聊天功能。

这个「接收消息 → 校验用户 → 遍历用户列表 → 循环发送广播」的完整业务流程,我们需要单独抽离出来,形成一个专门的业务模块,这就是我们接下来要实现的 Route 路由转发模块。

很多同学这里会和线程池混淆,我们先把边界讲清楚:

✅ Route 模块 = 只负责聊天室业务逻辑 :用户上线登记、消息广播转发,它回答的问题是:收到消息后,业务上具体要做什么。

✅ 线程池 = 只负责并发调度执行 :分配线程、执行任务队列,它回答的问题是:这个业务任务由谁来跑、怎么并发跑、怎么不阻塞主线程。

在架构上,Route 是业务层,线程池是调度层;必须先有业务,才有要调度的任务,所以我们先实现 Route,再引出线程池。

设计思路:

Route 是整个聊天室的业务中枢,承上启下:

  1. 对上对接网络层 UdpServer:接收网络层传过来的客户端地址和客户端消息;

  2. 对内对接 UserManager:调用用户管理模块,完成客户端上线登记、获取在线用户列表;

  3. 对下输出广播动作:封装广播逻辑,形成一个可被调度执行的业务任务。

代码:

头文件依赖

<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 也是我们之前封装过的。

使用智能指针的优点就是 :

  1. 生命周期可控:堆上动态创建销毁,灵活管理对象生存周期。

  2. 多态扩展:指针可指向子类实现,未来可灵活替换用户管理策略。

  3. 内存安全:unique_ptr自动管理内存,避免栈拷贝开销、内存泄漏。

  4. 职责分离: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 聊天室的架构闭环正式完成。

代码 :

先看整体结构

整个类的核心逻辑只有两步:

  1. Init():创建 UDP socket 并绑定端口,让服务端能监听客户端消息;
  2. Start():循环调用 recvfrom 接收消息,触发我们注册的业务回调。
回调函数类型定义

这两行定义了两个函数签名:

  1. handler_addr_t:接收 const InetAddr & 类型参数,无返回值,用来绑定 Route::CheckUser;
  2. 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. 消息广播转发。

  1. 第一个回调当 UdpServer 收到客户端消息,拿到 InetAddr 地址后,会自动调用这个回调;直接调用 Route::CheckUser(addr),把客户端加入在线用户列表;这一步是同步执行的,逻辑简单、耗时短,直接在主线程里完成即可。

  2. 第二个回调就是我们前面说的把广播函数打包成任务、丢进线程池的地方,分两步:

  3. 打包任务:std::bind(&Route::Broadcast, &r, sockfd, msg) 把 Route::Broadcast 函数,和它需要的参数(&r 路由对象、sockfd 套接字、msg 消息)全部绑定在一起;打包成一个可执行的 task_t 任务对象 t。

  4. 投递任务: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. 不断接收服务端广播回来的消息,打印到屏幕上。

为了实现这两个目标,客户端采用了多线程模型:

  1. 一个线程负责接收服务端消息(RecvMessage);
  2. 一个线程负责发送用户输入(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,同样被广播给所有客户端;

服务端日志完整记录了每一条消息,并显示每条消息的广播任务都被线程池的不同工作线程处理。

二、完整代码

ChatClient.cc

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;
}

ChatServerMain.cc

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、后端框架,还是分布式服务,这套基础思想都将成为你最扎实的根基。

谢谢大家的观看!

相关推荐
亿电连接器替代品网2 小时前
BEL连接器替代实践经验分享与国产化解决方案
网络·经验分享·物联网·硬件工程·集成学习·材料工程
正在努力的网络架构师2 小时前
BGP选路规则-华为
网络
XS0301062 小时前
Java 基础(十一)反射
java·开发语言
微刻时光2 小时前
影刀RPA应用落地全流程指南:从需求到运维的实战手册
运维·人工智能·机器人·自动化·rpa·影刀rpa
t***5442 小时前
Dev-C++中使用Clang调试有哪些常见错误
java·开发语言·c++
郝学胜-神的一滴2 小时前
[简化版 GAMES 101] 计算机图形学 06:相机视图矩阵的由来
c++·线性代数·unity·矩阵·godot·图形渲染·unreal engine
ydmy2 小时前
强化学习/对齐(个人理解)
开发语言·python
一叶之秋14122 小时前
哈希密钥:解锁unordered容器的极速潜能
开发语言·c++·哈希算法
艾莉丝努力练剑2 小时前
剑指巅峰,磨砺芳华:我的 CSDN 创作一周年深度总结
linux·运维·服务器·c++·学习