Runtime 复用与 Daemon

从一次 CLI 命令的 Runtime 生命周期出发,理解 daemon 如何长期持有 Worker Runtime pool,并在多次调用之间复用已加载的 Unit。

执行一条 CLI 命令时,真正昂贵的通常不是解析参数,而是把一个 Unit1 变成可以读写的 live Univer:创建 Univer、安装插件、打开协同 backend、加载 checkpoint、补齐 changeset,再等待公式计算就绪。

这一章只讨论两种 Runtime2 能力:

  • @univer-cli/univer-collaboration-runtime:在当前进程中加载并操作一个 Unit;
  • @univer-cli/univer-collaboration-runtime-pool:把 Runtime 放入 Worker,按 Unit 身份租用、复用和回收。

要让 Worker Runtime 跨多次 CLI 调用继续存活,还需要一个长期存在的 owner。这里由本地 daemon 承担这个 角色。

先建立三个生命周期

理解复用之前,先分清三个对象的寿命:

对象何时创建何时结束持有什么
CLI 进程用户每执行一次命令命令输出结果后参数、一次请求和输出格式化逻辑
Daemon 进程第一次请求按需启动,或显式启动用户停止、升级重启或异常退出Runtime pool、业务 handler 和本地 socket
Worker Runtimepool 首次遇到某个 keyTTL/LRU3 回收、失效、daemon 关闭或 Worker 崩溃一个 Worker、一个 backend handle、一个已加载的 Unit 和协同状态

关键心智模型是:CLI 只发起一次调用,daemon 持有 pool,pool 持有 Worker,Worker 持有 Runtime。

100%

Daemon 只转发应用定义的 JSON 请求。Snapshot、changeset 和 block 由 Worker 中的 backend 直接读取,不经过 CLI 或 daemon 中转。

第一步:每条 CLI 命令都直接加载 Runtime

最简单的实现是在 Commander action 中创建 @univer-cli/univer-collaboration-runtime,完成操作后关闭它:

TypeScript
const runtime = await runtimeFactory.load(unitId, unitType);try {  const pulled = await runtime.pull();  if (pulled.status === "conflict") throw new Error(pulled.conflict.message);  const execution = await runtime.execute({ mode: "write", code });  let commit = await runtime.commit();  if (commit.status === "pull-required") {    const repulled = await runtime.pull();    if (repulled.status === "conflict") throw new Error(repulled.conflict.message);    commit = await runtime.commit();  }  return { execution, commit };} finally {  await runtime.close();}

这里的 runtimeFactory 由 createUniverCollaborationRuntimeFactory() 创建。一个 Runtime 从加载到关闭始终只 绑定一个 Unit,并保存这个 Unit 当前的 revision、pending mutation、awaiting changeset 和 conflict 状态。 调用方还必须检查最终 commit status:只有 confirmed 和 nothing-to-commit 表示本轮已经完成。

一次 load() 不只是“读取一个文件”。它会依次完成:

  1. 打开 application 提供的 collaboration backend;
  2. 创建 headless Univer 并安装协同相关能力;
  3. 加载 UnitData 或 Snapshot checkpoint;
  4. replay checkpoint 携带的 changeset;
  5. 拉取并 replay checkpoint 到远端最新 revision 之间的缺口;
  6. 等待已安装公式模块的最新计算结果应用完成;
  7. 返回可以执行 execute()、pull() 和 commit() 的 Runtime。

完整的一次性调用如下:

100%

这种方式的优点是所有权直接、失败后没有残留状态,适合低频命令和一次性任务。代价是下一条 CLI 命令会启动 一个全新的 Node.js 进程,重复上述加载过程;上一条命令已经构建好的 Univer 和 Unit 内存状态无法复用。

第二步:用 Worker pool 承载 Runtime

@univer-cli/univer-collaboration-runtime-pool 为每个 resident key 维护一个独立 Worker process 和一个 Collaboration Runtime。应用先提供 Worker entry;backend、凭据和 Univer 都在 Worker 内创建:

