🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位:在已经跑通 UDP 回显的基础上,把通信循环与业务逻辑分开,并用网络字典和聊天室理解请求/响应、消息路由与多线程收发。
💡 学习目标:会设计业务回调;能加载词典并查询;理解一个 UDP Socket 如何服务多个客户端;掌握聊天室用户表、退出协议与接收线程的生命周期。
📌 实现范围:Linux、IPv4、C++17。服务器保留单线程顺序处理,聊天室客户端采用主线程发送、独立线程接收;线程池作为设计讨论,不假装省略代码也能直接运行。
如果上篇解决的是"两个进程怎样交换消息",下篇要解决的就是"收到消息后应该由谁处理,处理结果又应该发给谁"。网络接口没有变化,应用设计开始发生变化。
文章目录
- 一、从回显走向业务服务器
- 二、用回调隔离网络与业务
- 三、网络字典的数据加载与查询
- 四、完整的网络字典服务器
- 五、聊天室为什么需要路由
- 六、用户登记、广播与退出
- [七、remove_if 为什么还要搭配 erase](#七、remove_if 为什么还要搭配 erase)
- 八、完整的聊天室服务器
- 九、多线程客户端的收发与退出
- 十、从单线程服务走向线程池
- 十一、编译测试与工程边界
- 总结
一、从回显走向业务服务器
回显服务器收到 hello,返回 hello。网络字典收到 hello,返回 你好。聊天室收到 hello,把这条消息转发给已经登记的多个用户。
它们使用的网络基础相同:创建 Socket、绑定端口、接收数据报、获得发送者地址、发送数据报。不同的是业务处理方式。

1.1 为什么不直接改 recvfrom 后面的几行
第一次写字典服务器时,直接把回显代码替换为查询逻辑,当然能工作。但继续加入其他业务后,网络循环就会同时负责加载配置、查词、登记用户、广播消息和处理退出。
结果是每次改业务都需要进入通信循环,每次修网络问题都可能影响业务。如果想复用同一套网络代码,还会重复复制 socket / bind / recvfrom。
因此我们把职责拆开:
| 层次 | 负责什么 | 不需要知道什么 |
|---|---|---|
| Socket 封装 | 地址、收发、长度和资源管理 | 单词含义、用户是否在线 |
| 请求/响应服务器 | 接收、调用业务、回复来源 | 具体词典内容 |
| 字典业务 | 文本加载与查询 | 文件描述符与地址转换 |
| 聊天路由 | 用户表、控制命令、广播目标 | Socket 创建细节 |
1.2 分层不是把每一行都包装成函数
合适的边界来自职责:UdpSocket 封装系统调用;Dictionary 处理词条;Route 管理消息分发。它们各自解决不同问题。
如果一个包装类只是换了函数名字,又把所有错误检查留给外部,那么调用者依旧必须理解全部底层细节。本文封装至少统一了地址表示、描述符所有权、收包长度与超时结果,让上层代码能更专注于业务。
二、用回调隔离网络与业务
网络字典是一个典型的"一条请求,计算一条响应"服务。可以把业务处理抽象为:
cpp
using Handler = std::function<std::string(const std::string&)>;
输入是请求内容,返回值是响应内容。服务器不关心请求里是不是英语单词,只负责按这个约定调用业务。

2.1 PDF 的输出参数写法与本文写法
PDF 使用过这样的回调签名:
cpp
std::function<void(const std::string& request, std::string* response)>
它把结果写入输出参数,也是合理的接口形式。本文在只有一个返回结果时改为直接返回 std::string,省去输出指针是否为空的约定。
两种写法的思想完全一致:网络循环先拿到请求,再把请求交给外部提供的业务函数。
2.2 std::function 可以接什么
它可以保存与签名兼容的普通函数、函数对象和 lambda。例如回显也能作为一个业务:
cpp
Handler echo = [](const std::string& request) {
return request;
};
字典业务则写成:
cpp
Handler translate = [&dictionary](const std::string& word) {
return dictionary.Translate(word);
};
服务器代码不需要为"回显版本"和"字典版本"各写一次收发循环。
2.3 引用捕获为什么有生命周期要求
[&dictionary] 不会把整个词典复制进 lambda,它保存的是对外部对象的引用。这意味着执行回调时,外部词典必须仍然活着。
本文主函数先创建 Dictionary,再创建 UdpServer,最后同步调用 Run。在服务器运行期间,词典对象不会离开作用域,因此引用有效。
如果把回调提交到后台线程,在主函数继续退出后才运行,就必须重新设计所有权。例如让任务持有适当的 shared_ptr,或明确保证服务、任务与业务对象的销毁顺序。捕获列表不是生命周期问题的自动解决器。
2.4 请求/响应抽象有什么限制
这个接口要求每条请求生成一个响应,然后回给原始来源。它很适合字典,却不完全适合聊天室:一条聊天消息可能要发给十个用户,退出消息甚至不必回复退出者。
因此下文没有硬把聊天室塞进返回一个字符串的 Handler。聊天路由需要得到来源端点,并掌握发送目标集合,抽象边界也随之变化。
三、网络字典的数据加载与查询
字典业务可以拆成两个阶段:启动时加载文件,运行时查询内存。

3.1 为什么启动时加载
如果每收到一个单词,都重新打开词典、逐行扫描,那么网络循环会反复执行文件 I/O。对于教学词典,这没有必要。
我们在启动时把词条装入 std::unordered_map<std::string, std::string>。运行时按照请求查找,命中返回释义,未命中返回 Unknown。
哈希表的平均查找复杂度通常为 O(1),但不是所有输入下都保证固定时间;碰撞等情况会影响表现。这里选择它,是因为业务本身就是"根据键取值",而不是因为它在所有场景都最快。
3.2 文件格式先约定清楚
本示例采用 UTF-8 文本,每行一个词条,分隔符严格为"冒号加一个空格":
text
apple: 苹果
banana: 香蕉
hello: 你好
world: 世界
文件中可以有空行,也可以用 # 开头写注释。为了兼容从 Windows 保存的 CRLF 文件,读到每行后移除结尾可能存在的 \r。
正文只是举例,实际随附的 dict.txt 保存课程中的 20 个常见词条。
3.3 substr 的位置怎样计算
cpp
const auto position = line.find(": ");
const auto key = line.substr(0, position);
const auto value = line.substr(position + 2);
position 是冒号的位置,position + 2 跳过冒号与空格,来到释义的第一个字节。不要把 +2 理解成跳过两个汉字,它跳过的是两个 ASCII 字节。
处理顺序必须是先判断 find 是否得到 npos,再执行切分。否则对找不到分隔符的结果做算术,得到的就不再是有效的位置约定。
3.4 坏词条与重复词条如何处理
PDF 示例对格式不匹配的行采用跳过策略。本文改为启动时报出行号,让配置错误更容易被发现,避免运行后才发现词条莫名缺失。
重复键使用:
cpp
entries_[key] = value;
这意味着后出现的词条覆盖之前的值 。如果使用 insert,通常会保留先插入的值。两种策略都能成立,但应明确自己的选择。
示例还拒绝超过 Socket 应用容量的释义。否则词典加载看起来成功,查询时却无法把结果作为一条完整数据报发出去。
3.5 精确查询是什么意思
当前查询不进行大小写转换、不自动去掉空格、不做词形还原:hello、Hello、hello 是不同的请求。
这让协议与代码保持简单。希望增强搜索能力时,应在业务层增加明确的归一化规则,并考虑字符编码,不能随意对 UTF-8 每个字节套用英文字符转换。
四、完整的网络字典服务器
本篇目录中的 code/udp.hpp 与上篇一致,提供 Endpoint、UdpSocket、ParsePort 等基础能力。两篇各自保存一份,直接使用本篇文件夹即可构建,不必跨目录寻找课程依赖。
4.1 通用请求/响应服务器:udp_server.hpp
cpp
#pragma once
#include "udp.hpp"
#include <functional>
#include <iostream>
#include <utility>
using Handler = std::function<std::string(const std::string&)>;
class UdpServer {
public:
UdpServer(uint16_t port, Handler handler) : handler_(std::move(handler)) {
if (!handler_) throw std::invalid_argument("empty handler");
socket_.BindAny(port);
}
void Run() {
std::cout << "dictionary listening at " << socket_.Local().Text() << std::endl;
for (;;) {
std::string request;
Endpoint peer;
try {
if (!socket_.Receive(request, peer)) continue;
const auto response = handler_(request);
socket_.SendTo(response, peer);
} catch (const std::length_error& error) {
std::cerr << error.what() << '\n';
}
}
}
private:
UdpSocket socket_;
Handler handler_;
};
handler_ 通过构造函数传入,服务器对每条请求执行一次它,再把结果发送给本次来源。
构造时检查回调是否为空,避免在第一次收包后才因 bad_function_call 出错。词典相关文件读取放在进入服务循环之前,因此配置错误会在启动阶段暴露。
4.2 字典加载与启动入口:dict_server.cpp
cpp
#include "udp_server.hpp"
#include <fstream>
#include <unordered_map>
class Dictionary {
public:
explicit Dictionary(const std::string& path) {
std::ifstream input(path);
if (!input) throw std::runtime_error("cannot open dictionary: " + path);
std::string line;
std::size_t number = 0;
while (std::getline(input, line)) {
++number;
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty() || line.front() == '#') continue;
const auto position = line.find(": ");
if (position == std::string::npos || position == 0 || position + 2 == line.size())
throw std::runtime_error("invalid dictionary line " + std::to_string(number));
const auto key = line.substr(0, position);
const auto value = line.substr(position + 2);
if (value.size() > kMaxDatagram)
throw std::length_error("dictionary value is too large");
entries_[key] = value;
}
if (input.bad()) throw std::runtime_error("dictionary read failed");
}
std::string Translate(const std::string& word) const {
const auto found = entries_.find(word);
return found == entries_.end() ? "Unknown" : found->second;
}
private:
std::unordered_map<std::string, std::string> entries_;
};
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr << "Usage: " << argv[0] << " <port> <dict-file>\n";
return 1;
}
try {
Dictionary dictionary(argv[2]);
UdpServer server(ParsePort(argv[1]), [&dictionary](const std::string& word) {
return dictionary.Translate(word);
});
server.Run();
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 2;
}
}
这里没有使用全局词典对象。全局对象在 main 之前构造,会让错误报告与启动参数处理更难统一;放进 main 的作用域,初始化顺序和生命周期都更清楚。
Translate 是 const 成员函数,表达"查询不改变词典内容"。如果以后多线程只读同一个初始化完成的词典,分析会比较简单;如果允许热更新,就需要另行设计同步或不可变快照。
4.3 复用上篇客户端
因为字典仍是"一次发送、一次响应",可以直接使用同样的 udp_client:
bash
make
./dict_server 8888 ./dict.txt
另一个终端:
bash
./udp_client 127.0.0.1 8888
输入 apple,预期得到 苹果;输入没有配置的单词,得到 Unknown。输入空行同样会发出一条数据报,字典会把空字符串当成未命中的查询。
这里的 Unknown 是业务约定,不是 Socket 错误码。网络层负责交付字节,字典层决定这些字节是什么意思。
4.4 文件路径相对于谁
./dict.txt 相对于进程启动时的工作目录,不一定相对于可执行文件所在目录。如果从另一个目录启动服务器,找不到文件是合理结果。
所以命令中显式接受词典路径。实际部署可以提供绝对路径或统一工作目录,避免把"配置文件就在旁边"作为隐含假设。
五、聊天室为什么需要路由
字典请求只需要回给发起者。聊天室则希望客户端 A 发出的消息,能够被客户端 B、C 同时看见。
UDP 本身不会替应用保存"聊天室成员"。服务器必须记录已经出现的客户端端点,再决定每条消息应该发送到哪些地址。

