33 打破时域耦合

先画活动依赖图,再拆出真正独立的工作,以并行执行缩短关键路径而不改变语义。

学习目标

  • 能从一个工作流中画出输入、输出、先后依赖和可并行活动
  • 能在不改变结果语义的前提下拆出并行候选,并指出必须保留的同步点
  • 能用关键路径、首个偏差和重放记录判断并行化是否真的改善了系统

先别急着把任务丢进线程池

本页依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版·20周年版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 33 打破时域耦合提示56:通过分析工作流来提高并发性。正文、代码、图示、实验和练习均为重新设计的教学材料,不复制原书正文、插图或答案。

“并行”不是把每个函数都改成异步。它首先要回答:后一个活动究竟读取了前一个活动的什么结果?如果这个结果还没有提交,两个活动就仍然存在时序约束。只有把依赖写出来,才有机会证明某两项工作可以互换或同时开始。

五个节点把时序约束摊开

<Term def="两个活动之间必须按先后发生的约束,通常因为后者读取前者尚未提交的结果。">时域耦合</Term>的危险在于它常常藏在共享变量、文件提交或外部副作用里。比如“读取订单 → 计算运费 → 发票落库”存在真实先后;“读取订单”和“准备通知模板”可能只共享订单编号,因此有机会并行。

用一张 <Term def="把活动、输入、输出和先后关系画成节点与有向边的图。">依赖图</Term>把隐藏约束显形。边上写“传递订单摘要”“等待版本确认”之类的对象,而不是只画一根无名箭头。图完成后,才能筛选 <Term def="在输入不互相等待、输出不冲突且失败可独立处理时,可以同批执行的活动组合。">并行候选</Term>

并行候选也不是终点。下游必须在 <Term def="把多个活动的已完成结果合并,并检查完整性后再允许流程继续的观察位置。">同步点</Term>读取一份完整快照;同步点不能靠“应该已经完成”的猜测。最后用 <Term def="依赖图中决定最短完成时间的最长依赖链;并行化只能减少其中可拆开的部分。">关键路径</Term>测量改动是否真正减少等待,而不是只让某个局部计时器变小。

依赖图先行:并行不是把顺序删掉每个节点都要说明传递的事实和可以停下来的条件1活动写清输入与输出当前观察点2依赖图只保留真实先后等待证据3并行候选挑出互不阻塞的工作等待证据4同步点合并前检查条件等待证据5关键路径测量最长依赖链等待证据同步点只合并已满足前置条件的结果,关键路径仍需实测
专属图示:从活动清单推进到关键路径,每一步都能指出首个不满足条件的节点。

从工作流识别可并行区

假设发布流程包含四项活动:生成静态资源、运行 API 测试、读取发布配置、切换线上版本。静态资源和 API 测试都依赖提交后的代码,但互不读取对方结果;配置读取是切换前置条件;线上切换必须等三项输入完整。于是可以把前两项放进同一批,却不能把切换也提前。

先预测:如果把“生成静态资源”和“运行 API 测试”并行,关键路径会减少多少?再逐项记录输入身份、开始条件、完成事件和拒绝原因。预测不是答案,它只是让我们有东西可以和实际首差比较。

用一个不变量保护语义

并行化的验收不变量是:相同提交、相同配置和相同测试版本,最终发布候选必须包含同一组资源与测试结论;只允许完成时间变化。若结果中少了一份资源,哪怕耗时更短也应拒绝。

type ActivityResult = {
  name: "assets" | "tests" | "config";
  commit: string;
  completeAt: number;
  digest: string;
};
 
function readyToRelease(results: ActivityResult[]) {
  return results.length === 3 && new Set(results.map((item) => item.commit)).size === 1;
}

这段检查只回答“输入是否齐全且属于同一次提交”,不替代业务验证。切换动作仍然要在同步点以后执行;如果任一活动超时或摘要不一致,就记录首差并拒绝候选。

证据矩阵:速度下降也可能是语义保护只改变调度方式;输入、业务规则和观察点保持不变证据字段串行基线合法并行错误并行活动A → B → CA 与 B 同批B 偷读 A 的半成品依赖2 条强边1 条合并边缺少输入声明首差等待同步点并行候选被拒绝关键路径12 分钟8 分钟不能测量只有“合法并行”同时满足顺序无关、结果可重放、关键路径可测量
专属图示:关键路径数字必须和依赖证据一起出现,不能只报一个更快的总时长。

三步实验:从串行基线到可验证并行

