第6章 并发

从时域耦合与共享状态的根因出发,用工作流、所有权、角色和黑板组织并发。

学习目标

  • 能从工作流中标出先后依赖、可并行边、状态所有者和消息边界
  • 能把共享可变状态改成所有权、不可变消息或明确仲裁,并说明调用合同如何保持
  • 能用正常、边界和失败记录重放并发结果,定位首个偏差并选择可验证的回退动作

并发先回答谁拥有决定

本章依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 第6章 并发。正文、代码、图示、实验和练习都是本课程重新设计的教学材料,不复制原书正文、插图或答案。

并发不是把同一段代码复制到更多线程。它先要求我们回答:哪些工作必须按顺序完成?哪份状态只有一个所有者?角色如何交换消息?哪个事实可以被另一个角色重新读取?本章以一个“订单校验—库存预留—通知发送”的工作流为例,比较并行切分、所有权、角色和黑板四种边界。

先画依赖,再谈速度

<Term def="两个动作之间必须先后发生的约束;后一个动作读取前一个动作产生的结果。">时域耦合</Term>会把本来可以并行的工作绑在同一条时间线上。先把每个任务的输入、输出和拒绝条件写出来,再拆出真正独立的批次;不能因为两个函数名字不同,就假定它们互不影响。

本章的工作流是:读取订单、校验库存、预留库存、发送通知、记录事实。校验库存和准备通知模板可以并行,但预留库存必须在校验通过后发生。图中的每条边都标出传递的对象,避免把“跑得更快”误认为“语义不变”。

并发设计:先证明独立,再安排协作速度不是验收标准;每一步都要留下状态所有者与失败边界1分析依赖找出必须先完成的边当前观察点2并行切分只并行独立工作等待证据3隔离状态每份状态有所有者等待证据4消息协作用消息传递决定等待证据5事实协调黑板保存可复查事实等待证据完成协作前,先能指出谁拥有状态、消息何时过期以及事实如何重放
专属图示:并发证据沿着依赖边推进,不能用最终速度替代因果记录。

共享状态为什么让结果变得不可解释

<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)。每个事实都带来源和时间;重复事实要幂等,过期事实要拒绝,冲突事实要进入仲裁队列,而不是由最后写入者悄悄覆盖。

证据矩阵:先找首差,再选择恢复三种样本共享输入,唯一变化必须能被复核者重放字段正常边界故障输入批次job-A / job-B同一资源重复写入所有者各自角色明确仲裁者无人负责首差依赖等待状态冲突恢复提交结果串行回退重放消息并发完成不等于结果正确;所有权、边界与回退也要成为记录的一部分。
专属图示:故障列保留首差,不把竞态结果改写成“偶尔通过”。

分步实验:从并行候选到可重放事实

下面的实验只改变协作边界,输入订单、库存版本和通知模板保持一致。每一步先写预测,再推进图中的观察点;若失败,保存消息和首个偏差,不用最终库存数字倒推原因。

分步1 / 3

1. 标出依赖和可并行批次

并发设计:先证明独立,再安排协作速度不是验收标准;每一步都要留下状态所有者与失败边界1分析依赖找出必须先完成的边当前观察点2并行切分只并行独立工作等待证据3隔离状态每份状态有所有者等待证据4消息协作用消息传递决定等待证据5事实协调黑板保存可复查事实等待证据完成协作前,先能指出谁拥有状态、消息何时过期以及事实如何重放
专属图示:并发证据沿着依赖边推进,不能用最终速度替代因果记录。

记录每个动作读取和写入的对象。订单格式校验与通知模板准备可以并行;库存预留读取校验结果,因此不能跳过依赖边。预期是并行批次减少等待,却不改变提交顺序。

并发所有权实验台

只改变协作方式,观察状态所有者与首差。

可复核
可并行:两个角色拥有互不重叠的输入输入所有者并行工作状态边界结果证据首个偏差:并行切分:没有先后依赖的工作同时运行

恢复动作:回到同一输入,按所有者边界重放消息并比较首差。

正常、边界与失败记录

记录唯一变化预期停点必须留下的证据
正常独立输入、有效版本、单一所有者全部消息完成输入摘要、消息序列、事实版本
边界两个请求争用同一版本旧版本请求被拒绝版本比较、拒绝理由、重试上限
失败仅让所有者暂时不可用消息进入可观察的回退路径首个偏差、队列状态、补偿结果

边界不是“系统大概很忙”。它必须能指出容量、版本、期限或权限中的具体条件。失败记录也不能只写“偶现”;保存消息身份和调度窗口,独立复核者才能重放同一条路径。

迁移到真实系统的检查表

迁移到任务队列、数据库、移动端或 AI 辅助工作流时,分别记录延迟目标、重复投递策略、数据敏感性、取消语义和观察点。工具可以帮忙生成并发测试,但不能替团队决定状态所有者,也不能把没有证据的重试称为幂等。

最小证据包包括:输入摘要、代码和配置版本、消息序列、所有者、锁或队列策略、首个偏差、拒绝原因、恢复动作和用户可见结果。独立复核者只拿这些材料,不拿原作者的操作顺序;如果无法从材料重建决定,就应缩小结论范围。

本章回顾

  • 先分析依赖,再把真正独立的工作放进同一批;并发速度不能替代语义合同。
  • 共享可变状态需要所有者、版本和明确的消息边界;锁本身不是责任模型。
  • 角色通过消息隐藏内部状态,黑板通过带来源的事实协调多个代理。
  • 用正常、边界和失败记录重放首个偏差,恢复动作必须可解释、可验证。

名词解释

本章出现的专业名词,用大白话再讲一遍。

时域耦合

两个动作必须按先后顺序运行的约束。

共享状态

多个执行者可以直接读写的可变数据。

角色

拥有状态并通过消息提供能力的独立协作单元。

不可变消息

带有版本和期限、发送后不再原地修改的传递物。

黑板

由规则约束、记录来源和版本的事实空间。

可验证练习

练习

问题 1:画出依赖。 订单校验、库存预留和通知发送中,哪些动作可以并行?请写出每个动作的输入、输出和拒绝条件。

问题 2:消除共享写入。 两个 worker 同时执行 stock[sku] -= quantity。请改写设计,使结果可从消息和版本重建,并说明冲突时谁负责决定。

问题 3:复核失败记录。 一次压力测试出现重复通知,但最终库存正确。你会保存哪些证据,如何判断回退已恢复?

资料与写作方式声明

本章以程序员修炼之道权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

前后导航

讨论

评论区加载中…