5.1 这里的广播不是 UDP 广播地址
本文所谓"聊天室广播",是在服务器中遍历用户表,对每个地址分别调用 sendto。它是一组应用层单播。
它没有把目标 IP 设置成 255.255.255.255,也没有用到组播。文章里"广播"描述的是业务效果,不能和网络层广播机制混为一谈。
5.2 用户用什么标识
教学示例使用"源 IP + 源端口"作为路由表键。同一台电脑启动两个客户端,通常拥有不同的临时端口,因此服务器可以区分它们。
cpp
bool operator==(const Endpoint& other) const;
这个比较检查地址族、二进制 IP 和网络序端口。我们没有直接 memcmp 整个 sockaddr_in,因为结构体可能含有填充区域,整块内存相等不是这里需要表达的语义。
5.3 端点不等于登录账号
源端点适合演示消息回发,但它没有用户名、认证、会话令牌等信息。客户端重启可能换端口,NAT 映射也可能变化。
因此路由表中的"在线用户"准确地说是服务器记住的一组客户端端点。不能把收到一个包就登记,解释成已经完成可信登录。
5.4 为什么第一次发消息就能被加入
PDF 把首次发消息视为加入聊天室。这样不用额外设计登录消息,演示路径很短:服务器发现陌生端点,就加入用户表,再转发当前消息。
本文保留这个能力,同时增加显式 JOIN。客户端启动时先发送它,即使用户还没有输入正文,也能让服务器知道其存在。
六、用户登记、广播与退出
本文采用三个简单规则:
| 消息 | 服务器行为 |
|---|---|
JOIN |
未登记则加入,并向用户表发送加入通知 |
QUIT |
已登记则删除,再向剩余用户发送退出通知 |
| 其他内容 | 首次出现则登记,然后向用户表转发正文 |
JOIN 和 QUIT 是保留控制字。当前协议不能把完全相同的字符串当作普通聊天正文,大小写不同的内容不属于控制命令。
6.1 为什么退出分支应该先判断
如果一收到消息就无条件登记,再发现消息是 QUIT,一个从未登记的端点也会经历"加入再退出"的过程。
因此本文先处理退出:只有已存在的端点才删除。未知端点的 QUIT 直接忽略,避免产生没有意义的加入通知。
6.2 删除之前广播,还是删除之后广播
PDF 示例先广播当前内容,再删除发送 QUIT 的用户。它让退出者也能收到退出控制字对应的转发。
本文采用先删除、后向剩余成员发退出通知。这样广播对象就是退出完成后的用户表,语义更直接。两者不是 Socket 的硬性要求,而是应用协议需要明确的行为选择。
6.3 普通消息是否也发给发送者
会。转发目标包括当前用户表中的发送者本人。这可以让客户端看到服务端实际转发的内容,便于验证"自己的消息确实经过服务器"。
如果希望排除发送者,在循环里比较端点并跳过即可。改变的是应用转发规则,不是 UDP 通信能力。
6.4 在线表为什么会留下过期地址
UDP 不提供连接断开通知。如果客户端被强制关闭、机器断电,或 QUIT 丢包,服务器无法仅凭"最近没收到消息"立刻证明它已经离线。
当前教学版没有心跳和过期清理,所以可能留下过期端点。真实服务需要设计租约、心跳与失效策略,还应限制成员数量;否则用户表可能持续增长。
6.5 为什么 sendto 成功不能证明用户在线
本地发送成功不代表远端应用收到了数据。对没有接收者的目标,Linux 还可能在后续操作中报告异步网络错误,但不能把每次成功发送都当成实时在线确认。
因此聊天室成员管理仍然需要应用协议。Socket 的返回值解决本次系统调用的问题,不能自动回答用户身份与在线状态的问题。
七、remove_if 为什么还要搭配 erase
用户退出后,需要从 std::vector<Endpoint> 中移除对应端点。课程最后补充了 remove_if,这正好解释一个很常见的 C++ 误区。

