第2章 多线程
区分进程与线程,比较事件、临界区、互斥、信号量、互锁和可等待定时器,并用所有权与锁顺序消除竞态和死锁。
从第2章的核心问题开始
线程提高的是并发调度能力,不会自动提高正确性。游戏服务器应先决定状态由哪个线程拥有,再选择消息传递或最小同步原语。全局变量让共享变得容易,却把竞态、可见性和死锁扩散到所有路径。
官方目录命中与来源边界
本页明确覆盖:线程基础、进程、线程、线程的应用、Windows下的多线程处理、随机矩形、多工模拟程序、线程间的通信与同步、全局变量、事件、临界区、互斥、信号量、互锁访问、可等待定时器、线程死锁。这些名称来自 2007 年版公开书目。公开目录没有披露的细分内容会标为教学展开;现代 API、安全要求和部署方法用于帮助今天的读者验证原机制,不代表原书在 2007 年已经使用这些实现。
| # | 机制或证据边界 | 可观察解释 |
|---|---|---|
| 1 | 进程与线程 | 进程提供隔离的地址空间和故障边界;线程共享进程资源,切换成本更低,但任何未受控写入都可能破坏同一地址空间。 |
| 2 | 应用不是越多越好 | 网络接收、逻辑更新、数据库写入和日志可以并行,但线程数量必须由阻塞特征、核心数和队列背压决定。 |
| 3 | 同步原语有不同语义 | 事件表达状态通知,临界区保护进程内短临界段,互斥可跨进程,信号量限制并发额度,互锁适合单值原子更新,定时器触发计划工作。 |
| 4 | 全局变量是风险放大器 | 共享可变全局状态隐藏所有权。更稳妥的方案是单线程拥有状态,其他线程通过有界队列提交命令。 |
| 5 | 死锁是等待图的环 | 统一锁顺序、缩短临界区、禁止持锁阻塞 I/O,并在测试中制造反向获取,才能证明等待图不形成闭环。 |
核心概念与因果关系
、、、、。这些概念不能只停留在定义上:每个概念都要落到输入、内部状态、输出和失败证据。读者应能指出哪个模块拥有状态、哪个边界拒绝非法输入、哪个指标证明系统没有把成本推给下游。
进程与线程
进程提供隔离的地址空间和故障边界;线程共享进程资源,切换成本更低,但任何未受控写入都可能破坏同一地址空间。
验证时固定软件版本、配置和输入规模,先保存正常轨迹,再只改变一个边界条件。最终结果相同并不代表过程正确;必须比较阶段状态、队列水位、错误分类和恢复动作。
应用不是越多越好
网络接收、逻辑更新、数据库写入和日志可以并行,但线程数量必须由阻塞特征、核心数和队列背压决定。
验证时固定软件版本、配置和输入规模,先保存正常轨迹,再只改变一个边界条件。最终结果相同并不代表过程正确;必须比较阶段状态、队列水位、错误分类和恢复动作。
同步原语有不同语义
事件表达状态通知,临界区保护进程内短临界段,互斥可跨进程,信号量限制并发额度,互锁适合单值原子更新,定时器触发计划工作。
验证时固定软件版本、配置和输入规模,先保存正常轨迹,再只改变一个边界条件。最终结果相同并不代表过程正确;必须比较阶段状态、队列水位、错误分类和恢复动作。
全局变量是风险放大器
共享可变全局状态隐藏所有权。更稳妥的方案是单线程拥有状态,其他线程通过有界队列提交命令。
验证时固定软件版本、配置和输入规模,先保存正常轨迹,再只改变一个边界条件。最终结果相同并不代表过程正确;必须比较阶段状态、队列水位、错误分类和恢复动作。
死锁是等待图的环
统一锁顺序、缩短临界区、禁止持锁阻塞 I/O,并在测试中制造反向获取,才能证明等待图不形成闭环。
验证时固定软件版本、配置和输入规模,先保存正常轨迹,再只改变一个边界条件。最终结果相同并不代表过程正确;必须比较阶段状态、队列水位、错误分类和恢复动作。
六阶段证据链
可复现实验
让四个生产线程向一个逻辑线程提交玩家移动命令。先预测直接修改全局玩家表与有界队列两种方案的差异,再记录命令总数、最终状态、锁等待、队列高水位和关闭耗时。随后故意反向获取两把锁,验证看门狗能定位等待环。
最小实现
class CommandQueue {
public:
bool push(Command command) {
std::unique_lock lock(mutex_);
notFull_.wait(lock, [&] { return closed_ || queue_.size() < capacity_; });
if (closed_) return false;
queue_.push_back(std::move(command));
notEmpty_.notify_one();
return true;
}
private:
std::mutex mutex_;
std::condition_variable notEmpty_, notFull_;
std::deque<Command> queue_;
};边界处理
void transfer(Player& from, Player& to, int amount) {
std::scoped_lock lock(from.walletMutex, to.walletMutex);
if (from.gold < amount) throw InsufficientFunds{};
from.gold -= amount;
to.gold += amount;
}证据契约
struct ConcurrencyEvidence {
std::uint64_t acceptedCommands;
std::uint64_t appliedCommands;
std::size_t queueHighWatermark;
std::chrono::microseconds maxLockWait;
bool cleanShutdown;
};实现代码只是实验的一部分。每次运行还要记录构建版本、配置摘要、样本类型、输入规模、首偏离节点、恢复动作和最终决策。若无法用相同输入重放,结论不能进入交付清单。
两个必须主动制造的失败
验收矩阵
| 维度 | 正常样本 | 边界样本 | 失败样本 | 通过条件 |
|---|---|---|---|---|
| 输入 | 合法且固定 | 容量、顺序或时序临界值 | 畸形、重复或中断 | 分类清晰且可重放 |
| 状态 | 按六阶段推进 | 达到预算但不越界 | 在首偏离节点停止 | 无隐式跨层写入 |
| 输出 | 结果和证据一致 | 退化可解释 | 拒绝、补偿或回滚 | 不变量恢复 |
| 交接 | 他人按记录复验 | 干净环境复验 | 删除隐含依赖 | 结论仍成立 |
本页签发不变量是:每个可变游戏状态只有一个写入所有者;关闭阶段不丢已确认命令;锁依赖图无环且队列达到上限时会施加背压。 任何演示、截图或单次成功都不能替代它。
练习
小结
- 进程与线程:进程提供隔离的地址空间和故障边界;线程共享进程资源,切换成本更低,但任何未受控写入都可能破坏同一地址空间。
- 应用不是越多越好:网络接收、逻辑更新、数据库写入和日志可以并行,但线程数量必须由阻塞特征、核心数和队列背压决定。
- 同步原语有不同语义:事件表达状态通知,临界区保护进程内短临界段,互斥可跨进程,信号量限制并发额度,互锁适合单值原子更新,定时器触发计划工作。
- 全局变量是风险放大器:共享可变全局状态隐藏所有权。更稳妥的方案是单线程拥有状态,其他线程通过有界队列提交命令。
- 已用正常、边界和失败样本验证“每个可变游戏状态只有一个写入所有者;关闭阶段不丢已确认命令;锁依赖图无环且队列达到上限时会施加背压。”。
- 已把公开目录、历史语境和现代教学展开分开记录。