【Linux 网络编程】UDP Socket 编程(下):回调封装、网络字典与多线程聊天室

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位:在已经跑通 UDP 回显的基础上,把通信循环与业务逻辑分开,并用网络字典和聊天室理解请求/响应、消息路由与多线程收发。

💡 学习目标:会设计业务回调;能加载词典并查询;理解一个 UDP Socket 如何服务多个客户端;掌握聊天室用户表、退出协议与接收线程的生命周期。

📌 实现范围:Linux、IPv4、C++17。服务器保留单线程顺序处理,聊天室客户端采用主线程发送、独立线程接收;线程池作为设计讨论,不假装省略代码也能直接运行。

如果上篇解决的是"两个进程怎样交换消息",下篇要解决的就是"收到消息后应该由谁处理,处理结果又应该发给谁"。网络接口没有变化,应用设计开始发生变化。


文章目录


一、从回显走向业务服务器

回显服务器收到 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 模型时,可以用这套结构作为比较基线。

相关推荐
看-是灰机1 小时前
Milvus向量数据库
linux·运维·人工智能·机器学习·milvus
下辈子不要当码农1 小时前
Linux驱动学习(1)
linux·运维·学习
HelloWorld工程师1 小时前
不改动任何历史配置,也能让 SSL 证书自动续期
linux·服务器·网络协议·https·ssl
啦啦啦啦啦zzzz1 小时前
分布式存储----缓存组
linux·服务器·c++·分布式·缓存
艾芯微科技1 小时前
钰泰 ETA1654FSG|SOT563 4A、18V 宽压自适应 COT 同步降压 DC-DC,CPE / 机顶盒优选
网络·单片机·嵌入式硬件·集成测试·51单片机
znnnk1 小时前
【网络优化】减少B站直播卡顿的上行占用:AdGuard nowebrtc规则 / WebRTC Control 实测
服务器·网络·webrtc·直播·bilibili·adguard
zxanz11 小时前
泛域名 SSL 证书怎么申请?需要准备什么?
网络·网络协议·ssl
2401_868534781 小时前
论“网络去NAT”的规划与设计
服务器·网络
水饺编程1 小时前
第2章:Linux 基础,whoami 命令
linux·c语言·汇编