7.1 remove_if 做的是逻辑移除
假设容器内容是:
text
[A, B, B, C]
执行"去掉 B"的 remove_if 后,应保留的元素被压到前面,得到逻辑上的有效区间:
text
[A, C | 尾部仍占用的元素位置]
↑ new_end
此时 vector.size() 仍然是 4。new_end 是新的逻辑结束位置,不是容器原本的 end()。
7.2 尾部内容不应该继续解释
PDF 中有"被删除元素移动到尾部"的解释。更准确的说法是:算法把保留元素移动到前面,新的逻辑末尾之后仍然有对象,但它们的具体值不应被用来推断"删除集合"。
尤其对移动语义对象,尾部可能处于移动后的状态。算法的有效结果是 [begin, new_end),而不是"尾部一定完整保存了所有被删除元素"。
7.3 erase 才改变容器大小
cpp
auto new_end = std::remove_if(users.begin(), users.end(),
[&peer](const Endpoint& user) {
return user == peer;
});
users.erase(new_end, users.end());
这就是常说的 erase-remove 惯用法。前半段把有效元素整理好,后半段真正删除尾部区间并缩小容器。
虽然正常登记会避免重复用户,但这个写法仍会删除所有匹配端点,行为很明确。vector 删除会使相关位置之后的迭代器失效,不要一边握着旧迭代器遍历,一边随意调用删除。
7.4 list 又有什么区别
PDF 的补充示例使用 std::list<std::shared_ptr<int>>。如果只想从 list 删除满足条件的节点,直接使用它的成员函数 list.remove_if 更贴合链表结构,它会实际删除节点。
通用算法 std::remove_if 则按迭代器操作元素,不会调用容器自己的节点删除接口。学习时要区分算法名 和容器成员函数名,它们看起来相似,职责不同。
八、完整的聊天室服务器
为突出路由逻辑,服务器仍采用一个线程顺序接收、顺序更新用户表、顺序发送。这样用户表只由一个线程访问,先把正确性讲清楚。
8.1 chat_server.cpp
cpp
#include "udp.hpp"
#include <algorithm>
#include <iostream>
#include <vector>
class Route {
public:
void Handle(const UdpSocket& socket, const std::string& message, const Endpoint& peer) {
if (message.size() > 1024) {
socket.SendTo("[system] message exceeds 1024 bytes", peer);
return;
}
const bool exists = std::find(users_.begin(), users_.end(), peer) != users_.end();
if (message == "QUIT") {
if (!exists) return;
users_.erase(std::remove_if(users_.begin(), users_.end(),
[&peer](const Endpoint& user) { return user == peer; }), users_.end());
Broadcast(socket, "[system] " + peer.Text() + " left");
return;
}
if (!exists) users_.push_back(peer);
if (message == "JOIN") {
if (!exists) Broadcast(socket, "[system] " + peer.Text() + " joined");
return;
}
Broadcast(socket, peer.Text() + "# " + message);
}
private:
void Broadcast(const UdpSocket& socket, const std::string& message) const {
for (const auto& user : users_) {
try { socket.SendTo(message, user); }
catch (const std::exception& error) { std::cerr << error.what() << '\n'; }
}
}
std::vector<Endpoint> users_;
};
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " <port>\n";
return 1;
}
try {
UdpSocket socket;
socket.BindAny(ParsePort(argv[1]));
Route route;
std::cout << "chat listening at " << socket.Local().Text() << std::endl;
for (;;) {
std::string message;
Endpoint peer;
try {
if (socket.Receive(message, peer)) route.Handle(socket, message, peer);
} catch (const std::length_error& error) {
std::cerr << error.what() << '\n';
}
}
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 2;
}
}
Route 对外只暴露 Handle,内部保存用户表。服务器主函数负责创建 Socket 与接收消息,再把内容和来源交给路由对象。
8.2 这份代码的三个设计选择
第一,用户表只在路由对象内部维护。 上层不会随意插入、删除端点,这让成员规则集中在一个位置。
第二,单个目标发送失败时,继续尝试其他目标。 否则用户表里的一个异常地址,可能让整次转发提前终止。但发送错误不会自动证明该用户已退出,所以这里没有立刻删除它。
第三,正文限制与底层容量分别处理。 接收层最多处理 4096 字节数据报,聊天业务允许正文最多 1024 字节,添加源地址前缀之后仍在发送容量内。二者不是同一个上限。
8.3 复杂度随用户数增长
当前用户表是 vector:检查是否存在平均需要扫描,广播要遍历所有成员。一次消息处理整体为 O(n),其中 n 是成员数。
对教学聊天室,这种实现直观。真实服务可以用哈希结构提高登记查询效率,但广播本身仍要面向多个接收者,不能靠换一个容器就变成 O(1)。
九、多线程客户端的收发与退出
字典客户端可以"发一次、等一次"。聊天室则不能这样:当用户正在输入时,其他人的消息也可能到达;当自己没有发消息时,也应能持续接收。
因此我们把客户端分成两个执行流程:
- 主线程:读取终端输入并发送;
- 接收线程:等待服务器转发并显示。

