
🔥个人主页:爱和冰阔乐
📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》
🐶学习方向:C++方向学习爱好者
⭐人生格言:得知坦然 ,失之淡然

🏠博主简介

文章目录
- 前言
- 一、从请求---响应到消息路由
-
- [1.1 回显服务器为什么不能直接当聊天室](#1.1 回显服务器为什么不能直接当聊天室)
- [1.2 聊天室的整体结构](#1.2 聊天室的整体结构)
- 二、封装网络地址
- 三、在线用户表与路由模块
-
- [3.1 在线表保存什么](#3.1 在线表保存什么)
- [3.2 广播消息](#3.2 广播消息)
- [3.3 用户退出与 erase-remove](#3.3 用户退出与 erase-remove)
- [3.4 完整 `Route` 类](#3.4 完整
Route类)
- [四、让 UDP 服务器接收路由回调](#四、让 UDP 服务器接收路由回调)
-
- [4.1 服务器循环只需要调整回调参数](#4.1 服务器循环只需要调整回调参数)
- [4.2 服务器入口](#4.2 服务器入口)
- 五、客户端为什么要两个线程
-
- [5.1 单线程顺序读写的问题](#5.1 单线程顺序读写的问题)
- [5.2 共享线程参数](#5.2 共享线程参数)
- [5.3 接收线程](#5.3 接收线程)
- [5.4 发送线程](#5.4 发送线程)
- [5.5 完整客户端入口](#5.5 完整客户端入口)
- 六、线程池版工程怎样编译
-
- [6.1 线程池接入后多了哪些依赖](#6.1 线程池接入后多了哪些依赖)
- [6.2 启动顺序](#6.2 启动顺序)
- [6.3 线程池不会自动解决线程安全](#6.3 线程池不会自动解决线程安全)
- 七、这一版聊天室仍然有哪些边界
-
- [7.1 UDP 不保证消息一定到达](#7.1 UDP 不保证消息一定到达)
- [7.2 客户端地址可能变化](#7.2 客户端地址可能变化)
- [7.3 退出协议并不可靠](#7.3 退出协议并不可靠)
- [7.4 广播复杂度会随人数增长](#7.4 广播复杂度会随人数增长)
- 总结
前言
前一篇已经完成 UDP 回显服务器和字典服务器。它们都属于一问一答:客户端发一份请求,服务器处理后只把响应返回给这个客户端。
聊天室的行为不同。某个用户发来的消息不能只返回给自己,而要转发给其他在线用户。于是服务器除了收发数据,还要维护在线用户列表,知道当前有哪些客户端,并把同一条消息分别发送给它们。
客户端也要改变。回显客户端可以"发送一次,再接收一次",但聊天室随时可能收到其他用户的消息。如果程序一直阻塞在键盘输入上,它就无法及时打印服务器转发的内容。所以客户端需要把发送和接收拆开:一个线程读键盘并发送,另一个线程持续接收。
这一篇围绕这两个变化展开,并补充聊天室在线列表删除时会用到的 erase-remove 写法,以及接入线程池后必须处理的共享数据问题。
一、从请求---响应到消息路由
1.1 回显服务器为什么不能直接当聊天室
回显服务器收到消息以后,只做一件事:把消息发回报文来源地址。
cpp
recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
reinterpret_cast<sockaddr*>(&peer), &len);
sendto(sockfd, buffer, strlen(buffer), 0,
reinterpret_cast<sockaddr*>(&peer), len);
这里的 peer 只代表当前发送者。聊天室要实现广播,服务器还需要记住之前出现过的其他客户端。
UDP 本身没有"连接已经建立"这一状态,也没有 accept 返回一个专属客户端 Socket。因此可以约定:
- 某个地址第一次向服务器发送消息,视为上线;
- 服务器把它加入在线用户表;
- 后续消息转发给在线表中的所有地址;
- 用户发送
QUIT时,从在线表中删除。
这只是应用层协议,是我们根据业务自行规定的,并不是 UDP 内置功能。
1.2 聊天室的整体结构
服务器收到一条消息后,先把"来源地址 + 消息内容"封装为转发任务,再交给路由模块。路由模块遍历在线用户表,调用 sendto 发给每个客户端。
客户端采用两个线程:
- 发送线程从标准输入读取消息,再发给服务器;
- 接收线程阻塞在
recvfrom,有消息就打印出来。

图中服务器还可以继续接线程池。网络线程只负责收包,把转发任务推入任务队列;工作线程负责遍历用户表并发送,从而避免一次广播长时间占住收包循环。
整理后的工程结构如下:
text
udp_chat/
├── InetAddr.hpp
├── Route.hpp
├── UdpServer.hpp
├── UdpServer.cc
├── UdpClient.cc
├── ThreadPool.hpp
├── Thread.hpp
├── Mutex.hpp
├── Cond.hpp
└── makefile
ThreadPool.hpp、Thread.hpp、Mutex.hpp 和 Cond.hpp 复用前面线程部分已经完成的封装。这一篇重点放在网络服务器、路由和客户端怎样使用它们,不再把线程池实现重复粘贴一遍。
二、封装网络地址
聊天室会频繁比较、保存和打印客户端地址。直接到处操作 sockaddr_in 比较零散,因此先封装一个 InetAddr 类。
cpp
#pragma once
#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstdint>
#include <string>
class InetAddr {
public:
InetAddr() = default;
explicit InetAddr(const sockaddr_in& addr)
: addr_(addr), port_(ntohs(addr.sin_port)) {
char ip[INET_ADDRSTRLEN] = {0};
inet_ntop(AF_INET, &addr.sin_addr, ip, sizeof(ip));
ip_ = ip;
}
const std::string& Ip() const {
return ip_;
}
uint16_t Port() const {
return port_;
}
const sockaddr_in& NetAddr() const {
return addr_;
}
std::string StringAddr() const {
return ip_ + ":" + std::to_string(port_);
}
bool operator==(const InetAddr& other) const {
return addr_.sin_family == other.addr_.sin_family &&
addr_.sin_port == other.addr_.sin_port &&
addr_.sin_addr.s_addr == other.addr_.sin_addr.s_addr;
}
private:
sockaddr_in addr_{};
std::string ip_;
uint16_t port_ = 0;
};
比较地址时,应该比较地址族、网络序端口和 IPv4 地址,而不是只比较打印后的字符串。当前程序只使用 IPv4,地址族实际上都为 AF_INET,保留比较能让语义更完整。
StringAddr() 只负责生成便于显示的形式:
text
192.168.1.10:52346
服务器转发消息时,可以把它放在正文前面,让其他用户知道消息来自谁。
三、在线用户表与路由模块
3.1 在线表保存什么
UDP 服务器向客户端发送数据时,需要目标 sockaddr_in。所以在线表不能只保存一个用户名,也不能只保存端口号,而要保存完整网络地址。
第一版可以直接使用:
cpp
std::vector<InetAddr> online_users_;
每次收到消息,先检查该地址是否已经存在:
cpp
bool IsOnline(const InetAddr& peer) const {
for (const auto& user : online_users_) {
if (user == peer) {
return true;
}
}
return false;
}
如果不存在,就视为首次上线:
cpp
void AddUser(const InetAddr& peer) {
if (!IsOnline(peer)) {
online_users_.push_back(peer);
}
}
这里用 IP 和端口共同区分客户端。同一台主机上可以运行多个客户端,它们的 IP 相同,但临时端口不同,仍应被当成不同用户。
3.2 广播消息
收到消息后,服务器构造显示文本,再遍历在线表:
cpp
void Broadcast(int sockfd,
const std::string& message,
const InetAddr& peer) {
std::string output = peer.StringAddr() + "# " + message;
for (const auto& user : online_users_) {
const sockaddr_in& target = user.NetAddr();
ssize_t n = sendto(
sockfd,
output.data(),
output.size(),
0,
reinterpret_cast<const sockaddr*>(&target),
sizeof(target));
if (n < 0) {
std::cerr << "sendto " << user.StringAddr()
<< " failed: " << strerror(errno) << std::endl;
}
}
}
当前实现会把消息也发送给发送者本人,这样发送者能确认服务器已经收到并完成转发。如果不希望回显给自己,只要在循环中跳过 user == peer 即可。
3.3 用户退出与 erase-remove
最直观的删除方法是找到元素后调用 erase:
cpp
void DeleteUser(const InetAddr& peer) {
for (auto it = online_users_.begin();
it != online_users_.end();
++it) {
if (*it == peer) {
online_users_.erase(it);
return;
}
}
}
也可以使用 erase-remove 惯用法:
cpp
#include <algorithm>
void DeleteUser(const InetAddr& peer) {
auto new_end = std::remove_if(
online_users_.begin(),
online_users_.end(),
[&peer](const InetAddr& user) {
return user == peer;
});
online_users_.erase(new_end, online_users_.end());
}
remove_if 这个名字很容易让人误解。它不会直接缩短容器,而是把需要保留的元素移动到前面,返回新的逻辑结尾。真正删除尾部元素,还要继续调用 erase。
remove_if 只完成逻辑移动,erase 才真正改变容器大小。两者缺一不可。
3.4 完整 Route 类
cpp
#pragma once
#include "InetAddr.hpp"
#include <algorithm>
#include <cerrno>
#include <cstring>
#include <iostream>
#include <mutex>
#include <string>
#include <vector>
#include <sys/socket.h>
class Route {
public:
void MessageRoute(int sockfd,
const std::string& message,
const InetAddr& peer) {
std::vector<InetAddr> targets;
std::string output;
{
std::lock_guard<std::mutex> guard(mutex_);
if (!IsOnline(peer)) {
AddUser(peer);
std::cout << "new user online: "
<< peer.StringAddr() << std::endl;
}
if (message == "QUIT") {
output = peer.StringAddr() + " logout";
} else {
output = peer.StringAddr() + "# " + message;
}
targets = online_users_;
if (message == "QUIT") {
DeleteUser(peer);
}
}
for (const auto& user : targets) {
const sockaddr_in& target = user.NetAddr();
ssize_t n = sendto(
sockfd,
output.data(),
output.size(),
0,
reinterpret_cast<const sockaddr*>(&target),
sizeof(target));
if (n < 0) {
std::cerr << "sendto error: "
<< strerror(errno) << std::endl;
}
}
}
private:
bool IsOnline(const InetAddr& peer) const {
for (const auto& user : online_users_) {
if (user == peer) {
return true;
}
}
return false;
}
void AddUser(const InetAddr& peer) {
online_users_.push_back(peer);
}
void DeleteUser(const InetAddr& peer) {
auto new_end = std::remove_if(
online_users_.begin(),
online_users_.end(),
[&peer](const InetAddr& user) {
return user == peer;
});
online_users_.erase(new_end, online_users_.end());
}
std::vector<InetAddr> online_users_;
std::mutex mutex_;
};
这里没有拿着互斥锁执行整轮 sendto。锁内只完成用户表更新和目标快照复制,真正发送时已经释放互斥锁。否则某个发送动作变慢,会把其他上线、退出和转发任务全部堵在锁外。
这份代码仍有一个现实问题:
客户端异常退出时不会主动发送 QUIT,服务器会留下失效地址。要做成长期运行的服务,还需要增加心跳和超时淘汰。
线程池版路由不能只给 vector 加锁就结束:还要避免持锁执行网络发送,否则并发任务最终仍会退化成一条缓慢的串行链路。
四、让 UDP 服务器接收路由回调
前一篇已经把服务器业务抽象成回调。聊天室仍然可以沿用,只是回调除了消息,还需要 Socket 和客户端地址。
cpp
using Handler = std::function<void(
int,
const std::string&,
const InetAddr&)>;
4.1 服务器循环只需要调整回调参数
服务器的创建、绑定和接收流程与前文一致,不需要再复制一份完整实现。区别只在于:收到报文后,除了消息和客户端地址,还要把服务器 Socket 交给路由回调,因为 Route 要使用同一个 Socket 向其他客户端发送数据。
cpp
ssize_t n = recvfrom(sockfd_, buffer, sizeof(buffer) - 1, 0,
reinterpret_cast<sockaddr*>(&peer), &len);
if (n > 0) {
buffer[n] = '\0';
InetAddr client(peer);
handler_(sockfd_, std::string(buffer, n), client);
}
网络层仍然只做三件事:接收报文、整理参数、调用回调。消息发给谁,由 Route 决定。
4.2 服务器入口
cpp
#include "Route.hpp"
#include "ThreadPool.hpp"
#include "UdpServer.hpp"
#include <cstdint>
#include <functional>
#include <iostream>
#include <memory>
using task_t = std::function<void()>;
using namespace ThreadPoolModule;
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
auto route = std::make_shared<Route>();
auto* pool = ThreadPool<task_t>::GetInstance();
UdpServer server(
port,
[route, pool](int sockfd,
const std::string& message,
const InetAddr& peer) {
task_t task = [route, sockfd, message, peer] {
route->MessageRoute(sockfd, message, peer);
};
if (!pool->Enqueue(task)) {
std::cerr << "enqueue route task failed" << std::endl;
}
});
if (!server.Init()) {
return 2;
}
server.Start();
return 0;
}
这里捕获 shared_ptr 是为了保证任务在线程池中排队时,路由对象仍然存在。message 和 peer 继续按值捕获,不能引用服务器接收循环中的局部变量,否则任务真正执行时引用可能已经失效。
任务进入线程池以后,执行时间与入队时间分离。局部消息和客户端地址必须按值保存,不能把短生命周期对象的引用交给工作线程。
五、客户端为什么要两个线程
5.1 单线程顺序读写的问题
回显客户端常见流程是:
text
输入 -> 发送 -> 等待响应 -> 打印 -> 再次输入
聊天室不能假设"只有我发送以后才会收到消息"。当程序正在等待用户输入时,其他用户可能已经发送多条内容。若收发都放在一个线程里,消息只能等到当前输入结束后才能读取。
UDP Socket 本身支持发送和接收。同一个文件描述符可以由发送线程调用 sendto,由接收线程调用 recvfrom。两条执行流互不要求按请求---响应顺序配对,更适合聊天室。
客户端拆成两个线程,不是因为 UDP 只能分别读写,而是因为聊天消息的到达与本地键盘输入没有固定先后关系。
5.2 共享线程参数
cpp
struct ThreadData {
int sockfd;
sockaddr_in server;
};
两个线程都需要 Socket;发送线程还需要服务器地址。结构体在 main 返回前一直有效,线程退出前不能销毁。
5.3 接收线程
cpp
void Receiver(ThreadData* data) {
char buffer[4096];
for (;;) {
sockaddr_in peer{};
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(
data->sockfd,
buffer,
sizeof(buffer) - 1,
0,
reinterpret_cast<sockaddr*>(&peer),
&len);
if (n < 0) {
std::cerr << "recvfrom error: "
<< strerror(errno) << std::endl;
break;
}
buffer[n] = '\0';
std::cout << "\n" << buffer << std::endl;
std::cout << "Please Enter# " << std::flush;
}
}
接收线程长期阻塞在 recvfrom。消息到达后被唤醒,打印完成再继续等待。
5.4 发送线程
cpp
void Sender(ThreadData* data) {
for (;;) {
std::string message;
std::cout << "Please Enter# " << std::flush;
if (!std::getline(std::cin, message)) {
break;
}
ssize_t n = sendto(
data->sockfd,
message.data(),
message.size(),
0,
reinterpret_cast<sockaddr*>(&data->server),
sizeof(data->server));
if (n < 0) {
std::cerr << "sendto error: "
<< strerror(errno) << std::endl;
break;
}
if (message == "QUIT") {
break;
}
}
}
标准输出可能同时被接收线程和发送线程使用,所以提示符偶尔会和消息穿插。这不影响网络逻辑,但正式终端程序可以加输出锁,或者使用专门的界面刷新方案。
5.5 完整客户端入口
cpp
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <iostream>
#include <string>
#include <thread>
struct ThreadData {
int sockfd;
sockaddr_in server;
};
// Receiver 与 Sender 的实现放在这里
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr << "Usage: " << argv[0]
<< " server_ip server_port" << std::endl;
return 1;
}
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "socket error: " << strerror(errno) << std::endl;
return 2;
}
sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(static_cast<uint16_t>(std::stoi(argv[2])));
if (inet_pton(AF_INET, argv[1], &server.sin_addr) != 1) {
std::cerr << "invalid server ip" << std::endl;
close(sockfd);
return 3;
}
ThreadData data{sockfd, server};
std::thread receiver(Receiver, &data);
std::thread sender(Sender, &data);
sender.join();
// 让阻塞在recvfrom中的线程退出。
shutdown(sockfd, SHUT_RDWR);
receiver.join();
close(sockfd);
return 0;
}
UDP Socket 没有 TCP 连接,但 Linux 上对 Socket 调用 shutdown 可以让阻塞操作返回。也可以把接收线程设计成可取消任务,或者在退出时发送专门唤醒报文。这里重点是:不能让主线程永远卡在 receiver.join()。
六、线程池版工程怎样编译
6.1 线程池接入后多了哪些依赖
服务器入口会用到 ThreadPool.hpp,线程池继续依赖 Thread.hpp、Mutex.hpp 和 Cond.hpp。这些文件都属于头文件实现,不需要单独生成目标文件,但编译时必须链接 POSIX 线程库。
客户端使用 std::thread,底层同样需要线程库。Makefile 可以写成:
makefile
.PHONY: all clean
all: udpclient udpserver
udpclient: UdpClient.cc
g++ -o $@ $^ -std=c++17 -pthread
udpserver: UdpServer.cc
g++ -o $@ $^ -std=c++17 -pthread
clean:
rm -f udpclient udpserver
使用 -pthread 比只写 -lpthread 更完整,因为它不仅负责链接,也会启用编译阶段需要的线程相关选项。
6.2 启动顺序
编译:
bash
make
启动服务器:
bash
./udpserver 8888
打开两个或更多终端,分别启动客户端:
bash
./udpclient 127.0.0.1 8888
任意客户端发送消息后,服务器把消息路由给当前在线列表中的每个地址。输入 QUIT 时,服务器先广播退出提示,再把对应端点从在线表中移除。
6.3 线程池不会自动解决线程安全
接入线程池以后,多个任务可能同时执行:
- 两个新用户同时上线,可能同时修改
vector; - 一个任务正在复制在线列表,另一个任务可能删除用户;
- 消息和客户端地址若按引用捕获,任务执行时对象可能已经失效。
因此,路由类必须保护在线表,任务对象必须保存自己需要的数据。前面的实现采用"锁内生成快照、锁外发送"的方式,既保证容器一致性,也避免长时间持锁。
线程池只负责调度任务,不负责修复业务代码中的数据竞争。共享容器、回调捕获和对象生命周期仍要由程序自己保证。
七、这一版聊天室仍然有哪些边界
这份程序能够说明 UDP 路由和全双工客户端,但它还不是生产环境聊天室。
7.1 UDP 不保证消息一定到达
消息可能丢失、重复或乱序。当前代码没有序号、确认和重传机制,所以不能把"发送成功"理解为"所有客户端都已经收到"。sendto 成功只说明数据已经交给本机内核处理。
7.2 客户端地址可能变化
客户端重启后通常会得到新的临时端口,服务器会把它当成新用户。经过 NAT 时,服务器看到的还是映射后的公网端点,不一定是客户端本机真实地址。
7.3 退出协议并不可靠
用户正常输入 QUIT 时可以删除,但进程崩溃、断网或直接关闭终端时,服务器收不到退出消息。要清理这种用户,需要心跳、最后活跃时间和超时机制。
7.4 广播复杂度会随人数增长
当前每条消息都遍历所有在线用户,时间复杂度是 O(n)。人数较少时足够直观;人数很大时,需要按房间分组、批量发送或重新设计消息分发架构。
这些限制并不妨碍当前学习目标。它们反而说明了一件事:UDP 只提供基础数据报能力,连接状态、在线管理、可靠性和业务协议都由应用层决定。
总结
本文在 UDP 请求---响应模型上继续完成了一个简单聊天室:
- 服务器以客户端
IP + port作为在线用户标识; - 首次收到消息视为上线,
QUIT作为应用层退出约定; - 路由模块遍历在线表,通过
sendto向多个客户端转发; - 客户端用发送线程和接收线程分别处理键盘输入与网络消息;
- 接入线程池以后,在线用户表必须增加并发保护;
- 任务中的消息和客户端地址要按值保存,避免引用失效。
从回显服务器、字典服务器到聊天室,网络层的基本接口没有改变,变化的是收到数据后的业务处理方式。把网络收发和业务回调分开以后,同一个 UDP 服务器框架才能继续扩展,而不是每换一个需求就重新写一遍 socket、bind 和接收循环。
本文对应的聊天室、线程池及辅助封装可以在代码仓库中查看:Socket 编程代码。
往期博客分享
【Linux网络】Socket 编程前要搞懂什么?从 IP、端口到字节序与 sockaddr,一次理清网络通信基础
【Linux网络】UDP 服务器怎么写?从 socket、bind 到 recvfrom/sendto,手写完整回显服务器