从计划到代码:为什么 task graph 要写 read_set 和 write_set
任务编排的自然瓶颈
当计划中的任务数量超过五个之后,我开始遇到一个熟悉的瓶颈:选定了依赖顺序,但执行时总会出现"访问冲突"。
先说说一个让我印象深刻的例子。有一次我编排了一个博客发布工作流,有六个任务:拉取草稿、渲染 Markdown、生成标签索引、生成 RSS、压缩图片、部署到服务器。依赖关系理得很清楚——渲染在前,标签索引在后。但问题出在渲染和标签索引两个任务上。执行顺序是对的——标签索引在渲染之后运行。但标签索引任务读了一个文件 content/tags.yaml,而渲染任务也读了同一个文件——而且它顺手清理了文件中的空行,做了原地覆盖保存。标签索引任务拿到的 tags.yaml 已经被"悄悄修改"了,和 Git 仓库里的原始版本不一样。这不是并发问题——两个任务明明串行执行,顺序也没错。问题是 "执行顺序正确"不等于"数据依赖正确"。我只规定了谁先谁后,但没规定数据是怎么流动的。渲染任务和标签索引任务之间没有显式的数据契约,它们只是"都操作了同一个文件",而其中一个的副作用污染了另一个的输入。
最初的方案是加锁。但锁解决的是两个任务同时访问资源的问题。上面这个场景里两个任务根本不并行,加锁毫无意义。真正的根因是:我有义务告诉系统"这个任务会写这个文件",但我没说。 锁解决了并发冲突,解决不了因为依赖关系不细粒度导致的重复计算和意外副作用。
另一个常见的失败模式是缓存失效。假设任务 A 生成一个很大的中间结果文件 build/intermediate/layout.json,任务 B 读取它来渲染页面。后来你改了任务 C(完全不相关的任务),触发了一个全局重跑——因为系统中没有任何细粒度的缓存键,所以只要有任何一个任务变更,所有任务都会重跑。每次重跑任务 A 都要花 30 秒重新算 layout.json。如果任务图知道 A 的 write_set 只有 layout.json,而 B 的 read_set 只包含 layout.json 和 content/posts/ 目录,那么当 content/posts/ 下的文件没变化时,A 和 B 完全可以被缓存跳过。缺少 read_set 和 write_set,你的缓存策略只能是非黑即白的——要么全缓存(危险),要么全不缓存(慢)。
一个具体的问题场景
假设你有一个任务图,三个任务:
任务 1: 解析博文草稿目录 → 输出每篇文章的标题和标签
任务 2: 统计所有标签的出现频率 → 输出标签热力图数据
任务 3: 根据标签数据生成标签索引页
依赖关系:任务 1 → 任务 2 → 任务 3,看起来没问题。但实际运行中,任务 2 如果想获取某一篇文章的更多信息——比如发布时间——它需要重新解析博文目录吗?如果重新解析,就重复了任务 1 的工作;如果不解析,它拿不到额外信息。这是数据范围的缺口——你定义了顺序,但没有定义"任务 1 到底生产了什么数据、任务 2 到底需要什么数据"。更隐蔽的问题是:任务 1 结束后内存被回收,任务 2 是全新进程,你不能靠"任务 1 在内存中保留数据结构,任务 2 直接引用"——这是管道(pipeline)的思维,不是任务图(task graph)的思维。任务图里每个任务只能依赖"已经写入磁盘的文件",不能依赖"上一个任务还在内存里的东西"。
read_set 和 write_set 是什么
这个概念借鉴了数据库事务和并发控制理论。在数据库中,事务声明读集合和写集合,调度器据此判断事务之间是否冲突;在任务图里,同样的逻辑适用——只不过"数据项"变成了文件和目录。每个任务需要声明 read_set(读取哪些资源)和 write_set(创建或修改哪些资源),用 YAML 来描述:
tasks:
parse_drafts:
read_set: [content/posts/*.md, config/site.yaml]
write_set: [build/title_tag_map.json]
compute_tag_stats:
read_set: [build/title_tag_map.json, content/posts/*/metadata.yaml]
write_set: [build/tag_frequency.json]
generate_tag_page:
read_set: [build/tag_frequency.json]
write_set: [output/tags/index.html]
加上 read_set 和 write_set 之后,你可以清楚地看到:任务 2 需要的 metadata.yaml 不在任务 1 的输出范围内——这是数据缺口,在设计阶段就能发现;修改 metadata 时任务 1 不需要重跑(它的 read_set 没变),只有任务 2 重跑;任务 1 和任务 3 没有直接的数据依赖,任务 2 没变的话,任务 3 不该因为任务 1 的重跑而重跑。这种从"顺序推理"到"数据流推理"的转变,是任务图从手工编排走向自动化编排的关键一步。
数据流模型的深层逻辑
read_set 和 write_set 定义了一个有向数据流图。系统自动推导:如果 A.write_set ∩ B.read_set ≠ ∅,则 A → B(有数据依赖);如果 A.write_set ∩ B.write_set ≠ ∅,则冲突(需串行化);如果只有 read_set 相交,则无依赖(可并行)。有了 YAML 示例:
tasks:
fetch_remote_content:
read_set: [config/remote_sources.json]
write_set: [temp/remote/posts/*.md]
convert_notebooks:
read_set: [content/notebooks/*.ipynb]
write_set: [temp/converted/*.md]
merge_content:
read_set: [temp/remote/posts/*.md, temp/converted/*.md]
write_set: [build/content_index.json]
build_site:
read_set: [build/content_index.json, themes/current/*.html]
write_set: [output/public/]
fetch_remote_content 和 convert_notebooks 读写集合均无交集,完全可以并行执行——但手动画 DAG 很可能会给它们画一个顺序,白白浪费并行度。系统从数据流自动推导出并行机会:fetch_remote_content + convert_notebooks → merge_content → build_site。
它带来的三个实用好处
精准缓存: 任务检查 read_set 中每个文件的修改时间或哈希值,如果未变则跳过执行。对于 50 个任务的工作流,修改最底层的一个输入文件,可能只触发 3-5 个任务的重跑——其余任务全部命中缓存跳过。
冲突检测: 自动检测写-写冲突(write_set 相交则不能并行)、读-写冲突(A 正在写的文件被 B 读取则不能并行)、读-读冲突(无写交集则可并行)。最安全的做法是让每个任务的 write_set 唯一——数据从上游写入,流向下游读取,没有两个任务同时写同一个"水龙头"。
输入追溯: 当任务 3 的输出出现异常时,看 write_set 知道它做了什么,看 read_set 知道它依赖哪些输入,然后逐级向上追溯。配合 tg_inspect --upstream 命令可以输出完整的反向依赖树,调试从线性搜索变成图遍历——在复杂的生产系统中,这种可追溯性价值巨大。
为什么不在计划阶段声明会更麻烦
有些方案会说:"让工具自动推断 read_set 和 write_set 不就好了?"听起来很诱人,但自动推断有几个本质缺陷:第一,无法预知。 自动推断只能在任务运行时发生。你无法在任务执行前知道它会读哪些文件,也就无法在计划阶段进行冲突检测或缓存规划。等你发现"哦,这个任务居然写入了那个文件"的时候,可能已经产生了错误输出。第二,执行路径的分岔。 一个脚本可能因为输入数据的不同而走完全不同的代码路径:
if config["mode"] == "full":
files = list(Path("content/posts/").glob("**/*.md")) # 读全部
elif config["mode"] == "incremental":
files = [f for f in Path("build/changelist.txt").read_text().splitlines()] # 只读变更列表
自动推断如果只跑了一次"full"模式,缓存中就会记录 read_set = content/posts/**/*.md。下次跑"incremental"模式,缓存键不变,系统跳过了这个任务——但实际上这次需要的输入完全不同。read_set 的声明必须反映所有可能的执行路径,而自动推断只能反映本次执行的路径。第三,隐式写入。 日志文件、临时文件、缓存目录——很多脚本会在你意想不到的地方写入数据。全量自动推断要么过于保守(把所有读过的目录全部加入 read_set),要么遗漏(某些写入在非错误路径下没有被触发)。
在计划阶段声明,是一种强制性的精确化。 它逼着你在设计任务时就想清楚:"我到底需要什么输入,会产出什么输出。" 这比"先写了再说,运行时自动发现"的代价更低——因为在运行时发现问题,可能已经造成了数据污染或需要回滚。一个务实的方案是自动推断 + 人工确认:工具先通过静态分析或一次 dry-run 生成建议的 read_set 和 write_set,然后由人来审核和修正。这样结合了两者的优点。
下次我会怎么做
经过多次踩坑,我总结出四条原则。第一,每个任务在计划阶段必须包含 read_set 和 write_set 字段。 这是强制契约,不是可选优化。先写集合再写代码——如果你列不出来,说明还没想清楚任务边界,这反而是好事。系统用 read_set × write_set 矩阵自动推导数据依赖,用拓扑排序确定执行顺序,与手动声明的 deps 不一致时系统发出警告。第二,write_set 要区分"创建"和"修改",建议统一放在 build/ 目录下,每个任务使用唯一子路径。 代码中用 WriteMode(Enum): CREATE | OVERWRITE | APPEND 来区分三种写入模式,对应的缓存清理策略各不相同。
第三,read_set 中文件路径的通配符要谨慎,默认最大通配深度为 2,超过需要显式授权。 一个 read_set = {./data/**} 可能包含 10000 个无关文件,任何无关文件的变更都会导致缓存失效。建议从精确路径开始:
read_set: [data/sources/2026-05-01.json] # 精确路径
read_set: [data/sources/*.json] # 一级通配
第四,将 read_set 和 write_set 纳入缓存键设计。 完整的缓存键包含 task.command、task.read_set_patterns 和 file_checksums[read_set]。缓存命中时直接复制 .cache/<task>/<hash>/outputs/ 下的文件到目标位置即可。Git 仓库中加入 .gitignore: .cache/。关键是:缓存的失效从下游向上游自动传播。 上游任务重跑后 write_set 变化,自动导致下游任务的缓存键变化,不需要中央协调器"通知"。
从序列到数据流
回头看我最初的博客发布工作流,那个渲染任务悄悄写入 tags.yaml 的问题,从根本上是因为我只有"执行顺序思维"而没有"数据流思维"。执行顺序思维说:"任务 A 先做,任务 B 后做。" 这是一个一维的时间线。数据流思维说:"任务 A 写这些文件,任务 B 读这些文件。任务 C 写这些文件,任务 D 读这些文件。" 这是一个二维的、可并行、可缓存、可追溯的图结构。
read_set 和 write_set 就是让你从一维升到二维的那个最小声明。它看起来像是一个额外的麻烦——多的确增加了初始设计阶段的工作量。但它换来的,是在复杂工作流中可预测、可缓存、可并行的执行能力。这就像写代码时的类型声明。你可以在 Python 里完全不写类型注解,代码也能跑,运行时的 duck typing 足够灵活。但当代码规模变大、团队协作变多时,类型注解的价值就体现出来了——它让代码可检查、可推理、可重构。read_set 和 write_set 就是任务图的"类型系统"。
所以我的建议很简单:下次新建一个任务图时,先写 read_set 和 write_set。 哪怕只是粗略的目录级别,哪怕后期会调整。你需要一个"数据契约"来组织你的工作流——而不是靠"我记得任务 B 读了一个文件"这种脆弱的心智模型。从"串行依赖"到"数据流映射",是任务图从功能级走向数据级的进阶。在任务数量超过两位数之后,这种能力从一个"优雅的加分项"变成了"生存的必需品"。