解读 Cordis:dsh 一切皆插件背后的运行时设计

0 阅读18分钟

上一期「dsh 拆解系列 Vol.01:没有固化内核的 Agent 运行时」最后留下了一个问题:插件可以挂进运行时,卸载时又怎么把它带来的变化清理干净?

Cordis 背后的论文《A Programming Paradigm for Spatiotemporal Composability》把这类动态组件问题拆成两个维度:组件产生的修改如何撤回,组件之间的依赖如何随运行时变化。

动态组合的两个维度

函数调用、模块导入和类继承这些组合关系,在程序运行之后相对稳定。插件系统面对的情况偏动态:在运行期间组件可能会被加载、卸载、替换,系统里的依赖关系也会跟着变化。

论文把这里的问题分成两个维度。

第一个是时间可组合性。一个组件加载后,可能注册事件监听器、创建资源、写入共享状态。等组件被移除时,这些修改也需要一起撤销,尽量让共享环境恢复到组件加入之前的状态。

第二个是空间可组合性。组件之间可能存在依赖关系。A 提供某项能力,B 依赖这项能力才能工作;A 离开后,B 的依赖条件也随之变化。新的 Provider 出现后,B 可能会重新满足运行条件。

论文借用了编程语言里的两个概念来描述这两条关系:**Effect 描述一个组件如何改变环境,Coeffect 描述一个组件需要从环境获得什么。**不同之处在于,传统 Effect 和 Coeffect 主要服务于静态分析,这篇论文把它们变成了运行时可以追踪和处理的机制。

图 1 :时间可组合性和空间可组合性

可回滚 Effect

先看时间维度。

一个插件在加载过程中会创建各种资源,研发人员也可以为这些资源逐一编写对应的清理逻辑:

注册事件监听器
→ 卸载时移除监听器

注册一项服务
→ 卸载时注销服务

创建一项资源
→ 卸载时释放资源

这里的维护成本来自创建和清理之间必须始终保持对应。插件每增加一项注册,清理逻辑也要跟着补上;初始化执行到一半发生异常,得知道前面有哪些资源创建成功了;多项资源存在先后依赖时,释放顺序也需要匹配。

论文提出的 Revertible Effects(可回滚 Effect),把一次修改和对应的撤销动作放进同一套机制里管理。论文把一次 Effect 定义为:接收当前上下文,返回修改后的新上下文,以及针对这次修改生成的逆操作。

下面是一个 Effect 示例:

Effect A

执行:
State 0 → State 1

逆操作:
State 1 → State 0

逆操作不是提前为所有状态准备一份固定函数,它可以在 Effect 实际执行时根据当时的状态生成。这样,撤销动作就知道自己具体要恢复什么。

多个 Effect 按顺序执行时,对应的逆操作按照相反顺序组合:

执行:
A → B → C

恢复:
C⁻¹ → B⁻¹ → A⁻¹

这与资源释放中的 LIFO 顺序一致。后产生的修改先撤销,前面的修改最后恢复。

对于一个组件内部连续执行的一组 Effect,只要每次 Effect 都提供有效的逆操作,运行时就可以把这些逆操作累积起来,在组件退出时按照相反顺序恢复此前的状态。论文把这个累积的恢复函数称为 accumulator。

交错执行的 Effect

多个组件同时运行时,情况会更复杂点。假设 A、B 两个组件的 Effect 交错发生:

A1 → B1 → A2 → B2

现在只卸载 A,运行时需要撤销 A1 和 A2,同时保留 B1 和 B2 对系统产生的修改。这就要求不同 Effect 之间具备一定的独立性。

单个组件按照 LIFO 顺序撤销时,不需要额外考虑这个问题;但当多个组件的 Effect 互相交错,或者需要单独撤销其中一组 Effect 时,相关操作就不能互相干扰。只有这样,撤掉 A 的 Effect 后,B 留下的状态才能继续成立。

因此,Revertible Effects 处理的不只是“记住几个清理函数”。修改本身、逆操作、组合顺序,以及不同组件的 Effect 能否彼此独立地撤销,都被纳入了同一套模型。

响应式 Coeffect

时间维度解决组件“改了什么”,空间维度处理组件“需要什么”。

论文把组件需要的依赖写成一份 Coeffect Specification。例如一个组件 B 依赖三项能力:

database
model
filesystem

当这三项能力全部存在时,B 的依赖条件成立;其中任何一项缺失,条件就不再满足。

这里的关键点是判断会跟着上下文变化。论文把一次上下文变化相对于某个组件的依赖规格分成三种情况:

不满足 → 满足
activating

满足 → 不满足
deactivating

满足状态没有变化
neutral

因此,组件依赖不只在启动阶段检查一次。相关依赖出现、消失或更换 Provider 时,运行时会重新判断受影响组件的依赖状态,并据此推动它的生命周期。

