并发单体

摘要Corevo 如何拆开 WebSocket 入口、持久 Job 和执行 owner,让空闲 Worker 接走尚未开始的 Agent 任务。

文章目录8 节
  1. 单体不等于单进程
  2. 把连接、任务和执行者拆开
  3. execution started 是恢复边界
  4. 任务换了 Worker,事件怎样回来
  5. 这条链怎样验证
  6. 为什么我停止重写,也没有先拆服务
  7. 调度之外还踩过什么坑
  8. 现在的边界

Corevo 还没有拆成一组微服务时,已经先遇到了分布式系统的问题。

同一份后端可以启动多个 API Worker。用户的 WebSocket 落在 Worker A,一条耗时很长的 Agent 任务也在 A 执行。新的短任务继续从这条连接进来时,A 在排队,另外的 Worker 却可能空闲。入口负载均衡没有失效:它均衡的是连接,不是连接后面持续几分钟、会调用工具并产生副作用的 Agent 工作量。

我把这种形态叫作并发单体:同一应用制品和版本仍然一起构建、发布、回滚,运行时却已经有多个进程、事件循环、线程池和外部执行环境。发布边界还是单体,正确性边界不能再按单进程思考。

下面先用一个确定性的状态机模拟需要排队的 Web Chat 任务。场景固定为:WebSocket 在 W1,W1 正在执行长任务,W2 与 W3 空闲;随后从原连接进入一个短任务。三条 Worker 只是为了让调度关系可见,不代表 Corevo 的生产拓扑或容量。

Clean-room 实验 · 任务归属

连接在哪,不等于任务必须在哪执行

这是离散 tick 的状态机模拟,只比较所有权关系,不模拟 Redis、数据库、模型或网络延迟。

连接入口 Gateway · W1 WebSocket 始终留在这里
持久任务 Job · short 等待运行实验
结果去向 Gateway · W1 尚无事件
可重建候选索引 索引负责唤醒,数据库条件更新决定 owner
W1gateway

长任务占用

W2worker

空闲

W3worker

空闲

到开始执行

从进入系统到真正进入 running。

当前 owner

epoch 随重新 claim 单调增加。

状态结果

fencing 模式只验证迟到的 started。

选择一种模式,观察短任务的执行归属。

单体不等于单进程

同一应用制品可以同时运行 API、后台任务等不同角色;每个角色又可能有多个进程。此时内存、锁、任务句柄和 event loop 都是局部状态,“单体”只剩下一起发布和回滚的含义。

Corevo 没有先把它们拆成网络服务,而是先在代码里分出平台控制面、中立契约和 Agent Runtime。当前自动检查明确禁止新拆出的控制面反向导入内核,也禁止内核跨过中立契约依赖控制面;历史 Runtime 代码里尚未清零的反向依赖,则作为显式债务“只减不增”。这比画一张分层图更重要:新依赖越界会直接让测试失败。

把连接、任务和执行者拆开

最危险的耦合,不是三者在类型上真是同一个对象,而是它们被同一条进程本地 WebSocket 链路绑定:入口 handler 接收输入,本地状态记录排队,进程内句柄决定执行 owner。这里实际混在一起的是三种身份:

  1. Gateway:谁持有客户端连接,负责接收输入和推送流式事件。
  2. Job:用户提交了什么、是否准入、为什么正在排队。
  3. Execution owner:此刻由哪个 Worker 执行这一次尝试。

只要三种身份仍被这条本地链路绑定,连接落到哪里,任务就只能在哪里排队;用户重连到另一个进程后,本地也可能找不到原来的运行句柄。

Job 和 Execution 也不是同一个事实。Job 记录准入、排队、owner 和控制状态;Execution 描述一次真实执行尝试及其父子关系。前者解释“为什么还没开始”,后者让 trace、成本和副作用归到真正发生的那次执行。

并不是所有请求都绕一圈全局队列。入口 Worker 有容量时仍走本地快路径;只有需要排队的 Web Chat 才持久化启动输入,写入 ownerless Job,再进入可重建的候选索引:

Gateway 创建 Job
  ├─ 当前 Worker 有容量 → local direct
  └─ 需要排队 → 持久化 submission
                 → ready index 暴露候选
                 → 空闲 Worker 尝试 claim
                 → 数据库决定唯一 owner

这里有一个刻意的“不聪明”:Redis 只保存待办目录,不负责宣布“谁抢到了”。Worker 看到候选后,用数据库条件更新争夺 owner。下面的字段名已经泛化,但原子条件保留完整:

UPDATE jobs
SET owner = :worker,
    claimed_at = :now,
    epoch = epoch + 1,
    token = :token
WHERE id = :job
  AND state = 'queued'
  AND owner IS NULL
  AND claimed_at IS NULL
  AND execution_id IS NULL
  AND execution_started_at IS NULL;

只有更新命中一行才算 claim 成功。两个 Worker 同时看到同一个候选没有关系,数据库只允许一个成为 owner;随后候选才从 Redis 移除。索引丢失可以从数据库重建,反过来却不成立——一次 Redis pop 不能成为任务所有权的事实。

execution_started 是恢复边界

一个 Worker 在真正开始执行前短暂失联,任务被释放并由另一个 Worker 重新领取;旧 Worker 随后恢复,仍可能提交迟到的 running。因此每次 claim 都生成新的 epoch + tokenqueued → running 必须同时匹配 owner、epoch 和 token:

UPDATE jobs
SET state = 'running',
    execution_id = :execution,
    execution_started_at = :now
WHERE id = :job
  AND state = 'queued'
  AND owner = :worker
  AND claimed_at IS NOT NULL
  AND epoch = :epoch
  AND token = :token
  AND execution_id IS NULL
  AND execution_started_at IS NULL;