9.1 一个 Socket 为什么可以同时收发
UDP Socket 同时具备发送和接收能力。一个线程执行 sendto,另一个线程执行 recvfrom,可以形成独立收发流程。
但"网络允许同时收发"不意味着"程序里所有共享状态都没有多线程问题"。用户表、停止标志、任务队列、终端输出、Socket 的销毁顺序,都需要按各自的共享方式分析。
我们采用一个发送流程、一个接收流程,Socket 配置在启动线程前完成,关闭发生在线程退出后,避免把对象生命周期与系统调用并发搅在一起。
9.2 为什么启动接收线程前先绑定
上篇客户端先发送,系统自动选择端口,然后接收。聊天室客户端有独立接收线程,如果把端口选择时机完全留给线程运行顺序,流程不够清晰。
因此在启动前执行 BindAny(0),明确获得本地端口,再发出 JOIN,最后启动接收线程。服务器收到 JOIN 后就可以向这个已确定的端点转发消息。
JOIN 仍然可能丢包。本文不保证静默用户一定成功登记;用户后续发送普通消息也会触发登记。若业务要求可靠登录,应加确认、重试和会话标识。
9.3 接收线程为什么可能无法 join
假设主线程准备退出,设置 stop = true。如果接收线程已经阻塞在无期限的 recvfrom 中,它还没有机会再读一次停止标志。
此时主线程调用 join,就可能一直等着。仅仅增加一个布尔变量,并没有解决阻塞调用如何醒来的问题。
本文设置 200 毫秒接收超时,让接收线程定期返回循环检查停止状态。这个值服务于演示退出流程,不是精准的退出时间保证。
9.4 为什么停止标志使用 atomic
两个线程都会访问停止状态:主线程写入,接收线程读取,有时接收线程也会写入。普通 bool 在这种并发访问下会产生数据竞争。
cpp
std::atomic<bool> stop{false};
这里不要求通过停止标志发布其他复杂共享数据,默认原子操作足以表达这一简单状态。volatile 不能替代线程同步。
9.5 chat_client.cpp
cpp
#include "udp.hpp"
#include <atomic>
#include <iostream>
#include <thread>
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr << "Usage: " << argv[0] << " <server-ip> <port>\n";
return 1;
}
try {
Endpoint server(argv[1], ParsePort(argv[2]));
UdpSocket socket;
socket.BindAny(0); // Receive thread starts with a known local port.
socket.ReceiveTimeout(200);
socket.SendTo("JOIN", server);
std::cout << "local endpoint: " << socket.Local().Text() << std::endl;
std::atomic<bool> stop{false};
std::thread receiver([&] {
while (!stop.load()) {
try {
std::string message;
Endpoint peer;
if (socket.Receive(message, peer) && peer == server)
std::cerr << message << std::endl;
} catch (const std::length_error& error) {
std::cerr << error.what() << '\n';
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
stop.store(true);
}
}
});
int status = 0;
try {
std::string line;
while (!stop.load() && std::getline(std::cin, line)) {
if (line == "QUIT") break;
if (line == "JOIN") {
std::cout << "JOIN is reserved by the chat protocol\n";
continue;
}
if (line.size() > 1024) {
std::cout << "message exceeds 1024 bytes\n";
continue;
}
socket.SendTo(line, server);
}
socket.SendTo("QUIT", server); // Best effort; UDP has no delivery acknowledgement.
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
status = 2;
}
stop.store(true);
receiver.join(); // Socket stays alive until receiver has finished.
return status;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 2;
}
}
9.6 退出的顺序为什么这样安排