例如:

Database Provider 出现
        ↓
database 依赖满足
        ↓
Consumer 可以激活

Provider 发生变化以后:

原 Provider 停止提供能力
        ↓
Consumer 的目标状态变化
        ↓
Consumer 进入停用流程

这里还有一个容易被忽略的问题:Consumer 的清理代码本身也可能需要原来的依赖。

论文举了连接池的例子。组件停用时,可能还需要把连接归还给提供这些连接的服务。如果 Provider 在 Consumer 清理之前就把能力收掉,Consumer 的卸载清理反而无法完成。

所以 Provider 的退出不能简单处理成“先删服务,再通知别人”。论文后面的组件生命周期专门为这条依赖顺序增加了约束。

Effect 与 Coeffect 的统一上下文

前面的两套机制最初各自作用于一种上下文。

Effect 这一边需要记录当前状态和 accumulator;Coeffect 这一边需要记录当前有哪些依赖、每个 key 对应什么能力。

论文把它们合成了一个统一的上下文类型。这个上下文同时携带三类信息:

当前上下文状态
+
用于恢复 Effect 的 accumulator
+
组件依赖信息

这样,一个组件与运行环境之间的交互就可以放进同一套上下文里追踪:它向环境写入了什么,它从环境读取了什么,它留下的修改如何恢复,它依赖的能力是否仍然存在。

这也是论文所说的 Context Paradigm。

统一上下文还是递归的。一个组件可以在自己的上下文下面创建子组件,父上下文负责聚合和管理下一级 Effect,于是插件可以继续加载插件,形成树形的组件结构。

在这套模型里:

加载组件
≈ 执行组件的 Effect

卸载组件
≈ 执行累计的逆操作

父组件
≈ 管理下一层组件产生的 Effect

动态插件系统里的“装上去”和“拔下来”,因此有了一套对应的运行时语义。

组件生命周期

论文接下来把 Effect 和 Coeffect 组合成 Component

一个 Component 包含三部分:

  • d:需要哪些依赖;

  • p:能够提供哪些依赖;

  • e:组件激活后会产生哪些 Effect,以及这些 Effect 对应的逆操作。

组件在运行时中的一次实例化称为 fiber。每个 fiber 保存自己的依赖、Provider 解析结果、Effect accumulator 和生命周期状态。

最基础的模型只有:

Inactive ⇄ Active

但真实的插件加载和卸载不会瞬间完成。初始化可能会分为多步执行,中间可能等待异步操作,也可能失败。论文因此把生命周期扩展成:

Inactive
   ↓
Reloading
   ↓
Active
   ↓
Unloading
   ↓
Inactive

这里需要区分论文模型和 Cordis 代码中的命名:论文形式化模型里的 Reloading,在 Cordis 实现中对应 fiber.stateLOADING;发生错误后的 Inactive(ξ),实现中对应 FAILED

图 2:组件完整的生命周期

这个四状态模型处理了几类现实情况。

第一类是分步初始化。一个组件的加载可以执行多个 Effect,每一步都会把新的逆操作累积到 accumulator 中。中途依赖条件发生变化时,系统可以停止后续步骤,再撤销前面完成的部分。

第二类是异步执行。某一步操作发出以后,外部状态可能在等待期间发生变化。论文为这类情况引入了执行惯性:一旦某一步已经发出,就先让它完成,再根据最新的目标状态决定是否进入卸载,避免操作停在执行到一半的状态。

第三类是加载失败。如果某次迭代报错,fiber 会进入 Unloading,执行此前累积的逆操作,清理前面成功创建的状态,最后回到带有错误结果的 Inactive 状态。

Provider 的退出顺序

空间可组合性在这里和组件生命周期接到了一起。

假设 B 依赖 A 提供的某项能力。A 准备退出时,会先从 Active 进入 Unloading。此时 A 不再作为新的依赖解析中的可用 Provider,因此 B 的目标状态会发生变化,开始进入自己的卸载流程。

但 B 在激活时保存的 committed view 仍然存在。B 在卸载清理期间读取依赖时,仍然可以访问当时绑定的 Provider。A 则要等所有依赖自己的 Consumer 完成卸载,再执行自己的 accumulator,撤销此前创建的资源和注册。

所以,退出顺序更接近:

Provider 决定退出
        ↓
停止接受新的依赖绑定
        ↓
相关 Consumer 开始停用
        ↓
Consumer 仍可使用原 committed binding 完成清理
        ↓
Consumer 全部退出
        ↓
Provider 执行自己的恢复操作

Cordis 实现里的 refreshcommittedunload 分别承担了这几步。论文明确说明,Provider 的 unload 会等待被通知的依赖组件进入 Inactive,再执行自己的恢复过程。

Cordis 的运行时实现