epoch 提供顺序,token 绑定这一次租约。旧 Worker 即使补交 started 写入,也不能绑定过期 Execution。终态收敛是另一条独立的不变量:完成链还必须携带并校验自己预期的 execution 身份,不能从“started CAS 被拒绝”推导出“旧调用方的后续清理一定无害”。因此上面的实验停在迟到 started 被拒绝,不宣称 W3 后续一定完成。

更重要的是:只有 execution_started 之前的 claim 才会释放并重新排队。 一旦 Agent 已经开始调用外部工具,自动重跑可能重复发消息、写数据或扣款。运行中的 owner 失联,只能把平台事实收敛为 losttimeout,不能假装把外部副作用无状态迁移走。

这些条件更新保护的是 claim 与 started ownership,不是整条完成链,更不是外部世界的 exactly-once。

任务换了 Worker,事件怎样回来

queued submission 必须先持久化,因为后来 claim 它的 W2 没有 W1 那条 WebSocket 的 Python 对象。W2 从持久输入重建运行命令,启动无前端连接的执行器;模型 delta、工具事件和终态再经过跨进程事件通道,投影回仍持有连接的 W1。Gateway 和 execution owner 至此真正分开。

取消和运行中追加输入走反方向:Gateway 先读取当前 owner,再把命令路由过去;owner 消费前还要复核 Job、Execution 和租约,避免旧命令落到新一代执行上。运行句柄继续留在本地进程,不需要为了横向扩展把整个 Agent 对象序列化。

但“消息已发出”不等于“操作已完成”。尽力投递的控制消息只能表示 requested,最终还要区分 owner accepted、already finished 和 unknown。平台把 Job 标成 lost,也不能自动证明远端进程和外部副作用已经停止。

同理,Agent 循环进入 completed 只说明平台执行结束。发消息、写第三方系统等动作仍需要业务回执、幂等键或 outbox;模型说“已完成”不能充当 commit log。

这条链怎样验证

浏览器实验只解释状态关系。团队对真实实现的验收还需要并发和故障用例:

故障用例必须成立的不变量
两个 Worker 同时 claim只有一个数据库更新命中,只有一个 owner
Gateway 与 owner 不同流式事件和终态仍回到原连接
pre-start owner 失联原排队时间不重置,epoch 增加后可重新领取
running owner 失联收敛为 lost,不自动重新执行
控制命令发到旧 owner租约复核失败,不能触碰当前 Execution

这些用例定义了多 Worker 链路应守住的不变量,不等于端到端正确性已经自动成立。尤其是旧代 started 被拒绝后的失败清理,也必须继续携带旧代身份,不能误收口当前 Execution;这正是独立代码复审仍然抓得到的边界。受控环境的结果不能换算成生产并发数字,“一个平台终态”也不能写成外部副作用 exactly-once。

为什么我停止重写,也没有先拆服务

Corevo 早期耦合很重时,我曾从零写过一套更干净的 Agent 内核。它有独立循环、工具层、CLI 和测试,却没有承载真实产品主链。测试能证明新循环自己可以运行,不能证明它保留了旧系统积累的上下文、工具、错误和流式语义;继续维护只会得到两个内核、两套事实。

我最终停止重写,删除未接入主链的新循环和工具骨架,改为迁移当时经过真实链路验证的七层代码,并用薄 CLI 适配和依赖测试守住新增边界。后续依赖清理由团队继续完成。真实调用方、行为对照和可回滚迁移,比一套崭新的目录更重要。

这也形成了我的判断:此时不应先拆微服务。当状态 owner 和失败协议还在变化时,加入 RPC、独立发布和网络部分失败,只会把模糊边界搬到进程外。等某个模块确实需要独立扩缩容、故障隔离或发布节奏,而且协议已经稳定,再拆部署单元才是运维选择,不是业务重写。

调度之外还踩过什么坑

  • 进程内锁不是全局配额。 我在另一条空间并发链路里处理过“先计数、再占位”的竞态:改成一次原子地清理过期成员、计数和准入,并用持有者心跳、正常释放和过期回收闭环。它和本文的 Platform Job claim 是两套机制,不能混为一谈。
  • 多进程不会修复 event loop 阻塞。 同步上传、文件扫描或数据库调用进入 async def,仍会冻结所在 Worker 的心跳和流式输出;具体证据链在 《同步 I/O 拖死 Agent》
  • 本地缓存一扩副本就是分布式状态。 一个进程清掉工具缓存,不代表其他进程已经失效。Pub/Sub 适合提醒,持久事实、版本或 TTL 才负责自愈。
  • 数据库事务不能跨长时间远程操作。 启动沙箱、上传对象和等待模型前先提交短事务,之后再用条件更新收敛结果;否则长任务会同时占住连接池和外部副作用。

这些坑的共同点是:多开进程能暴露错误假设,却不会自动替你建立全局不变量。

现在的边界

当前代码已经具备模块化控制面、queued Web Chat 的全局 claim、pre-start release/reclaim、started CAS 和跨 Worker 事件回推;这套 Job/Execution 控制面由团队共同演进。旧代失败清理还没有端到端带齐 fencing 身份,因此我不把它表述成“完整的 pre-start 恢复”。本文中我能直接归到自己名下的,是最初的依赖边界检查、一次错误内核重写的停止与迁移转向、旧空间配额的原子准入,以及子 Agent 的 best-effort 跨 Worker 取消语义。

我不把运行中迁移、持久化控制命令、任意复制后台角色或外部副作用 exactly-once 算作已经完成的能力。这些不是谦虚措辞,而是系统对故障负责的边界。

并发单体最终教会我的不是“单体比微服务好”。更准确的结论是:发布单元可以晚拆,状态所有权必须早拆;网络边界可以暂缓,正确性边界不能含糊。