退出时的正常顺序是:
text
输入 QUIT 或出现 EOF
→ 尽力发送 QUIT
→ 设置 stop
→ 接收线程从等待中返回并结束
→ 主线程 join
→ Socket 对象析构关闭 fd
Socket 在接收线程完成前保持存活。不要让主线程先关闭它,再指望另一个线程恰好安全醒来;描述符复用与阻塞调用行为会使问题更复杂。
发送 QUIT 不等于退出消息一定被服务器收到。客户端本地关闭可以完成,但服务器成员表是否删除,是另一个依赖协议交付的问题。
9.7 EOF 和后台接收错误也要考虑
std::getline 返回失败时,发送流程应结束,避免在 EOF 后不停发送旧字符串。示例会在 EOF 时尝试发送 QUIT。
接收线程发生严重错误时会设置停止标志,但它无法直接唤醒主线程已经阻塞的终端输入。此时主线程仍可能需要用户回车或产生 EOF,才能继续退出。这是当前终端演示版的明确边界。
需要完整协调终端与网络事件时,可以使用事件循环、poll 或额外唤醒机制,把输入、网络和关闭请求统一管理。相关 I/O 模型属于后续课程。
9.8 为什么终端显示仍可能交错
主线程向 stdout 输出提示,接收线程向 stderr 输出转发内容。两条输出流可以在终端中交错,收到消息时也可能打断正在输入的行。
这不表示数据报损坏,而是终端 UI 没有做重绘和同步。可以用统一输出锁、独立显示窗口或终端界面库改善体验,不能通过修改 UDP 协议来解决光标位置问题。
十、从单线程服务走向线程池
PDF 中给出了把聊天路由任务交给线程池的思路,并省略了池实现。这个方向需要理解,但不能把代码片段原样拼起来,就认为已经完成并发服务器。