Cordis 把前面的 Effect、Coeffect 和组件生命周期落到一组运行时 API 上。ctx 负责承载上下文,组件对上下文产生的修改由 ctx.effect() 追踪,组件加载后则由 fiber 保存自己的依赖、状态和恢复路径。

其中,ctx.effect() 是 Cordis 追踪 Effect 的核心接口。它接收一个 callback,callback 在执行修改时,可以返回或者逐步 yield 对应的逆操作。运行时再按照 LIFO 顺序,把这些逆操作组合成一条恢复链:

修改 A → 逆操作 A
修改 B → 逆操作 B
修改 C → 逆操作 C

dispose:
逆操作 C → 逆操作 B → 逆操作 A

这样,即使组件的初始化分成多步执行,运行时也能记录前面完成了哪些修改,并在组件退出或加载失败时按顺序撤销。

Cordis 负责逆操作的记录、组合和执行,但不会验证逆操作本身是否正确。callback 声明某个函数能够撤销刚才的修改,这个函数能否恢复对应状态,仍然由组件实现保证。

ctx.effect() 解决了组件修改如何记录和撤销的问题。依赖的注册、变化和访问,也建立在同一套机制之上。

依赖的注册与访问

Coeffect 这一边同样建立在 ctx.effect() 之上。

ctx.set(key, value) 会在上下文里提供一项能力,对应的逆操作负责撤销这项注册。因此,提供一项依赖本身也是一个可回滚 Effect。

ctx.set("database", db)
        ↓
database Provider 出现
        ↓
通知相关 fiber
        ↓
重新计算目标状态

等这项 Effect 被撤销:

database Provider 离开
        ↓
相关 fiber 的目标状态发生变化
        ↓
生命周期进入停用流程

Cordis 不需要每次扫描所有组件。运行时会根据发生变化的 key、fiber 声明的 inject 和对应 realm 找到相关组件,再执行 refresh,更新它们的状态。

依赖访问有两种方式。ctx.get(key) 是底层查询接口,找到对应绑定就返回值,没有找到则返回空,也不会检查当前组件是否声明过这项依赖。

更常见的属性访问 ctx[key] 则由 Proxy 接管。运行时会沿 fiber 链检查依赖声明和 committed view

key 在 committed view 中
→ 允许访问

fiber 声明了 key,但当前没有 committed binding
→ INACTIVE_ACCESS

一路到 root 都没有声明过 key
→ UNDECLARED_ACCESS

因此,fiber.inject 不只描述组件依赖哪些能力,也参与运行时的访问约束;committed view 则记录这一轮激活时实际绑定到的 Provider。

Cordis 还提供 isolateintercept。前者让同一个依赖 key 在不同上下文里解析到不同 realm,后者则为依赖访问附加额外的元数据和约束。这样,不同组件即使使用同一个 key,也可以拥有各自的依赖解析范围。

配置树与增量协调

Core Library 提供的是命令式原语,Component Loader 再往上加了一层声明式配置。

一个 Entry 对应一个 fiber,记录六项内容:

id
url
isolate
intercept
config
disabled

id 是稳定标识,用来匹配配置更新前后的同一个 Entry;url 指向组件模块;isolateintercept 描述依赖范围;config 保存组件配置;disabled 表示该 Entry 当前是否启用。

这些 Entry 组成一棵配置树,作为运行时要加载哪些组件的声明记录。当配置发生变化时,Loader 会根据变动字段选择不同的调整方式:

  • idurl 发生变化:重建 Entry;

  • isolate 变化:重新分配 realm;

  • intercept 变化:原地更新;

  • config 变化:交给组件处理,组件自己决定是否需要 reload;

  • disabled 设为 true:卸载 fiber;恢复后重新加载。

如果某个 group 的 config 是一组子 Entry,Cordis 会根据子项 id 做 keyed diff,只创建、删除或更新发生变化的部分,并递归向下协调配置树。

**这里依赖关系影响的是组件什么时候激活,并不会要求 Loader 按照依赖拓扑逐个加载模块。**论文指出,模块本身可以并发获取和求值,依赖不满足的 fiber 会留在对应状态,等 Provider 出现后再进入激活流程。

组件级热更新

Cordis 的 HMR 也建立在这套组件生命周期上。

论文把热更新分成三个阶段。

第一阶段是 Module Classification

系统从发生变化的模块开始沿 import 图传播,把模块划分为 accepted 和 declined。不能热替换的 external module 会进入 declined,无法在依赖环中确定的模块最后也按 declined 处理。

第二阶段是 Stale-entry Detection

系统检查各个组件 Entry 的依赖树,只找出依赖路径触及 accepted module 的 Entry,这些才需要重新加载。

第三阶段是 Transactional Reload

Cordis 先备份并清除相关模块缓存,然后逐个处理 stale Entry:

dispose 旧 fiber
        ↓