TypeScript
import { defineUniverCollaborationRuntimeWorker } from "@univer-cli/univer-collaboration-runtime-pool";export default defineUniverCollaborationRuntimeWorker({  async createRuntime(init: { unitId: string; unitType: number }) {    return createRuntimeFromApplicationInit(init);  },});

Worker entry 必须指向构建后的 JavaScript ESM 文件。init 只在冷创建时发送给 Worker,并且必须兼容 structured clone。

调用方不需要设计一套 Runtime RPC。Lease 已经代理 getState()、execute()、fetch()、pull()、 commit() 和 exportUnitData() 等公共接口:

TypeScript
import { createUniverCollaborationRuntimePool } from "@univer-cli/univer-collaboration-runtime-pool";const pool = createUniverCollaborationRuntimePool({  entry: new URL("./collaboration-worker.js", import.meta.url),});const lease = await pool.acquire({  key: targetKey,  init: { unitId, unitType },});try {  await lease.pull();  const result = await lease.execute({ mode: "read", code });  console.log(result.value);} catch (error) {  await lease.invalidate();  throw error;} finally {  await lease.release();}

key 是 Runtime 的复用身份,不只是查询参数。同一个 key 必须始终表示同一个 Unit、同一条协同状态链和兼容的 初始化语义。普通 Unit 与 Worktree draft 即使拥有相同 unitId,也应使用不同 key。

Pool 带来四个重要行为:

  • 同一个 key 同时只会交给一个 lease,并发请求按 FIFO 等待,避免两个调用同时修改同一份 live state;
  • release() 把可信的 Runtime 放回 idle cache,下次相同 key 可以继续使用;
  • invalidate() 表示状态已经不可信,Worker 和 Runtime 会被销毁,下次重新冷加载;
  • idle Runtime 可以按 TTL 和 LRU 回收;timeout、Worker crash 和协议错误也会使当前实例失效。

但是,如果 pool 本身创建在短生命周期 CLI 进程里,复用仍然无法跨命令发生:

100%

Worker 解决了 Runtime 的进程隔离、独占租用和故障终止问题;要跨 CLI 调用复用,还必须让 pool 的 owner 比 任意一条 CLI 命令活得更久。

第三步:让 Daemon 成为 pool 的长期 owner

Daemon 是一个按需启动的本地 Node.js 进程。CLI 通过 Unix socket 或 Windows named pipe 调用它;CLI 退出 不会带走 daemon,因此 daemon 持有的 pool、Worker 和 idle Runtime 可以继续存活。

在 daemon entry 中创建一次 pool,并在业务 handler 中 acquire lease:

TypeScript
import { createDaemonServer, DAEMON_SOCKET_ENV } from "@univer-cli/daemon";import { createUniverCollaborationRuntimePool } from "@univer-cli/univer-collaboration-runtime-pool";const socketPath = process.env[DAEMON_SOCKET_ENV];if (!socketPath) throw new Error(`${DAEMON_SOCKET_ENV} is required`);const pool = createUniverCollaborationRuntimePool({  entry: new URL("./collaboration-worker.js", import.meta.url),  cache: { idleTtlMs: 5 * 60_000, maxEntries: 20 },});const server = createDaemonServer({  identity: { id: "my-cli", version: "1.0.0" },  socketPath,  onShutdown: () => pool.close(),});server.handle("unit.execute", async (payload) => {  const input = parseExecuteRequest(payload);  const lease = await pool.acquire({    key: input.targetKey,    init: { unitId: input.unitId, unitType: input.unitType },  });  try {    const pulled = await lease.pull();    if (pulled.status === "conflict") throw new Error(pulled.conflict.message);    const execution =      input.mode === "read"        ? await lease.execute({ mode: "read", code: input.code })        : await lease.execute({ mode: "write", code: input.code });    const commit = input.mode === "write" ? await commitWithPull(lease) : null;    return { commitStatus: commit?.status ?? null, value: execution.value };  } catch (error) {    await lease.invalidate();    throw error;  } finally {    await lease.release();  }});await server.listen();