下面的步骤只改变调度方式,提交、配置和活动输出保持不变。动手试之前先写下你的预测:选择“合法并行”时,哪一条边会消失?选择“错误并行”时,首个偏差应该在哪个节点出现?

分步1 / 3

1. 画活动和依赖边

依赖图先行:并行不是把顺序删掉每个节点都要说明传递的事实和可以停下来的条件1活动写清输入与输出当前观察点2依赖图只保留真实先后等待证据3并行候选挑出互不阻塞的工作等待证据4同步点合并前检查条件等待证据5关键路径测量最长依赖链等待证据同步点只合并已满足前置条件的结果,关键路径仍需实测
专属图示:从活动清单推进到关键路径,每一步都能指出首个不满足条件的节点。

列出每项活动的输入、输出和提交事件。若 B 读取 A 的临时文件,边就必须保留;若 B 只读取固定的提交摘要,边可以被重新评估。此步的产物是能被别人复画的依赖图,不是“看起来差不多”的箭头。

可交互验证:速度和语义必须一起看

先选择“合法并行”,观察同步点仍然存在且关键路径从 12 分钟降到 8 分钟;再选择“错误并行”,观察流程在依赖图处拒绝。最后点击“重置”,确认实验回到串行基线。这个实验记录的是调度方式改变后的首差,不是一个把不同结果压成分数的排名。

时域耦合实验台

先选一个调度样本,再观察首差和关键路径。

可复核
基线:所有活动按声明顺序完成活动 A活动 B同步点活动 C首个偏差:无:基线顺序完整且可重放

恢复动作:回到原始输入,重建依赖图并重放。

正常、边界与单故障记录

记录唯一变化预期停点必须留下的证据
正常两项活动输入互不等待同步点收到完整结果提交摘要、依赖边、关键路径
边界一项活动恰好在同步点前完成接受并合并,或明确超时完成事件、期限、合并理由
单故障让一项活动返回半成品摘要并行候选或同步点拒绝首差、错误上下文、重放记录

边界条件必须具体到期限、版本或输出完整性,不能写成“系统比较忙”。一旦失败,先锁定第一个不满足条件的节点,再决定补偿、重试或回退;最终版本看似正确,并不能抹掉中间发生过的错误副作用。

迁移到队列、数据库与团队流程

在任务队列里,时域耦合可能藏在消息可见性和确认顺序;在数据库里,可能藏在事务提交和读一致性;在团队流程里,则可能藏在“评审通过后才允许部署”的工作约束。迁移时分别标出数据边界、确认边界、取消语义和观察点,不能因为工具提供了并发 API 就自动删除顺序。

对于可重复投递的活动,给输出一个稳定的 <Term def="同一输入重复执行时,不会产生额外错误副作用的性质。">幂等性</Term>键。它不能代替依赖图:幂等只降低重复执行的风险,不能让一个依赖未满足的读取突然变得安全。只有当输入、输出和失败回退都能重建时,才可以把结论迁移到新的运行环境。

本章回顾

  • 先画依赖图,再判断哪些活动真的可以同时开始。
  • 并行候选必须保留同步点,并用明确输入检查完整结果。
  • 关键路径是最长依赖链,速度数字必须和语义证据绑定。
  • 单故障先停在首差,再从原始输入重建和重放。

名词解释

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

时域耦合

两项工作必须按先后运行的约束,通常因为后项要读取前项的结果。

依赖图

用节点表示活动、用有向边表示“谁必须先完成”的地图。

并行候选

输入不互相等待、输出不冲突,因而可以同批执行的一组活动。

同步点

等多个结果都完成并检查完整后,流程才继续的位置。

关键路径

决定最短总完成时间的最长依赖链,链上等待不能被随意删除。

幂等性

同一输入重复执行时,不会额外制造错误副作用的性质。

可验证练习

练习

问题 1:画依赖图。 发布流程有“生成资源”“运行测试”“读取配置”“切换版本”四项活动。请写出每项活动的输入、输出和完成条件,并指出哪两项可以并行。

问题 2:修改调度代码。 下面的写法把切换动作提前了。请改成“候选全部完成后才切换”,并说明校验失败时当前版本是否改变。

await Promise.all([buildAssets(), runTests()]);
activateRelease();
await readConfig();

问题 3:复核错误并行。 一次测试从 12 分钟变成 8 分钟,但下游偶尔读到半成品。请列出重放所需的证据,并给出一个证明恢复成功的观察结果。

前后导航

讨论

评论区加载中…