cordis-cj 源码拆解
卸载这件事,静态语言从根上就做不了
动态语言的插件系统可以「加载 → 卸载 → 再加载」,
静态语言不行——不是实现得不够好,是运行时结构不允许。
这篇讲 cordis-cj 绕开这个限制的办法,以及绕开之后要付的账。
一、卸载不掉的东西
先看问题。
JavaScript 里卸载一个模块,做法是删掉缓存里的引用,让 GC 回收。Node 的 require.cache 就是干这个的。
仓颉不行。它编译成静态机器码,模块加载后,里面的全局变量、静态初始化、注册表都落在进程内存里。你就算把代码段「卸掉」,那些数据还在——而且没有任何机制能安全地把它们重置回初始状态。
这不是仓颉独有的问题。C 的 dlopen/dlclose 在理论和实践之间差着一大截:POSIX 标准里 dlclose 甚至允许什么都不做;GNU ld 的文档明说,带 UNIQUE 符号或 dlopen 了带 TLS 的库时,dlclose 之后那个 handle 依然有效。
换句话说:「卸载」在原生程序里本来就只是个建议,不是承诺。
所以静态语言写插件系统,最后都撞同一面墙。撞法有两种:
| 做法 | 代价 |
|---|---|
| 假装能卸载(重置状态) | 要穷举所有全局状态,漏一个就是幽灵 bug |
| 干脆不能卸载(只重启进程) | 每次改插件都要重启宿主,热更新没了 |
cordis-cj 选了第三条:插件不跑在宿主进程里。
二、卸载 = 杀进程
思路很直接:如果「卸载」的定义是「让这些代码和数据彻底消失」,那唯一可靠的方式是让承载它们的东西消失。
进程。kill 之后操作系统回收它的全部内存、文件描述符、线程——不需要插件配合,不会漏。
于是架构变成两层:
宿主进程(微内核,永不卸载)
├─ 调和器:读配置、算 diff
└─ 进程管理:拉起 / 杀掉 / 探活
│ stdio 管道 / Unix socket
▼
插件 A 插件 B 插件 C
(各自独立,崩溃不影响别人)
这个方案解决了三件事,而且都不需要插件作者操心:
- 资源回收:
kill就是全部回收,不依赖插件写dispose - 故障隔离:一个插件崩了,宿主和别的插件都不知道
- 崩溃自愈:宿主探活失败就重启,状态从配置文件重建
但天下没有白拿的好处。跨进程之后,一次函数调用变成了消息往返。
三、代价在三处
拆进程的账,主要记在三个地方。
一、握手
插件不能一启动就开始干活——它得先告诉宿主「我是谁、我提供哪些服务」,宿主才能把它接入路由表。
所以每个插件启动后第一件事是握手:
插件 → 宿主:
initialize(name, provides)
宿主 → 插件:ok,登记到 serviceIndex
插件 → 宿主:provide("echo", undo_id=uuid)
宿主:登记服务,记住 undo_id
这个 undo_id 不是随便加的。它对应可逆效果(revertible effects):注册一个服务时,同时给出「怎么撤销这次注册」。宿主执行 dispose(undo_id) 时,插件按记录撤销——而不是插件自己猜要清理什么。
这个设计来自北大和 DeepSeek-AI 的一篇论文,cordis-cj 按它的语义实现。
要点是:副作用在发生时就把「撤销方式」记下来,而不是事后靠人回忆。
二、序列化
跨进程只能传字节。所以每个调用都要走 JsonValue。
这件事如果让插件作者手写,插件系统就没法用了:
// 每天写一百遍这种东西,谁都不想用
let args = ArrayList<JsonValue>()
args.add(JsonValue.Number(2.0))
args.add(JsonValue.Number(3.0))
let result = ctx.invoke(
"calc", "add", args)
let n = match (result) {
case JsonValue.Number(v) => v
case _ => 0.0
}
所以 cordis-cj 提供了泛型版本,把序列化藏在类型后面:
// 业务代码只认仓颉类型,序列化是框架的事
let sum = ctx.invoke<AddRequest, Int64>(
"calc", "add", [AddRequest(2, 3)])
AddRequest 实现 JsonSerializable,Int64 实现反序列化——框架在中间做转换,业务代码里看不到 JsonValue。
三、启动顺序
服务有依赖。插件 B 要用插件 A 提供的 db 服务,就必须后启动。
这件事不能靠配置里的书写顺序——那太脆弱了。所以调和器按 inject / provide 声明做拓扑排序:
[[plugins]]
id = "db"
provide = ["db"]
[[plugins]]
id = "cache"
provide = ["cache"]
inject = ["db"] # 得等 db 起来
声明了 inject,宿主自然会把它排在提供者后面。配置里不用写顺序,写依赖。
四、把 IPC 藏起来
到这里,插件作者要面对的东西还是很多:写 main、处理握手、注册服务、解析参数……
cordis-cj 用宏把这层削掉了。一个完整的插件长这样:
@Plugin[]
public class MyPlugin {
@Provide["echo"]
public func echo(
args: Array<JsonValue>)
: JsonValue {
if (args.size > 0) { args[0] }
else { JsonValue.Null }
}
}
@Plugin[] 自动生成 main 函数、握手流程、服务注册、invoke 路由分发。@Provide["echo"] 声明这个方法对外是 echo 服务。
插件作者看到的是一个普通类,不是一套 RPC 脚手架。
不过宏不是唯一的路。README 里给了两种写法:
| 写法 | 适合谁 | 代价 |
|---|---|---|
宏(@Plugin) |
框架内置插件 | 宏包必须与调用方同模块(见后文) |
显式 API(PluginRuntime.run) |
外部插件作者 | 手写 main,但不受宏的跨模块限制 |
「两种写法」不是偷懒,是在工具链缺陷面前留了退路。这个后面会讲。
双传输,插件无感
进程间通信有两种方式,cordis-cj 都支持:
| 传输 | 帧格式 | 适用 |
|---|---|---|
stdio(默认) |
换行分帧 | 简单,管道天然有序 |
uds |
Content-Length 分帧 | Unix socket,能传更复杂的东西 |
切换靠配置里的一个字段。插件侧完全不用改:
main(): Int64 {
PluginRuntime.run("my-plugin", [],
["echo"], handler)
return 0
}
run() 内部会自己看命令行有没有 --uds <path>,有就走 UDS,没有就 stdio。所以同一份插件代码两种模式都能跑。
这个细节值得说:传输模式是部署决定,不该污染插件代码。
如果插件得知道自己跑在哪种传输上,那换传输就等于改所有插件。
五、调和器:配置说了算
剩下最后一件事:怎么让「期望状态」和「实际状态」对上。
cordis-cj 用的是声明式调和——你只写「该有哪些插件」,不写「怎么启动」:
interval = 5
transport = "stdio"
[[plugins]]
id = "my-plugin"
source = "./target/release/bin/my_plugin"
provide = ["echo"]
enabled = true
宿主每 5 秒跑一轮,做四件事:
读配置 → 算 diff → 拓扑排序
→ 启动新的 → 先加后删 → 崩溃自愈
几个设计细节:
「先加后删」不是随意定的。 如果要换一个插件的版本,先杀旧的再起新的,中间会有一段服务不可用;先起新的再杀旧的,切换是平滑的。代价是短时间内两套并存,所以只在服务不冲突时成立。
探活是独立的。 每 30 秒 ping 一次,超时就重启。这跟调和周期分开——配置 5 秒一轮是为了响应变化,探活 30 秒一轮是为了发现假死。
崩溃自愈不消耗人。 插件崩了不需要运维介入,下一轮调和就会把它拉起来。
这套东西的哲学是:人只维护「想要什么」,机器负责「怎么达到」。
配置写错时宿主会报错,但配置漂移(有人手动杀了进程)会被自动纠正。
六、一个工具链层面的坑
写宏的时候撞了一个 cjpm 的问题,值得单独记:带 organization 的宏包,跨模块依赖时宏不会链接到调用方。
// cordis_plugin 模块里的宏包
macro package ystyle::cordis_plugin.macros
public macro Plugin(
attr: Tokens, input: Tokens): Tokens {
...
}
独立模块的调用方 import ystyle::cordis_plugin.macros.Plugin 之后:
// 报错:undeclared identifier 'plugin'
@Plugin[]
public class SimplePlugin {}
编译不通过。把 cjpm.toml 里的 organization 去掉,立刻就好了。
这个坑的麻烦之处在于:报错信息(undeclared identifier)完全不指向真实原因。你会以为是宏写得不对、导入写错了、语法有问题——实际上是包管理器没把宏的 .so 传下去。
排查花了多久不重要,重要的是结论落在了文档里:cordis_plugin.macros 和调用方必须同模块。于是才有了前面那个「两种写法」的设计——宏给内置插件用,外部插件走显式 API。
一个工具链的缺陷,最终变成了一个 API 的形状。
七、还有一条路:用脚本写插件
进程隔离的另一个好处是:插件进程里跑什么,宿主管不着。
所以 cordis-cj 支持 xisp 脚本插件——插件不是编译好的二进制,而是一个 Lisp 脚本:
(define (echo args)
(if (> (length args) 0)
(car args)
'null))
cordis_xisp 运行时把宿主能力注入脚本环境(cordis::* 命名空间),脚本直接调用 provide / invoke / log。
这条路的代价是性能(解释执行),好处是改插件不用重新编译。对一个热更新场景,这个交换很划算。
八、边界
诚实说一下这套东西做不到什么:
| 限制 | 说明 |
|---|---|
| 不做跨机 | 进程隔离只在一个机器上;跨机得自己加网络层 |
| 插件只增不热改 | 改插件代码要换二进制 + 重启该插件(调和器能做,但不是热补丁) |
| 序列化有成本 | 大对象跨进程传会慢,设计服务接口时要考虑粒度 |
| 微内核不卸载 | 宿主进程本身是单体,它崩了整个都崩 |
| 宏有跨模块限制 | 见上节;外部插件只能走显式 API |
最后一条值得展开:架构上的「可卸载」不等于「随时能改」。
进程隔离让你能安全地停止一个插件,但要让新版本生效,还是得换文件、重启进程。这跟某些动态语言那种「运行时热替换类定义」完全是两回事。
九、小结
- 静态语言不能卸载模块,这是运行时结构决定的,不是实现问题。
dlclose在原生程序里本来就只是个建议。 - 绕开的办法是换承载物:插件跑在独立进程里,卸载就是杀进程,资源回收交给操作系统。
- 代价在握手、序列化、依赖顺序三处。前两个用宏和泛型 API 藏起来,第三个用声明式依赖(
inject/provide)自动解决。 - 可逆效果:服务注册时就把撤销方式记下来(
undo_id),而不是事后猜。 - 调和器让运维只写期望:5 秒一轮 diff,探活 30 秒一轮,崩溃自愈不惊动人。
- 工具链缺陷会改变 API 形状。宏的跨模块问题没有「修好」,而是变成了两套写法并存。
如果只能记一句:当语言不给你某件事时,先想清楚「不给你」是在哪一层——如果在运行时,就换个承载它的东西。
这个项目的插件语义对标 Cordis (DeepSeek Harness 的插件框架),
理论依据来自《Revertible Effects and Reactive Coeffects》。
它的下游用途之一,是把 agent 适配器从宿主进程里拆出去——
也就是 uhp-cj 那个服务端将来要做的事。