parseExecuteRequest() 和 targetKey 规则由 application 定义。Daemon 只提供本地 transport 和生命周期, 不理解 Unit、Runtime 或协同协议。关闭 daemon 时必须 pool.close(),这样 idle 和 active Worker 都有明确的 最终 owner。示例中的 commitWithPull() 负责处理 pull-required 后的 pull/commit,并检查最终提交状态;为保持 示例保守,任何未处理错误都会 invalidate 当前 lease。

普通 CLI 命令现在只负责把一次业务请求发给 daemon:

TypeScript
import { createDaemonClient } from "@univer-cli/daemon";const client = createDaemonClient({  entry: new URL("./daemon-entry.js", import.meta.url),  identity: { id: "my-cli", version: "1.0.0" },  socketPath,});const result = await client.request("unit.execute", {  targetKey,  unitId,  unitType,  mode,  code,});

Socket 不存在时,client 会启动配置的 daemon entry,等待 identity 与 protocol handshake,再发送请求;已有兼容 daemon 时则直接复用它。

第四步:看清第一次与后续请求

第一次访问某个 key 是冷路径:daemon 可能需要启动,pool 也必须创建 Worker 和 Runtime。

100%

后续访问相同 key 是热路径。CLI 进程仍会重新启动,但昂贵的 Runtime 加载不再重复:

100%

这里复用的是“已经创建并加载好的 live Runtime”,不是跳过协同一致性。每次 lease 后通常仍应先 pull();写入 后仍要检查 commit() 的最终状态,并处理 pull-required、retry、unknown 或 conflict。

哪些消耗被省下,哪些仍然存在

成本每次直接加载 RuntimeDaemon + Worker pool
CLI 进程启动、参数解析、结果输出每次都有每次都有
本地 socket JSON request/response无每次都有
Daemon 启动与模块加载无第一次或重启后发生
Worker 启动与 Worker entry 加载无 Worker每个 key 冷创建时发生
创建 Univer、安装插件每次都有每个 key 冷创建时发生
加载 checkpoint、replay 初始 changeset、等待公式每次都有每个 key 冷创建时发生
pull() 获取空闲期间的新 changeset每次都有每次通常仍需要
execute() 与 commit()每次都有每次都有
idle 内存、backend handle、Worker 进程命令结束即释放保留到 eviction、失效或 daemon 关闭

因此,复用是在用常驻内存和 Worker 数量换取后续调用的低启动延迟。应通过 pool 的 event 和实际耗时决定 idleTtlMs、maxEntries、open timeout 与 operation timeout,而不是无限保留所有 Unit。

并发、失败与关闭

最后用四条规则判断系统行为:

  1. 相同 key 的并发请求会排队。 一个 live Runtime 在任意时刻只属于一个 lease,避免协同状态被交叉修改。
  2. 不同 key 可以由不同 Worker 并行处理。 代价是每个 resident key 都占用独立 Worker 和 Runtime 内存。
  3. 可信状态才允许复用。 成功完成并清理本地 mutation 后 release();timeout、crash、协议错误,或应用无法 确认本地状态时 invalidate()。
  4. 关闭责任逐层向下。 CLI 结束一次 request;daemon shutdown 调用 pool.close();pool 再终止 Worker 并关闭 Runtime/backend。

如果命令低频、单次延迟可接受,直接使用 @univer-cli/univer-collaboration-runtime 最简单。只有测量表明重复 加载成为主要成本,并且可以接受常驻进程与内存占用时,才组合 daemon 与 @univer-cli/univer-collaboration-runtime-pool。

完整 package 清单见 Package 概览。

Footnotes

  1. Unit 指一个文档实例,例如一个工作簿或一篇文档。工作表属于工作簿的一部分,不是独立的 Unit。 ↩

  2. 这里的 Runtime 指已加载文档的 Univer 实例及其操作 Unit 所需的运行资源,不是 Node.js 可执行程序,也不是保存到磁盘的文档快照。 ↩

  3. TTL 在达到配置的存活时间后回收空闲缓存 Runtime;LRU 在容量受限时优先回收最久未使用的实例。这些策略释放运行资源,不会删除持久化的 Unit。 ↩

你觉得这篇文档如何?

© 2026 DreamNum Co., Ltd.