10.1 std::bind 不会立即执行任务
类似下面的表达式:
cpp
auto task = std::bind(&Route::Handle, &route,
std::cref(socket), message, peer);
构造的是一个以后可以执行的可调用对象,不是立即调用路由。把它放进任务队列后,工作线程会在未来某个时刻运行。
因此必须回答:那时候 route 和 socket 还在吗?message 和 peer 保存的是自己的值,还是某次收包循环里的临时引用?
10.2 请求和来源要按任务保存
接收循环每一轮都会更新来源地址。如果异步任务引用了同一个会被下一轮修改的 peer,执行时就可能把 A 的消息发给 B。
通常应让任务拥有消息和端点的独立副本:
cpp
auto task = [message, peer, &route, &socket] {
route.Handle(socket, message, peer);
};
这个片段只展示捕获方式,不是可直接加入当前项目的线程池版本。引用的服务对象仍需满足生命周期要求,用户表也仍需同步。
10.3 用户表会发生什么数据竞争
多个工作线程同时调用路由,可能出现一个线程遍历用户表,另一个线程插入或删除。vector 扩容会使旧位置失效,并发读写本身也可能产生未定义行为。
"只有一个 UDP Socket"并不能保护这个用户表。真正共享的是容器内存,保护方案必须围绕它设计。
一种方案是用锁保护用户表更新,并在锁内生成接收目标快照,然后释放锁再逐个发送。这样减少持锁时的 I/O,但要接受"快照中的用户可能已经退出"等语义。
另一种方案是让一个线程负责成员状态和路由,其他线程只处理独立业务,再把结果提交回来。它通常更容易维持成员变化的顺序。
10.4 加锁也不能自动保证消息顺序
即使用户表访问安全,多线程任务仍可能按不同次序执行。JOIN、聊天正文、QUIT 的处理顺序可能与接收顺序不同,而 UDP 本身还可能让网络到达顺序发生变化。
因此并发设计要区分两件事:内存访问正确 和业务顺序正确。需要严格顺序时,应增加序号、队列或会话状态规则,不能只加一把互斥锁就结束设计。
10.5 为什么随附代码保留单线程服务器
字典查询和简单转发足够轻量,单线程实现更容易观察通信与用户表语义。本文提供可构建的基线,同时把线程池的共享状态与生命周期问题说明白。
如果业务开始执行耗时计算或磁盘任务,再根据实际瓶颈引入并发。并发结构应该服务于负载需求,而不是为了让代码看起来更高级。
十一、编译测试与工程边界
11.1 本篇完整项目
text
code/
├── udp.hpp
├── udp_server.hpp
├── udp_client.cpp
├── dict_server.cpp
├── dict.txt
├── chat_server.cpp
├── chat_client.cpp
├── Makefile
├── README.md
└── 验证记录.md
进入 code 后执行 make。聊天室客户端的编译命令包含 -pthread:
bash
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 -pthread chat_client.cpp -o chat_client
11.2 字典与聊天室分别测试
字典服务器先使用 8888 验证查询。完成后停止它,再启动聊天室,避免两个示例争用同一个 UDP 端口:
bash
./chat_server 8888
打开两个独立终端,分别执行:
bash
./chat_client 127.0.0.1 8888
观察两端打印的临时端口是否不同。让客户端 A 发消息,A 和 B 都应收到转发。让 A 输入 QUIT,B 应看到退出通知;B 后续发消息时,服务端不会再把 A 当作已登记目标。
11.3 用文件描述符重定向观察收发
PDF 提到用管道辅助演示。随附客户端将接收内容写到标准错误,可以在两个窗口中观察:
终端 A:
bash
./chat_client 127.0.0.1 8888 2>chat-received.log
终端 B:
bash
tail -f chat-received.log
终端 A 负责输入,终端 B 观察消息。2> 会截断已有日志,想保留旧内容时使用 2>>。这是终端实验,不是完整日志系统。
11.4 哪些行为值得专门验证
| 用例 | 预期观察 |
|---|---|
| 字典查询存在词条 | 返回 UTF-8 释义 |
| 字典查询不存在词条 | 返回 Unknown |
| CRLF 词典与重复键 | 无额外 \r,后面的值覆盖前面的值 |
| 词典路径不存在 | 启动失败并报告路径 |
| 两个聊天室客户端 | 源端口不同,正文能同时收到 |
重复 JOIN |
不产生重复用户或重复转发 |
| 空聊天消息 | 仍是一个有效消息,有端点前缀 |
输入 QUIT 或 EOF |
接收线程结束,客户端退出 |
| 超长正文 | 业务拒绝,不按截断内容转发 |
| 退出后另一用户发消息 | 不再发送给已删除端点 |
本篇代码在 Ubuntu 24.04 上完成编译和回环通信验证,字典、路由和客户端退出的结果见 code/验证记录.md。这些检查不覆盖公网丢包下的可靠登录、异常离线清理或大规模压力测试。
11.5 跑通之后还缺什么

