最近读了一篇论文:《A Programming Paradigm for Spatiotemporal Composability》。

它讲的不是一个单独的 API,也不只是“给插件加一个析构函数”。它真正想解决的是:一个大型系统里有很多可以动态加载、卸载、替换的组件,怎样让它们在变化过程中仍然保持正确。

先说结论

论文把这个问题拆成两个方向:

  • 时间可组合性(Temporal composability):组件退出时,能够撤销自己留下的副作用。
  • 空间可组合性(Spatial composability):组件之间的依赖发生变化时,系统能够自动协调谁启动、谁停止、谁先退出。

最后,这两个方向被统一成 时空可组合性(Spatiotemporal composability)。空间侧回答“谁应该运行、谁先停”,时间侧回答“停下来以后怎样清理干净”。两者合在一起,才是一个真正可热插拔的组件系统。

目录

1. 先看一个最小例子

假设系统里有两个组件:

1
2
A:数据库组件,提供 Database
B:用户服务,依赖 Database,并注册 /users 路由

正常加载时:

1
2
3
A 启动并提供 Database
→ B 发现依赖满足
→ B 启动并注册 /users

现在卸载 A。安全流程不是直接关闭数据库:

1
2
3
4
5
A 宣布自己即将退出
→ 通知 B
→ B 先停止并清理 /users
→ B 变成 Inactive
→ A 再关闭数据库并撤销 Database 注册

如果 A 重新加载:

1
2
3
A 再次提供 Database
→ B 发现依赖重新满足
→ B 重新启动

这个例子里其实同时发生了两件事:

问题 负责的方向
B 依赖 A,A 退出时谁先停? 空间可组合性
B 停止时怎样删除路由、清理定时器? 时间可组合性

2. 时间可组合性:退出时只撤销自己的贡献

论文里的 effect 可以理解为组件对共享环境做出的修改,也就是副作用:

1
2
3
4
5
注册路由
启动定时器
添加事件监听器
注册服务
创建数据库连接

所谓 revertible effect,就是每次做修改时,同时提供一个撤销操作:

1
2
3
4
5
6
7
ctx.effect(() => {
router.add('/users', handler)

return () => {
router.remove('/users', handler)
}
})

组件卸载时,运行时执行已经积累的撤销操作:

1
2
3
4
注册 /users      → 删除 /users
启动定时器 → 清除定时器
监听 message → 取消监听
注册 Database → 撤销注册

关键不是“有没有写一个析构函数”,而是每个副作用和自己的逆操作在同一个地方配对。多个小操作的逆操作可以自动组合成整个组件的清理流程。

因此,卸载 B 时,B 只撤销:

1
B 的路由、定时器、监听器

不会顺便删除 A 的数据库。卸载 A 时,A 也只撤销自己的:

1
A 的数据库连接和服务注册

这就是时间可组合性最直观的含义:组件随时间离开系统后,它留下的贡献可以被正确收回。

3. 空间可组合性:依赖变化时自动协调

空间可组合性关注的不是“B 做了哪些修改”,而是:

1
2
3
B 需要什么?
谁提供这个东西?
提供者消失时,B 应该怎么办?

论文把这部分叫 reactive coeffects。组件可以声明:

1
2
我需要 Database
我提供 UserService

运行时持续检查依赖是否满足:

1
2
3
4
5
6
7
8
9
Database 不存在
→ B 保持 Inactive

Database 出现
→ B 自动激活

Database 被替换
→ B 停止
→ 基于新的 Database 重新激活

它还给出了一个很重要的 Ordering 保证:

1
2
依赖者先退出
提供者后退出

原因很简单:B 的清理流程可能还要访问 A 提供的 Database。如果 A 先把数据库连接关掉,B 的退出就可能失败。

所以空间可组合性更像一张动态依赖图:

1
2
3
4
5
A 提供 Database

B 依赖 Database

C 依赖 B 提供的 UserService

当 A 退出时,系统会沿着这张图协调:

1
2
3
C 先停
→ B 再停
→ A 最后停

4. 为什么还要讨论 effect independence

effect independence(效应独立性)是另一个层次的问题。它问的是:

两个组件对环境的修改,能不能交换执行顺序,并且在撤销时互不破坏?

例如:

1
2
A:注册 /a
B:注册 /b

通常无论先注册谁,结果都是:

1
{/a, /b}

卸载 A 后也只剩:

1
{/b}

这种操作可以认为比较独立。

但下面就不一样:

1
2
A:余额加 5
B:余额乘 2

如果初始余额是 10:

1
2
(10 + 5) × 2 = 30
10 × 2 + 5 = 25

顺序改变了结果,因此两个 effect 不独立。

论文并不会把“不独立”的操作强行变成独立,而是:

  • 同一个组件内部,按积累的逆操作以 LIFO 顺序撤销;
  • 不同组件之间,如果存在必要的先后关系,就用依赖关系表达;
  • 只有能够交换的部分,才允许在多个组件交错运行时自由重排。

因此:

1
2
依赖关系:B 是否需要 A 存在?
效应独立性:A、B 的修改能不能换序?

这是两个不同问题。

5. 论文真正新增了什么

如果只看工程表面,它确实很像很多已有技术的组合:

1
2
3
4
5
RAII / cleanup
依赖注入
服务注册
响应式更新
热模块替换

论文的主要工作不是凭空发明这些零件,而是把它们统一到一个运行时模型里,并尝试证明几个全局性质:

5.1 组件可以完整撤销自己的贡献

组件卸载后,自己的路由、监听器、服务注册和子组件不会残留。

5.2 依赖者不会在提供者之前被迫失去依赖

提供者退出时,依赖者先停,依赖者清理完成后提供者再退出。

5.3 异步启动期间不会悄悄混用两个依赖版本

如果 B 正在基于 Database v1 启动,而 v1 被 v2 替换,B 不会继续半边使用 v1、半边使用 v2,而是结束当前转换并重新开始。

5.4 最终状态尽量与中间历史无关

系统可能经历:

1
2
3
4
5
加载 A
加载 B
替换 A
卸载 B
重新加载 B

只要最后配置相同,系统稳定后的状态应当等价于直接从头加载最终配置。这是论文里比较强的 Confluence(合流性)结论。

当然,这些保证有前提:

  • 每个副作用都要经过 Context,并提供正确的逆操作;
  • 依赖图不能有环;
  • 不可交换的操作必须保留必要顺序;
  • 已经发出去的网络消息、邮件和扣款,不属于可以物理回滚的内部状态。

6. 收个尾

这篇论文最容易混淆的地方,是“时间”和“空间”听起来像两个互相独立的系统。其实它们是同一个问题的两个方向:

1
2
3
4
5
6
7
8
空间可组合性:
组件之间怎么连接?谁依赖谁?谁先停?

时间可组合性:
组件加入和退出时做了什么?退出后能否清理干净?

时空可组合性:
依赖关系和副作用恢复一起正确,组件才能安全动态加载、卸载和替换。

所以它最终想做的事情可以用一句话说完:

让大型系统里的组件能够随时进出,而系统既知道该让谁先停,也能把离开的组件留下的东西收干净。

这也是我读完这篇论文后的直观理解:它的工程材料并不全新,但它试图把“动态插件系统为什么能够安全运行”这件事,从经验和约定提升为一套可以描述、实现和证明的模型。