当一个 Agent 开始读文件、运行命令和协调其他角色,问题就从“模型怎么回答”扩展为“系统怎么执行”。Cognitive OS 是我沿着这条边界做的一个开源项目:把任务状态、调度、记忆、上下文和执行放进可检查的 Runtime。
项目地址:cognitive-os-agent。本文是源码与实测系列的第 1 篇。
这篇文章要解决什么问题?
本文围绕“从一次请求理解 Runtime 的职责”展开。阅读顺序是:请求如何进入系统 → 任务怎样被调度 → 对象何时释放 → 如何验证资源边界。先建立概念与执行顺序,再进入具体函数、异常分支和测试,避免把模块名当成理解系统的起点。
| 阅读阶段 | 要回答的问题 |
|---|---|
| 为什么需要独立的 Runtime? | 先明确模型与执行系统各自负责什么,才能判断 C11 的选择是否符合项目约束。 |
| 一次请求如何变成可执行任务? | 接下来沿请求入口追踪到组件装配,把静态模块关系转换为实际调用顺序。 |
| 任务如何排队,协程如何运行? | 任务已经进入系统后,需要分别解释执行顺序、工作线程和容量上限。 |
| 谁拥有任务,何时发布终态与释放对象? | 调度解决何时执行;所有权解决执行和查询同时发生时,对象能否继续安全访问。 |
| 怎样验证并发正确性与排队表现? | 用可放大竞争窗口的测试验证生命周期,再用分阶段测量解释等待时间。 |
| 实现取舍与后续阅读 | 最后把源码事实、实验推论和后续建议分开,避免从实现语言直接推导性能结论。 |
文中的源码行为以原文标注的 cca98b6 为阅读基线;涉及“应该”“可以”或后续演进的内容属于设计分析与验证建议。历史基准和故障记录不代表当前版本每次运行都会产生相同结果。
1. 为什么需要独立的 Runtime?
先明确模型与执行系统各自负责什么,才能判断 C11 的选择是否符合项目约束。
贯穿案例:创建一个文件,要经过哪些可检查的边界?
继续使用“创建 note.txt,内容为 hello”这个任务。下面是用于分析职责的检查表,不是声称现有接口会原样返回这些字段。
| 阶段 | 输入与输出 | 这一阶段要验证什么 |
|---|---|---|
| 接纳请求 | 用户目标变为任务 ID | 请求确实被接纳;返回 ID 还不表示文件已经写入 |
| 调度执行 | 排队任务变为运行中的 runner | 任务只在资源允许时开始,取消和超时有明确观察点 |
| 规划动作 | 文本目标变为工具名与参数 | 文件路径、内容和参数类型符合要求 |
| 执行动作 | 工具参数变为文件变化 | 执行结果与实际落盘状态一致 |
| 验收交付 | 文件变化与目标逐项比较 | 路径正确、文件存在,内容确实是 hello |
| 发布与回收 | 终态、回调、历史记录 | 查询者能看到稳定结果,回调结束前对象不会被释放 |
假设文件已写入,但完成回调仍在向 journal 保存记录。此时“用户目标已实现”和“任务对象可回收”是两件事:前者由文件证据支持,后者还取决于回调和外部引用。若仅以 runner 返回为释放条件,查询线程可能还在访问输出。
因此阅读源码时可以依次追问:提交失败由谁清理对象?入队成功后谁持有它?查询接口借用还是增加引用?最后一个持有者何时释放?这四个问题把调度、所有权和完成历史连成同一条执行线。后文的堆与协程分析,都是为了说明这条线怎样在并发下成立。
从“创建 note.txt”看问题
用户输入“创建 note.txt,内容为 hello”,一个有用的系统至少要回答这些问题:工作目录在哪里?模型返回的工具参数是否有效?写入失败时保留什么?前端显示完成,是否真的代表文件存在?进程重启后还能不能找到执行记录?
这些问题分布在同一条链路上,却不能只靠一段提示词解决。模型可以提出 file_write 动作;动作解析、策略检查、实际文件操作和状态记录仍需要确定性的程序负责。
在 Cognitive OS 中,这条链路大致是:
查看图示源码
flowchart LR
U[用户请求] --> A[HTTP / CLI]
A --> S[任务调度器]
S --> R[Reasoning Loop]
R --> L[模型提出计划]
L --> P[计划解析与策略]
P --> T[工具与事务]
T --> V[观察与完成校验]
V --> R
V --> J[任务记录与界面]
模型会多次参与推理,Runtime 则持续掌握任务的生命周期。一次调用返回了自然语言,并不意味着整项工作已经完成。
C11 的选择意味着什么
我希望尽量直接地控制对象所有权、线程、文件和跨平台接口,因此用 C11 实现核心。项目通过 OS 抽象层连接线程、协程、网络、进程和文件能力;上层的调度、推理和工具接口复用这些基础设施。
这种选择会增加内存管理、错误路径和平台适配的工作量。它的价值在于系统行为比较容易从调用链追踪:谁创建任务、谁持有引用、谁写终态、哪个边界检查取消,都可以落到具体函数。
这并不自动带来更好的推理质量,也不能仅凭实现语言推导性能优势。模型质量取决于选用的模型、输入证据和任务;Runtime 的性能需要用实际负载测量。
把职责拆开之后
目前的核心可以从四个方向阅读:
| 层次 | 负责的事 | 入口 |
|---|---|---|
| 调度与生命周期 | 排队、执行、取消、完成历史 | runtime/scheduler.c |
| 推理循环 | 规划、工具观察、重试与完成判断 | cognition/reasoning.c |
| 知识与上下文 | 记忆访问、召回、活动上下文预算 | memory/、context/ |
| 动作与交互 | 工具、事务、HTTP、Web 界面 | action/、tx/、api/ |
例如多 Agent 不必都占用一个独立进程。角色由 AgentPool 注册,任务由调度器管理;角色数量、排队任务数量和实际工作线程数量是不同指标。后面讨论百万任务测试时,这个区别非常重要。
选择 C 的收益要通过具体约束证明
C11 让资源所有权、对象布局和 OS 接口容易显式表达,也让错误路径的维护成本落到团队自己身上。没有统一垃圾回收器,就要回答每个字符串、JSON 树、线程句柄、协程栈、锁和部分初始化组件由谁释放。反过来,手写内存管理本身不保证省内存;多一次内容复制、长期保留缓存或错误的历史回收一样会推高 RSS。
选择实现语言更适合被当作工程假设,而不是性能结论。可以测 CPU profiling、分配次数、RSS、任务尾延迟和跨平台构建失败数,判断当前实现是否满足目标。与同一算法的另一个语言实现比较时,还要控制编译优化、负载、I/O、线程数和库版本。只拿两个不同系统的单次吞吐,无法隔离语言本身的影响。
项目还承担 OS 抽象层维护成本。Windows、Linux 与 macOS 的线程、文件、进程和网络语义不完全相同。可移植接口应明确错误码、超时、取消和句柄关闭的含义,并为各平台构建及集成回归。编译成功只说明代码能链接,不一定说明运行时取消、文件权限与路径编码的行为一致。
我的判断标准是:核心边界是否能被读者追踪,已知故障是否有相邻反例测试,指标是否准确标注口径,以及维护成本是否仍在可接受范围。若新增一个 C 抽象层只把调用隐藏起来,却没有改善错误处理、替换性或测试性,它可能只是增加间接层。源码讲解应展示这些选择的具体证据,而不是把“底层”“高性能”当成理由本身。
2. 一次请求如何变成可执行任务?
接下来沿请求入口追踪到组件装配,把静态模块关系转换为实际调用顺序。
HTTP、CLI 和 Runtime 之间的边界
同一任务可以从 CLI 或 HTTP 接入,但两条路径负责的适配不同。入口解析端口、工作目录和 state;API 层解析 JSON、身份验证与响应格式;Scheduler 接受任务;Reasoning 决定规划与工具调用。按入口分层排查时,如果 CLI 运行成功而 HTTP 不行,差异可能在字段映射、序列化或 API 权限,不一定是 Scheduler 本身。
一个定位实验是从同一 state/workspace 发出等价的 CLI 与 HTTP 请求,然后比较 task input、attachment metadata、model route、tool actions 与终态。若两条路径不是严格等价,应先确认差异字段,例如 thinking mode、session、timeout、附件或认证上下文。测试设计可以让 HTTP 端到端 fixture 调用真实 Runtime,而不是复制一份 mock 状态机。
服务返回的任务 ID 是关联日志的键,但它可能是进程内生成并由 journal 恢复的数字序列。跨重启连续性需要先看 tasklog 的恢复策略;若多实例共享同一 state,单机 ID 分配并不能保证全局唯一。把本地任务 ID 当成分布式 trace ID 会造成监控关联错误。跨服务链路还需要独立的 request/trace ID。
HTTP 请求断开也不代表任务一定取消。客户端超时后可能只是停止等待;服务器可能仍继续处理请求。若 API 支持显式取消,应让调用方保存 task ID 并提交取消请求,再读取终态。反过来,网关重试 POST 请求可能创建重复任务;端点需要根据幂等键决定是否复用已有任务,当前行为必须通过接口文档与测试确认。
一个可以核对的入口
按照仓库构建说明启动服务,默认示例端口使用 18300。在独立测试 workspace 中,可以发送:
curl -X POST http://localhost:18300/v1/chat \
-H 'Content-Type: application/json' \
-d '{"message":"创建 note.txt,内容为 hello","session":"blog-demo"}'
接口返回任务 id。随后读取 /v1/tasks/<id>,核对终态和工具步骤,再检查工作目录中的文件。/v1/tasks/journal 用于查看持久任务历史。这样的演示把“模型说完成”和“可以检查的结果”连起来了。
工具执行使用运行账户拥有的权限。策略、事务与审计可以帮助约束和追踪操作,但它们不构成对任意外部命令的安全保证;适合先在明确范围的测试工作区里体验。
沿着调用链阅读:请求如何变成任务
阅读入口不必从数千行的推理代码开始。我更建议先建立三个对象的关系:runtime_ctx 持有运行时组件,Scheduler 持有任务生命周期,Reasoning 实例负责一次会话的规划与观察。它们分别回答“系统有什么”“工作处于什么状态”“下一步做什么”。
服务在 init() 中装配配置、策略、工具、记忆、快照和调度器。配置具有明确的覆盖顺序:默认值、状态目录中的配置文件、COA_ 环境变量,部分显式 CLI 配置再覆盖最终值。排查“为什么用了另一组模型或工作目录”时,应查看生效配置和启动参数,而不只看自己修改过的 JSON。
任务被接纳后获得 ID,进入 ready queue。工作线程取出任务,创建协程,执行 runner;runner 驱动推理循环,再把结果交回调度器。下面是阅读用的简化流程,省略锁、引用和错误处理:
提交请求 → 分配 task / 分配 ID → 加入 ready queue
worker 取出 task → 懒创建 coroutine → runner
runner → 上下文 → 模型计划 → 工具 → 观察 → 下一轮
runner 返回 → Scheduler 记录终态 → completion callback
callback 完成 → completed 可回收 → 裁剪内存历史
这里存在两个不同的“结束”:runner 已返回,和完成回调已结束。回调可能还需要将结果写入 journal 或广播进度。如果任务对象在前者就被释放,回调会访问失效内存;因此源码明确要求完成回调先结束,任务才进入可回收状态。
初始化和销毁路径怎样连成一个闭环
运行时装配通常比单一模块更能暴露资源所有权问题。runtime_ctx 需要按依赖顺序构建配置、工具、策略、记忆、快照、AgentPool 与调度器。后面的组件可能引用前面组件,因此销毁顺序要反过来:先停止接收新请求与 worker,再等待活动任务结束,最后释放它们依赖的服务对象。若先销毁 Provider 或工具注册表,而 runner 仍在执行,就会让活跃任务持有悬空指针。
初始化失败发生在任意步骤。比如 HTTP 服务创建成功但调度器 worker 创建只成功了一半,清理逻辑应停止已创建 worker、释放监听 socket、销毁锁和配置内存。最简单的代码组织方式是给 context 内每个字段设置“已初始化”状态,统一走一个可重复调用的 destroy(),或者用 goto cleanup 逆序释放。重复调用销毁应安全,部分对象不存在时不能假设构造完整。
可用故障注入覆盖每个初始化阶段:强制第 N 次 malloc、线程创建或 socket bind 失败,启动进程后检查是否退出、端口是否释放、线程是否残留、临时目录是否清理。仅测试“启动完全成功后正常关闭”看不到这些部分构造路径。进程退出码、stderr 与资源检查应一起记录。
配置也有生命周期。解析出的字符串可能指向 JSON 树内部;如果配置对象在释放 JSON 树后仍保存借用指针,稍后调用 Provider 时就会读到无效内存。检查配置映射时,应明确字段是复制、借用还是引用计数,并让头文件或函数名表达这些区别。C 接口不能依赖调用者“记得 JSON 还活着”。
3. 任务如何排队,协程如何运行?
任务已经进入系统后,需要分别解释执行顺序、工作线程和容量上限。
Ready queue 为什么使用二叉堆
scheduler.c 的 queue_insert() 和 queue_pop() 实现最小堆。比较规则很短,下面是源码节选:
static int task_less(const task *a, const task *b) {
if (a->priority != b->priority)
return a->priority < b->priority;
return a->id < b->id;
}
数值较小的 priority 先执行,相同优先级按 ID 排序。这提供了稳定、可解释的次序。若用有序数组,每次插入或取走队首都可能移动大量元素;堆把插入和取出降为 O(log Q),其中 Q 是排队长度。
但不能因此把整个调度器描述为所有操作都是 O(log Q)。历史记录裁剪、任务查找和输出序列化有自己的路径。做性能分析时,需要分别看提交、出队、执行、完成通知与历史回收;优化一个容器不等于消除整个系统的瓶颈。
堆也没有自动解决饥饿。如果高优先级任务源源不断,低优先级任务可能长期等待。当前这段比较代码没有体现优先级老化;若业务要求等待时间上界,后续应设计老化策略或配额,并把它变成专门的负载测试。
Ready heap 的顺序保证与饥饿边界
优先队列有两个决策:什么时候选哪个任务,以及哪个任务已经满足运行条件。heap 只解决前一个问题。项目当前按数值优先级和任务 ID 排序,ID 使同优先级下次序稳定;这对测试和事故复现很有帮助,因为相同输入下不容易被偶然的数组位置影响。
在一个最小堆中,每个父节点的排序键不大于子节点。插入时从叶子向上移动父项,直到新任务不再小于父项;弹出时取走根,将最后一个元素向下交换到较小的子节点。若队列有 Q 个元素,两种操作每次只沿高度约为 log2(Q) 的路径调整。扩容本身仍可能复制整个指针数组,所以摊还复杂度和单次最坏时延需要分开讨论。
优先级只决定等待队列内部的先后。正在运行的同步工具不会因为出现高优先级请求就立即中断;一个已经让出的协程会回到自己的 worker continuation 队列,也不会自动重新插回全局 heap。换句话说,优先级不是抢占,也不是端到端响应时间承诺。
还要考虑饥饿。若高优先级任务持续到达,低优先级任务即使一直在 heap 中,也可能永远无法成为根。常见处理有等待时间提升优先级、分级队列轮转、按租户配额或给高优先级流量设限。每种方法都改变公平性定义,应先明确“最老任务多久内需要启动”,再设计算法和负载测试。
一个能揭示该边界的测试可以先阻塞 worker,依次提交同优先级任务与不同优先级任务,再释放 worker,记录启动顺序;接着持续提交高优先级流量,观察低优先级等待时间是否有上限。测试不能只断言一个静态任务组的顺序,就推导出系统在持续到达负载下不会饥饿。
协程很多,线程为什么不必很多
调度器在任务第一次真正运行时才调用 coro_new(),不会在接纳任务时为所有排队项提前分配协程栈。源码注释明确指出,这避免了为每个等待任务预付默认栈空间。
举一个只用于理解容量的估算:如果每个尚未运行的任务都提前保留 256 KiB 栈,十万项任务对应约 24.4 GiB 的地址空间。这个算式不是本项目 RSS 测量值,却能解释为什么“接纳任务”和“创建执行栈”应分开。
scheduler_yield() 调用协程让出接口。让出后的 continuation 会进入当前 worker 的本地队列,继续由原线程恢复,因为 Fiber/ucontext 的状态与执行线程有关。把 continuation 直接迁到任意线程,不能只修改一个队列指针就认为安全。
还要区分协程让出与阻塞 I/O。一个同步 HTTP 请求如果一直阻塞线程,即使任务由协程包装,也不会自动变成非阻塞网络调用。因此这里的 M:N 组织方式有助于管理执行单元,但真实吞吐仍受工具、模型等待和取消检查位置限制。
协程生命周期为什么不能只看线程数
“M:N”描述的是许多逻辑执行单元由较少线程承载,不表示所有操作都可被挂起,也不表示一个协程可以在任意线程恢复。项目在首次运行时才创建协程,避免为排队任务立即分配完整栈;任务尚未出队前,只承担任务对象、输入和队列节点等成本。
任务 yield 后,worker 把 continuation 放进属于自己的本地链表。这个决定与底层 Fiber/ucontext 状态的线程关联有关。若未来要做 work stealing,需要先确认所用协程实现可迁移,或者在迁移时完整保存和恢复线程相关状态;也必须重新验证线程局部变量、TLS、栈保护和析构回调等行为。仅仅把队列节点从 worker 0 移到 worker 1,无法证明迁移安全。
阻塞操作是另一条边界。协程调用同步网络函数时,当前 worker 仍可能一直被占住;线程数有限时,几个长时间阻塞的调用会减少其他任务的实际并发机会。把“用协程”描述成“自动非阻塞”会掩盖这一点。评估运行时,需要观察不同类型工作:纯 CPU、等待模型 HTTP、执行 shell、读取大文件、短周期高频 yield。每一类对 worker 的占用都不同。
对取消也一样。task_should_abort() 读取取消标志或超时条件,但只有控制流回到调用它的位置,任务才能响应。模型 HTTP 请求或外部进程若没有可用的中断机制,标记取消并不一定马上终止底层操作。系统可以先拒绝新的阶段、限制请求超时,再在安全边界清理;用户界面显示“已请求取消”和后端确认“已终止”也应区别。
队列容量是稳定性问题,不只是性能问题
ready heap 可以动态扩容,但“内存允许就一直接单”不是完整的生产策略。上游流量持续超过 worker 服务能力时,队列长度会一直增加,等待时间与排队对象占用也会增加。任务超时从开始执行还是从提交时计时,直接改变这类积压的语义。
当前 task_should_abort() 使用 started 时间和 timeout 检查运行任务。一个长时间待在队列中的任务可能还未消耗执行预算;如果产品需要限制总请求时长,应该另存 submitted deadline,并在排队和运行阶段都检查。否则用户可能在队列等待很久后才开始一个“仍有完整执行时间”的任务。无论选择哪一种,都应让 UI 能解释超时计时从什么时候开始。
背压可以在服务入口限制 active+queued 数量、按用户/租户限额,或将任务持久队列化。同步返回拒绝会使客户端更快重试,若无退避又可能形成重试风暴;排队过久则浪费内存并产生过期工作。合理方案要定义拒绝码、重试间隔、优先级和超时预算,并做突发负载实验。
固定线程池的容量也不是简单等于线程数。任务可能 yield 后暂时让出 worker,阻塞 I/O 却会占住线程;因此相同 worker 数的吞吐会随任务等待模式变化。建议分别测 CPU runner、短 I/O、长 HTTP 阻塞和频繁 yield,再将 max_active 与 worker 数组合起来观察。单一 benchmark fixture 无法代表所有 Agent 工作。
4. 谁拥有任务,何时发布终态与释放对象?
调度解决何时执行;所有权解决执行和查询同时发生时,对象能否继续安全访问。
C 代码里最值得看的是所有权
在 C 中,一个 JSON 输出、一段模型回答、一个 task 指针,都必须有明确的释放方。以任务查询为例:API 如果只拿到一个裸指针,调度器恰好在另一线程裁剪历史,读取输出时就可能发生 use-after-free。
项目提供 scheduler_acquire() / scheduler_release() 这样的持有接口,配合历史裁剪对 references、borrowed 等条件的检查。应重点阅读“获得引用时是否持锁”“释放引用后是否还使用字段”“回调期间是否允许回收”,而不是只检查正常路径有没有 free()。
| 对象或资源 | 应核对的所有权问题 | 典型失败表现 |
|---|---|---|
| 模型返回的字符串 | 谁接管,重试分支是否释放 | 长任务内存逐轮增加 |
| 工具参数 JSON | 是否借用解析树内部字符串 | 释放 JSON 后路径变成乱码 |
| task 对象 | 查询与历史裁剪如何同步 | 偶发崩溃、输出损坏 |
| 完成回调 | 回调结束前对象能否回收 | journal 写入访问失效对象 |
| 部分初始化的组件 | 初始化失败怎样逆序清理 | 启动失败后句柄泄漏 |
这也是我选择 C11 后必须承担的成本:这些约束更显式,错误也更直接。工程价值来自把约束落实并验证,不能由“没有垃圾回收”直接推出低延迟或低内存。
源码阅读基线:装配入口、队列、worker 与回收、任务结构。这些链接固定到本文核对的提交,后续实现可能继续变化。
把一次任务画成对象关系,而不只画成模块框图
读 Runtime 时,我会先画出任务、调度器、工作线程和协程之间的关系。模块图回答“代码放在哪”,对象关系回答“某个时刻是谁持有它”。对 C 程序来说,后者往往更能解释偶发崩溃、状态错乱和退出时的资源泄漏。
一个简化的关系可以写成:
scheduler
├─ all[] ─────────────── task A ── input / output / progress
│ └────── coroutine A
├─ ready heap ────────── task B
├─ worker 0 ──────────── task C 的协程恢复点
└─ worker 1 ──────────── task D 的协程恢复点
这个图只是帮助阅读的抽象。实际实现还有调度器互斥锁、任务进度锁、完成回调和工作线程本地 continuation 队列。尤其要区别 all[] 和 ready heap:前者负责当前仍由调度器管理的任务集合,后者只保存等待首次运行的任务。一个任务从 ready heap 弹出后,并不因此从生命周期集合中消失。
调度器新建任务时会分配输入、初始化状态并生成 ID。只有 ready heap 插入成功,提交才算真正进入队列;若分配或插入失败,就要逆序释放已经创建的字段。阅读错误路径时,可以把“每个成功创建的资源”列出来,然后逐个问失败分支是否覆盖它。这样比在正常流程里寻找 free() 更容易发现部分初始化泄漏。
任务完成也有多个时点。协程函数返回意味着 runner 已停止;Scheduler 设定终态意味着调度状态已变;完成回调可能还在写 journal、推送事件或保存相关记录;回调结束后,任务才被标为可回收候选。代码注释明确要求回调结束前不能回收任务。若把这几个时点压成一个“完成”,就会看不懂为什么任务结构还要留在内存里。
从日志看状态时,也应区分“执行状态”和“记录状态”。任务可以已经进入 DONE,但持久化或通知的回调尚未结束;反过来,任务 journal 中存在一条记录,不能证明所有副作用都已确认。观测字段最好保留事件顺序与单调时间,以便识别延迟来自 runner、回调还是读取接口。
终态转换为何需要集中管理
任务状态可能由 runner、取消线程、超时检查和完成回调同时影响。若每个位置都随意写 status,就可能出现两个终态竞争:用户取消与 runner 正常返回几乎同时发生,到底任务是 DONE 还是 CANCELLED?Scheduler 需要定义终态判定顺序,并在统一锁保护下写入一次。
finalize_task() 在 worker 收到协程结束后集中设 finished time、判断 timeout/cancel flag 并减少 active。这个集中点能减少不同 runner 自己定义终态的情况,但仍应检查边界:时间预算在 runner 刚返回时过期,是否被标为 TIMEOUT?cancel flag 与 timed_out 是否可能同时为真,哪个优先?具体顺序是产品语义的一部分。
进度数据和终态对象可能由不同锁保护。文档注释指出完成输出由 Scheduler 锁稳定,而 progress 有单独互斥锁。若读取接口同时获取两类数据,要避免锁顺序相反形成死锁。假设线程 A 先拿 Scheduler 锁再取 progress 锁,而线程 B 先拿 progress 锁再取 Scheduler 锁,二者就可能互相等待。所有调用点应保持固定锁顺序,必要时复制快照后释放锁再序列化 JSON。
状态发布最好发生在内部数据一致之后。先将 task 设置为 DONE、通知客户端,再补写 output,UI 可能短暂读到 DONE 却看不到结果;先写输出、更新终态再推事件则更容易保持一致。跨内存和 journal 不能获得真正的原子事务时,可以让事件带 version 或写入顺序,并在查询时返回一致快照。
Runtime 的完成历史是内存策略
completed task 保存在 Scheduler 的 all[] 中一段时间,方便查询和回调。prune_history() 同时统计完成任务数量与字节,然后在越过限制时查找可回收对象。若最早任务仍有外部引用或 borrowed 标记,算法会跳过它,继续找其它符合条件的旧任务。历史上限因此代表尽力回收到阈值,而非任何时刻都严格等于该数字。
持有引用的任务会短暂超出 history limit,这是保护并发读取安全的设计代价。读取接口如果忘记 release,旧任务可能一直不能回收;相反,引用计数过早减一可能造成 use-after-free。要诊断历史增长,最好同时观察 retained task count、blocked-by-reference 数和 retained bytes,而不是只看全局任务总数。
内存历史和磁盘 journal 的职责也不同。prune 释放进程中的对象,并不代表磁盘中对应任务事件被删除;journal 可能长期增长,需要自己的滚动、压缩或保留策略。反过来,磁盘历史清理也不应在一个对象仍被 API 引用时强行释放内存字段。两类保留策略应独立配置,并说明可查询窗口。
在单机容量估算中,任务对象大小并非固定值:input、tag、attachments、output、progress 和 trace JSON 都是可变字段。history_count=64 不代表最多只占几 KB。长上下文与详细执行 trace 可能让少数任务主导内存预算。可在 task_bytes() 计数时记录不同字段贡献,帮助决定应裁剪输入、压缩 trace 还是缩短保留时间。
5. 怎样验证并发正确性与排队表现?
用可放大竞争窗口的测试验证生命周期,再用分阶段测量解释等待时间。
生命周期测试应该把竞争窗口放大
很多并发生命周期 bug 在正常速度下难以稳定复现。测试可以用 barrier 或可控 fixture 人为扩大窗口:先让完成回调停在 barrier,尝试裁剪历史并并发查询任务;确认引用和 borrowed 标记阻止回收;释放 barrier 后再检查对象是否最终可回收。若仅循环提交几千项任务并期望偶发崩溃,失败概率很难解释,测试也难以证明修复覆盖了哪条竞争。
任务查询用例应分别覆盖:任务仍在 queued、runner 正在读写进度、完成回调执行中、任务终态已记录、历史裁剪候选中。每种状态下都检查 API 返回数据的所有权和释放顺序。对错误路径则可以注入:线程创建失败、协程创建失败、heap 扩容失败、callback 返回错误、进度 JSON 分配失败。注入点让测试聚焦于回滚与清理契约。
如果对象提供 acquire/release,测试还应核对引用的配对:acquire 成功时 release 一次;acquire 失败时不能释放未持有引用;早退路径不能漏 release;释放后不能再读取字段。AddressSanitizer 等动态工具有助于捕获越界和 use-after-free,但它们只覆盖实际跑过的路径。静态分析、错误注入和竞争测试各自发现不同类别的问题,彼此不能代替。
可复现的任务记录最好包含运行二进制路径、代码版本、编译器、线程数、配置目录、任务 ID 和终态时间。否则用户说“我刚编译了最新版本”,但真正处理请求的是安装目录下的旧进程,开发者很难判断测试结果属于哪一版。这个项目曾遇到类似的运行环境混淆,排查时需要把实际进程路径和包时间戳作为证据。
如何测量提交到启动之间的等待
队列 latency 可以从任务提交时间和 runner 开始时间计算,但二者必须来自同一种单调时钟。Wall clock 可能因系统同步向前或向后调整;跨时区展示的时间不适合直接做耗时减法。进程内用 monotonic clock 得到 duration,再用 wall clock 记录事故发生时间,是常见的组合。
提交时间应该在 API 认证、请求体解析之后,还是在进入 ready queue 的那一刻?这些选择回答不同问题。用户端到端时长包括 HTTP 接收,Scheduler queue latency 通常从任务 accepted 到 runner start。报告命名要写清起止点,不能把 queue latency 宣传成 API latency 或 Agent 响应时间。
采集本身也有成本。每项任务都记录多个 trace span 会分配内存并增加锁竞争;可以对高频成功任务采样,对所有错误任务保留详细事件,并把关键 counter 作为低成本指标。抽样会影响尾部事件被采中的概率,需保存采样率并对 histogram 做权重修正,或避免对 P99 做不可靠校正。
利用排队分布可判断是否需要扩容。若 arrival rate 上升时 queue p99 稳步增加但 runner duration 稳定,优先检查服务槽和背压;若两者同时增加,可能是共享资源变慢导致服务率下降。观察开始时间和结束时间的直方图,比只看平均任务完成数更能解释用户等待。
进一步的队列测试:突发后能否恢复
任务突发结束后,系统应逐渐排空 ready heap,排队延迟回到基线,history pruning 保持上限,且后续短任务不被旧 continuation 永久压住。可以用 burst-pause-burst 三段负载反复观察,而不是只在持续恒定提交下测平均吞吐。
测试可以在每段结束时记录 queue length、active count、oldest task age 和 worker idle time。若 queue 已归零但 oldest task age 仍异常,计时点可能有误;若 active 归零后仍有 callback pending,就需要单独观察 completion 工作。阶段边界的观测能让“服务恢复正常”成为可检查结论。
从任务提交到回收的读码练习
可以用下面的小实验验证上文的对象生命周期:选择单 worker 和较小的完成历史上限,提交一项可控任务,让 runner 先写进度再 yield;另一任务查询该进度;随后取消或等待任务完成,查询最终状态;最后继续提交短任务,观察历史裁剪。对每一步都记录 task ID、状态、队列位置和 callback 时间。
如果要避免接触模型服务,可从 Scheduler 测试里的本地 runner 入手。runner 可以按 barrier 控制何时 yield、何时返回、何时写输出。这样可以把队列和生命周期同模型质量分离,专注检查并发不变量。完成之后再走一次 HTTP API,验证任务状态如何序列化以及 journal 如何记录变化。
读代码时建议按这些问题记笔记:
| 观察点 | 源码问题 | 可证伪的实验 |
|---|---|---|
| 排队 | 成功入队前是否已完成必要初始化 | 注入 heap 扩容失败并检查是否有半注册任务 |
| 首次执行 | 协程何时分配,失败状态如何写入 | 注入 coro_new 失败,观察活动数是否归零 |
| yield | continuation 放入哪里,谁能恢复 | 单 worker 多次 yield,核对线程亲和与顺序 |
| 完成 | callback 何时执行,任务何时可回收 | 暂停 callback 并并发读取、裁剪历史 |
| 回收 | 哪些状态或引用会阻止释放 | 固定任务引用后超过历史上限再释放 |
| 取消 | 哪些等待可以被中断 | 在 HTTP 或工具等待期间取消并计时 |
实验结果要记录实际观察,而不是预期。若队列延迟增加,先区分 worker 饱和、同步 I/O 阻塞、回调变慢和锁竞争;若进程内存增加,再看活动任务、完成历史、协程栈、缓存和 benchmark 记录数组。一个指标只能缩小问题范围,不能单独指出根因。
一次源码阅读可以产出哪些可验证问题
读调度器时,不妨把每个结论都写成“我预计 A 在条件 B 下会变成 C”的形式。比如“同优先级按 task ID 稳定排序”可以由构造任务顺序和检查 runner 起始顺序验证;“回调期间任务不会被裁剪”可以由 callback barrier 与并发 query 验证;“取消不会立刻终止阻塞 Provider”需要用可控 Provider 阻塞、计时并观察底层请求是否结束。
这种方法还帮助区分事实与设计意见。事实是 worker 从 yield_head 优先恢复本地 continuation;推论是同一 worker 可能在全局队列任务前恢复旧协程;建议是观察局部队列是否造成全局饥饿。源码可以直接支持事实,测试可以验证推论,建议则还需要工作负载与产品需求。
读完源码后保存的“可证伪问题”比一张函数名列表更有用:队列扩容失败是否保留正确状态?重复取消是否幂等?worker shutdown 时 continuation 是否被清理?callback 失败是否影响 active 计数?历史限制同时使用 count 和 bytes 时哪个条件优先?这些问题都能成为明确的 issue 或回归用例。
6. 实现取舍与后续阅读
最后把源码事实、实验推论和后续建议分开,避免从实现语言直接推导性能结论。
阅读后的结论
Cognitive OS 的 Runtime 值得从生命周期、所有权和失败路径理解,而不只是从功能列表理解。最有信息量的源码通常不是 happy path,而是协程创建失败、任务被取消、历史裁剪、callback 尚未完成或页面预算拒绝提升这些分支。
这也决定了后续文章的阅读方式:先问一个具体工程问题,再找负责的对象与状态,最后找能失败的反例和回归。项目并没有因此变成完整操作系统,也不保证所有外部副作用可恢复;它提供的是一套可检查的运行时边界,剩余保证必须逐项验证。
本文之后值得追踪的 Runtime 演进
若 Scheduler 继续演进,我最想观察三条数据:不同优先级的等待时间、协程 yield 后同 worker continuation 的局部队列占比,以及 callback/历史裁剪耗时。它们分别关联公平性、可运行性和终态成本。没有这些指标前,直接调整堆、增加线程或缩短历史上限,都缺少足够的收益证据。
第二条演进是给任务定义更明确的提交契约:入队成功返回 ID;验证器以 task ID 关联请求目标;每个工具动作带 invocation ID 和结果状态;最终状态列出哪些验收项通过。这样不仅改善生成文件任务,也能让解释类、读取类和发布类任务分别采用合适的完成标准。
第三条是跨平台语义测试。线程、文件替换、进程终止和网络超时在 Windows/POSIX 上有实现差异,应该共享行为规范而由平台测试验证细节。只有 Windows 本地基准不能代表 Linux 容器,也不能说明 macOS 协程恢复语义相同。
为什么值得继续看
Cognitive OS 最吸引我的部分,是把 Agent 问题拆成可验证的系统问题:讨论如何停止、依赖何时满足、上下文为什么被淘汰、恢复后能否继续执行。这个项目仍在演进,适合对 Agent 基础设施、C 系统设计和可靠执行感兴趣的开发者一起研究。
源码:Runtime 装配、Scheduler、Reasoning。
延伸阅读与实践顺序
完成本文后,建议选取“任务如何排队,协程如何运行?”中的一个分支,用固定输入记录预期行为、实际结果和差异。先验证一个具体不变量,再扩大任务规模;若结果不同,优先核对源码版本、配置与输入证据。
下一篇:让多个 Agent 真正讨论起来。欢迎运行项目、提出可复现的问题,或从一个小模块开始参与。