重新 import module
        ↓
ctx.use(...)
        ↓
创建新 fiber

如果其中一个模块重新导入失败:

reload failed
        ↓
恢复旧 module cache
        ↓
清理本轮产生的新 fiber
        ↓
用备份中的旧 component 重建 fiber

这样,系统不会停留在一部分组件使用新代码、另一部分组件尚未替换完成的中间状态。

可回滚的系统边界

可回滚 Effect 也有明确的边界:并不是所有外部行为都能通过逆操作恢复。

论文把这条边界称为系统边界。一项状态能否纳入可回滚范围,取决于两个条件:系统是否能够独占地修改它,以及修改之后能否恢复到此前的状态。只有两个条件都满足,这项修改才会被运行时追踪并参与恢复。

这个边界不是按照“内存、文件、网络”这样的介质划分,而是取决于具体资源是否处在系统控制范围内。例如,一块只有当前系统能够修改的内存可以位于边界内;如果其他进程也能修改它,就落在边界外。只有系统自身能够访问的临时文件也可以纳入边界,而被其他程序共同读写的文件则无法保证完整恢复。

对于会跨出系统边界的操作,论文又把过程拆成两个阶段。

第一个是资源获取:

open  → close
malloc → free
fork  → kill

这些操作会在系统内部留下可追踪的记录,例如文件描述符、内存块和子进程句柄。运行时可以把资源获取记录成一项可回滚 Effect,在组件退出时执行对应的逆操作。

第二个是对外输出:

write → 数据写入文件
send  → 数据发送到网络

一旦数据通过这些通道进入系统边界之外,其他主体就可能读取或修改它。此时 Cordis 无法保证把外部环境恢复到操作发生前的状态。

针对这类操作,论文给出两种处理方式。一种是暂缓输出:先把结果留在系统控制范围内,等内部状态确定不会回滚后再提交出去。另一种是补偿操作:当外部行为无法撤销时,再执行一个对应动作,把业务状态恢复到允许的等价状态,例如删除此前创建的文件,或者对一笔收费执行退款。

因此,可回滚 Effect 保证的是系统控制边界内的状态恢复。一旦操作跨出这条边界,就需要延迟提交、事务或补偿机制来处理。

Koishi 的生产案例

论文最后使用 Koishi 作为 Cordis 的案例。

Koishi 是建立在 Cordis 上的开源聊天机器人框架。论文统计的四年开发周期中,社区积累了 4,000 多个插件,覆盖 IM Adapter、数据库驱动、管理控制台和用户功能等不同类型。

这些插件共享同一套 Cordis 组件模型。

插件从控制台停用以后,它通过上下文产生的 Effect 可以在当前进程中撤销;开发阶段修改插件代码后,HMR 可以重新应用对应插件,同时保留系统其他部分的缓存和活动连接。

空间关系上,IM Adapter 可以提供消息平台能力,数据库 Driver 提供存储能力,功能插件再声明这些能力作为自己的 Coeffect。当某个 Provider 被重新配置或替换时,只有解析到该 Provider 的依赖组件需要重新激活;缺少依赖的插件则保持未激活,等待对应能力出现。

不过,这组案例有两个边界需要注意:

第一,Koishi 当前使用的是 Cordis v3,这篇论文描述的是 Cordis v4。作者说明,两者共享核心组合模型,但 v4 重新整理了 Effect、Coeffect 语义,并重新设计了 Loader。

第二,这不是一组和其他插件架构进行对照的定量实验。论文自己把这里的证据定义为 existence-and-adoption result:它说明这套组合模型能够在一个长期运行、拥有大量独立作者插件的生态里落地,但无法据此判断 Cordis 相比其他架构能提升多少性能或开发效率,这些仍属于后续研究。

结语

回到最开始的问题,Cordis 处理的是动态组件进入和退出运行时之后留下的两条关系。

一条是 Effect:组件对上下文产生了哪些修改,以及对应的逆操作记录、组合和执行;另一条是 Coeffect:组件需要哪些能力,当 Provider 出现、退出或替换时,它的生命周期应该怎样变化。

这两套机制最终汇入同一个上下文和 fiber 生命周期。组件加载时,运行时开始记录它带来的 Effect 和依赖关系;组件退出时,再按照依赖顺序和 accumulator 撤销属于它的状态。

论文最后也把 Self-Evolving Agent Harness 列成了后续验证方向:如果 Agent 会高频生成和替换自己的 Harness 组件,运行时同时需要处理频繁回滚和依赖拓扑变化,这正是论文定义的时间与空间两个维度。需要注意,这仍是作者提出的未来验证方向,论文没有拿自演化 Agent Harness 做实验。

下一篇再回到 dsh 的配置层,看这些组件最终怎么通过 Bundle、Profile 和 Patch 组装成一棵实际运行的插件树。