当前字典与聊天室是理解 Socket 的实验项目。进一步演进时,可以围绕以下问题设计:
- 消息识别:增加版本、消息类型和请求编号,避免控制字与正文冲突;
- 可靠处理:明确重试、确认与去重规则,识别迟到响应;
- 成员管理:增加心跳、会话令牌、过期清理与容量限制;
- 可信入口:验证身份,控制消息大小与发送频率;
- 退出与并发:统一 I/O 事件、资源所有权和任务收尾顺序;
- 可观察性:记录必要的时间、来源、消息类型与错误状态。
这些能力应该逐个加入,并验证新增规则。不要因为底层叫 UDP,就把应用状态管理的问题都交给网络栈。
总结
UDP 的收发接口很少,但它可以承载不同的应用结构。
网络字典展示了"接收请求 → 回调业务 → 生成响应 → 回给来源"。把查询从通信循环中取出来后,网络层能够复用,词典的数据加载和错误规则也更加独立。
聊天室展示了"接收消息 → 更新成员 → 选择目标 → 逐个转发"。服务器保存的是客户端端点,所谓广播是应用层循环发送;用户退出需要协议约定,不能依赖 UDP 提供断开通知。
多线程客户端让输入与接收独立进行,同时也暴露了生命周期问题:停止标志要同步,阻塞接收要能返回,线程结束后才能释放 Socket。remove_if 则提醒我们把逻辑边界和容器物理大小分开理解。
至此,课程中的三个版本已经连成一条清晰路径:回显验证通信,字典引入业务,聊天室引入多目标路由和独立收发。 后续继续学习 TCP、协议与 I/O 模型时,可以用这套结构作为比较基线。