第6章 并发
从时域耦合与共享状态的根因出发,用工作流、所有权、角色和黑板组织并发。
学习目标
- 能从工作流中标出先后依赖、可并行边、状态所有者和消息边界
- 能把共享可变状态改成所有权、不可变消息或明确仲裁,并说明调用合同如何保持
- 能用正常、边界和失败记录重放并发结果,定位首个偏差并选择可验证的回退动作
并发先回答谁拥有决定
本章依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 第6章 并发。正文、代码、图示、实验和练习都是本课程重新设计的教学材料,不复制原书正文、插图或答案。
并发不是把同一段代码复制到更多线程。它先要求我们回答:哪些工作必须按顺序完成?哪份状态只有一个所有者?角色如何交换消息?哪个事实可以被另一个角色重新读取?本章以一个“订单校验—库存预留—通知发送”的工作流为例,比较并行切分、所有权、角色和黑板四种边界。
先画依赖,再谈速度
<Term def="两个动作之间必须先后发生的约束;后一个动作读取前一个动作产生的结果。">时域耦合</Term>会把本来可以并行的工作绑在同一条时间线上。先把每个任务的输入、输出和拒绝条件写出来,再拆出真正独立的批次;不能因为两个函数名字不同,就假定它们互不影响。
本章的工作流是:读取订单、校验库存、预留库存、发送通知、记录事实。校验库存和准备通知模板可以并行,但预留库存必须在校验通过后发生。图中的每条边都标出传递的对象,避免把“跑得更快”误认为“语义不变”。
共享状态为什么让结果变得不可解释
<Term def="多个执行者可以直接读写、且没有单一更新责任人的可变数据。">共享状态</Term>的问题不只是锁是否足够,而是结果不能从输入和明确顺序重建。两个角色同时修改余额、库存或重试次数时,即使测试偶尔通过,也无法说明哪个写入应该获胜。
角色、进程与消息边界
<Term def="拥有一份状态并通过消息提供能力的独立协作单元;调用者不直接触碰它的内部数据。">角色</Term>把状态和决定放在同一处。订单角色可以向库存角色发送 Reserve(orderId, items, version),但不能直接改库存表。角色的边界不是线程 API,而是责任和可观察消息的边界。
<Term def="描述事实、版本、请求者和期限的不可变传递物;接收者可以拒绝过期消息。">不可变消息</Term>让每次决定有可重放的输入。发送者不应在消息发出后偷偷修改同一个对象;接收者也不应把请求对象当成共享缓存。
type Reserve = {
orderId: string;
items: ReadonlyArray<{ sku: string; quantity: number }>;
stockVersion: number;
};
function reserve(message: Reserve, state: StockState): Decision {
if (message.stockVersion !== state.version) {
return { kind: "reject", reason: "stale-version" };
}
return canFulfil(message.items, state)
? { kind: "accept", next: applyReservation(message, state) }
: { kind: "reject", reason: "insufficient-stock" };
}角色模型的验收条件是:调用者只依赖消息合同;状态更新由一个所有者完成;拒绝消息包含原因;相同消息和相同版本可以重建同一个决定。若仍需到处读取内部字段,说明边界还没有真正建立。
黑板不是无主的全局变量
<Term def="由规则约束的事实空间;代理向其中贡献带来源的事实,其他代理按版本和权限消费。">黑板</Term>适合多个独立代理协作,但它必须有事实格式、来源、版本、过期规则和冲突仲裁。把所有数据塞进一个全局对象,只是扩大了共享状态,并没有得到黑板的可复查性。
一个订单黑板可以保存 StockChecked(orderId, version, result)、ReservationAccepted(orderId, version) 和 NotificationSent(orderId, messageId)。每个事实都带来源和时间;重复事实要幂等,过期事实要拒绝,冲突事实要进入仲裁队列,而不是由最后写入者悄悄覆盖。
分步实验:从并行候选到可重放事实
下面的实验只改变协作边界,输入订单、库存版本和通知模板保持一致。每一步先写预测,再推进图中的观察点;若失败,保存消息和首个偏差,不用最终库存数字倒推原因。
1. 标出依赖和可并行批次
记录每个动作读取和写入的对象。订单格式校验与通知模板准备可以并行;库存预留读取校验结果,因此不能跳过依赖边。预期是并行批次减少等待,却不改变提交顺序。
并发所有权实验台
只改变协作方式,观察状态所有者与首差。
恢复动作:回到同一输入,按所有者边界重放消息并比较首差。
正常、边界与失败记录
| 记录 | 唯一变化 | 预期停点 | 必须留下的证据 |
|---|---|---|---|
| 正常 | 独立输入、有效版本、单一所有者 | 全部消息完成 | 输入摘要、消息序列、事实版本 |
| 边界 | 两个请求争用同一版本 | 旧版本请求被拒绝 | 版本比较、拒绝理由、重试上限 |
| 失败 | 仅让所有者暂时不可用 | 消息进入可观察的回退路径 | 首个偏差、队列状态、补偿结果 |
边界不是“系统大概很忙”。它必须能指出容量、版本、期限或权限中的具体条件。失败记录也不能只写“偶现”;保存消息身份和调度窗口,独立复核者才能重放同一条路径。
迁移到真实系统的检查表
迁移到任务队列、数据库、移动端或 AI 辅助工作流时,分别记录延迟目标、重复投递策略、数据敏感性、取消语义和观察点。工具可以帮忙生成并发测试,但不能替团队决定状态所有者,也不能把没有证据的重试称为幂等。
最小证据包包括:输入摘要、代码和配置版本、消息序列、所有者、锁或队列策略、首个偏差、拒绝原因、恢复动作和用户可见结果。独立复核者只拿这些材料,不拿原作者的操作顺序;如果无法从材料重建决定,就应缩小结论范围。
本章回顾
- 先分析依赖,再把真正独立的工作放进同一批;并发速度不能替代语义合同。
- 共享可变状态需要所有者、版本和明确的消息边界;锁本身不是责任模型。
- 角色通过消息隐藏内部状态,黑板通过带来源的事实协调多个代理。
- 用正常、边界和失败记录重放首个偏差,恢复动作必须可解释、可验证。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 时域耦合
两个动作必须按先后顺序运行的约束。
- 共享状态
多个执行者可以直接读写的可变数据。
- 角色
拥有状态并通过消息提供能力的独立协作单元。
- 不可变消息
带有版本和期限、发送后不再原地修改的传递物。
- 黑板
由规则约束、记录来源和版本的事实空间。
可验证练习
练习
问题 1:画出依赖。 订单校验、库存预留和通知发送中,哪些动作可以并行?请写出每个动作的输入、输出和拒绝条件。
问题 2:消除共享写入。 两个 worker 同时执行 stock[sku] -= quantity。请改写设计,使结果可从消息和版本重建,并说明冲突时谁负责决定。
问题 3:复核失败记录。 一次压力测试出现重复通知,但最终库存正确。你会保存哪些证据,如何判断回退已恢复?