何时对异步说不

先讲一个让我肉疼的调试故事。ARES 最早的雏形,是一个简单的 chatbot:一个 Leader、两个 Sub、几个工具。开发环境跑得好好的。一上生产,Leader 会在 20 分钟后静默停止响应——没报错、没 panic、没崩溃日志。就……沉默。

我花了三天才找到根因:LLM 客户端里有个 goroutine 泄漏。每个请求泄漏一个 goroutine,最终撞上 OS 的线程上限。修复是一行。但找到它花了 72 小时,因为我对 Agent 内部发生了什么零可见性

那次之后我才明白:问题从来不是「怎么造一个 Agent」,是「怎么让一个 Agent 在生产环境里活着」。

异步,在这个故事里是氧气,也是那颗泄漏的螺丝。

Problem: ARES 的 AHP 协议用 Go channel 做同进程通信,延迟 <1μs(第二篇);goroutine 泄漏曾让 Leader 静默死亡 20 分钟才被发现(第一篇架构篇)。异步是 ARES 的氧气。但异步不是信仰——什么时候该对「全异步」说不?

异步贩卖的是「不等待」

异步的 selling point 永远是那句话:别阻塞,让调用方先走。听起来无敌。ARES 的 Leader 把任务扔给 Sub 就继续干别的,不用等 Sub 跑完(第二篇)——这确实是异步对的场景。

但「不等待」的代价是因果链断裂。同步调用里,A 调 B,B 挂了,栈告诉你就在 A 调 B 那一行。异步里,A 发了个消息就走了,B 三秒后在另一个 goroutine 炸了——你拿到的栈和 A 毫无关系。调试异步 bug,本质是在拼一幅你没有碎片的拼图。

AHP 的入队是非阻塞的,返回 ErrQueueFull 立刻走人,绝不阻塞调用方(第二篇 MessageQueue.Enqueue)。这设计没错——但「立刻走人」意味着:如果 Sub 那边出了问题,Leader 这边不会停,它已经去干下一摊事了。

信任模型决定该不该异步

「并发的本质是信任」那篇讲过:并发策略选错,追踪器本身成了瓶颈(memscope-rs 篇)。异步同理——你敢不敢信任下游会按时回来,决定了你能不能放心地不等待。

  • 下游是同进程、确定性、毫秒级返回(如 AHP 的 channel)→ 异步划算,信任成本低。
  • 下游是外部 API、人类、不可控的 Agent → 你「不等待」只是把等待藏起来了,问题没消失,只是你失去了在出错现场的位置。

ARES 第一篇架构篇里那个 20 分钟静默死亡,根因正是 goroutine 泄漏:异步的东西死了,没人通知同步的世界。异步把「它还活着吗」这个问题,从显式的返回值,变成了你必须额外建造的观测系统(见「可观测性的代价」)。

20 分钟静默死亡:异步的反面教材

回到开头那个故事。Leader 静默停止响应,不是因为它「崩溃了」——崩溃至少会留个栈、写个日志。它是异步地、悄悄地死掉的:一个泄漏的 goroutine 每次请求一个,慢慢把线程池吃光,等到 OS 线程上限被撞上,Leader 就已经没法再处理任何新请求了。

整个过程里,没有任何一个同步的、会立刻回报错误的路径被触发。所有「我还活着吗」的检查,都是异步的、延迟的、可以被泄漏绕过的。等我发现时,它已经「活着但死了」20 分钟。

异步把「它挂了」这个事实,从「调用方立刻拿到的返回值」,降级成了「某个后台机制将来某刻可能会注意到」。区别就在这:同步逼你当场看见失败,异步让你将来某刻才可能发现。

AHP 的异步,是刻意设计出来的

补一个 AHP 里的细节,说明 ARES 对异步的态度不是「默认全异步」,而是「每一处异步都得过脑子」。

Enqueue 那个 ctx 参数其实是摆设——它的 selectdefault 分支永远先命中,根本不会去选 ctx.Done()。设计者明说这是有意的:入队要的是「立刻走人」,不是「等到上下文取消」。把 ctx 摆在那儿,是接口对称性,不是真要阻塞等它。

还有个更刁钻的点叫 TOCTOU 规避SendMessage 故意在入队前先 IsFull() 检查一下。因为如果先检查、再入队,这中间队列可能从「没满」变成「满了」(竞态),反而丢消息。直接干、再处理错误,比「先看再干」更稳。

func (q *MessageQueue) Enqueue(ctx context.Context, msg *AHPMessage) (retErr error) {
    if q.closed.Load() { return errors.ErrQueueClosed }
    defer func() {
        if r := recover(); r != nil { retErr = errors.ErrQueueClosed }
    }()
    select {
    case q.messages <- msg:
        return nil
    default:                        // 永远先命中,从不会等 ctx.Done()
        return errors.ErrQueueFull
    }
}

这些细节都指向一件事:ARES 的异步是被设计出来的诚实,不是框架默认的信仰。它知道异步会把失败藏起来,所以给心跳检测、给 DLQ、给 Compactor——用一整套观测设施,去补异步偷走的那个「出错现场」。

同步的诚实之处

同步最被低估的价值是:它强制你在出错的地方看到出错。 一个返回 error 的同步调用,逼你把失败写进主路径。异步把失败降级成「某处的一个事件」,主路径假装一切都好。

ARES 那个 goroutine 泄漏,如果发生在一条同步的、每次调用都立刻检查返回值的路径上,可能几分钟内就会被告警抓住——因为它会表现为「请求开始失败、错误率飙升」,而不是「系统悄悄变慢直到彻底没反应」。

很多系统不是需要更多异步,而是需要更少的假装成功

我当时怎么想的,又错在哪

我一度是异步的信徒。goroutine 轻、channel 快、非阻塞入队优雅——我把「能异步就异步」当教条。那个 72 小时才定位的泄漏,把我打醒了。

我错在把异步当成了信任的替代品,而它其实只是信任的延伸。当你其实不信任下游、却假装异步时——你不是在设计系统,是在给自己埋雷。ARES 的 AHP 用 channel 是对的,因为同进程通信满足「确定性」这个前提;但 20 分钟静默死亡也是 ARES 的,因为它把本该同步感知的「我还活着吗」也异步化了。

异步不是去掉了等待,只是把等待从你能看见的地方,搬到了你看不见的地方。看不见的等待,才是真的成本。

结论

「全异步」和「全同步」一样愚蠢。区别只在于你愿不愿意为每一次「不等待」支付调试税。

我的判断线很简单:如果出错的现场必须和调用的现场在同一视野里,就同步。如果下游的生死对你当下的决策无关紧要,才异步。

AHP 对,是因为同进程通信满足前者里的「确定性」;20 分钟静默死亡错,是因为它把本该同步感知的「我还活着吗」也异步化了。

异步是信任的延伸,不是信任的替代品。当你其实不信任下游、却假装异步时——你不是在设计系统,是在给自己埋雷。


Next: 重写还是演进 -- OmniScope 从 Zig 原型改写成 Rust,ARES 从 GoAgentX 改名重生。每个项目都会撞上同一个岔路口:推倒重写,还是渐进演进?这条线到底该画在哪。