并发程序最难推理的部分,往往不是“两个任务同时执行”,而是两个任务之外的关系:谁创建了它们,谁等待结果,失败是否会传播,取消能否到达子任务,资源最终由谁释放。只要这些关系散落在回调、标志位和约定里,代码即使每一行都正确,整体也可能无法回答“任务什么时候真正结束”。
函数式并发的价值,不在于把所有代码写成纯函数,而在于把计算描述与副作用执行分开,并让生命周期成为程序结构的一部分。
纯函数负责决定,效果负责发生
先看一个简单边界:业务规则只根据输入决定要做什么,运行时负责让它发生。
type Command =
| { type: 'fetch'; url: string }
| { type: 'store'; key: string; value: string }
function plan(input: Input): Command[] {
return input.cached
? []
: [{ type: 'fetch', url: input.source }]
}
plan 不启动线程、不发网络请求,也不修改共享状态,因此可以独立测试。副作用并没有消失,而是被推迟到一个明确解释 Command 的边界。这个分离让重试、超时、日志和测试替身都有了统一落点。
可控不是因为副作用变少,而是因为副作用在哪里发生、由谁解释,变得可以指出来。
结构化并发补上所有权
效果描述解决“发生什么”,结构化并发继续解决“活多久”。核心约束可以概括为:子任务不能悄悄活过创建它的作用域。父作用域结束之前,要么拿到子任务结果,要么取消并回收它们。
| 关系 | 非结构化写法的风险 | 结构化表达 |
|---|---|---|
| 创建 | 启动后失去句柄 | 子任务属于当前作用域 |
| 失败 | 错误停留在后台 | 失败沿父子关系传播 |
| 取消 | 依赖共享布尔值 | 取消信号覆盖整个子树 |
| 释放 | 清理散落在多个回调 | 作用域退出时统一收束 |
这并不要求某一种语言语法。async/await、任务组、fiber 或效果运行时都可以承载类似规则,关键是运行时是否保留清楚的父子关系。
一个 API 如何表达作用域、错误和取消,会直接决定程序员能否在局部代码中推断全局生命周期。
失败不是异常分支,而是计算结果的一部分
并发会放大含糊的错误语义。三个子任务中一个失败,另外两个应该继续、取消还是返回部分结果?没有统一答案,但答案必须由调用方显式选择。
函数式建模常把这类选择放进结果类型:成功值、可恢复错误、取消和超时不再全部挤进一个隐式异常通道。这样,组合器才能清楚表达“全部成功”“最先完成”或“收集所有结果”等不同策略。类型不能消灭竞态,却能迫使关键分支进入接口,而不是藏在实现细节中。
从共享可变状态退一步
很多并发问题来自多个任务同时拥有同一份可变数据。函数式方法通常先问:能否传递不可变值,能否通过消息转移所有权,能否把修改收束到一个状态机。锁仍然可能必要,但它不再是默认的组织方式。
实践中可以按这个顺序设计:先定义任务产生的值,再定义效果边界,然后建立父子生命周期,最后才选择调度器和并行度。调度是性能策略,所有权才是正确性模型。
函数式编程与结构化并发在这里相遇:前者让“要计算什么”可组合,后者让“计算何时存在”可推理。真正高级的并发抽象,不是隐藏所有复杂性,而是让不能被消除的复杂性以明确、局部并可组合的形式出现。