02Actor 到底是什么:四条金律与公司隐喻
私有状态、串行处理、不可变消息、地址寻址;let-it-crash 的反直觉错误哲学。
actor/pid.go:Receiveractor/actor.go:run上一章从 AI 后端的三处痛点倒推出「需要一个新范式」;这一章把这个范式的地基浇筑清楚——Actor 到底是什么,它靠哪几条规则站得住脚。先放下 Go 语法细节,把这套世界观的公理刻进脑子里:后面几条看似「限制」的规则,每一条都是运行时用来物理消灭某一类并发 bug 的手段,而不是纸面上的君子协定。
一句话原点
不要通过共享内存来通信,而要通过通信来共享状态。
传统并发的默认剧本是「多线程 + 共享内存 + 锁」——脑子里永远转着同一组问题:谁在读、谁在写、会不会撞车、锁该加多粗。Actor 把这套剧本整个推翻:系统里没有共享内存,只有一堆各自拥有私有状态的 Actor,谁都碰不到别人的那份状态分毫。想让某个 Actor 做事、想探它的状态,唯一的途径是给它递一条消息。它从自己的收件箱里逐条取出消息、处理、也许改改自己的状态、也许回一封信、也许再给别人发一条。
这句话反过来读同样成立:通信本身就是共享状态的唯一途径。放弃共享内存不代表系统里没有状态,而是把「谁能碰这块状态」从「任何拿到指针的线程」收窄成「只有状态的主人自己」。别人想知道当前值,也得排队发一条消息进去,等它腾出手来才处理——这看似自缚手脚,实际上是把「并发正确性」从「程序员每次读写都要格外小心」,搬到了「运行时结构上根本不允许出错」。一旦接受「状态私有、只靠消息往来」这个前提,锁、竞态、内存可见性这些顽疾就从根子上不存在了——不是被小心翼翼地避免,而是物理上不可能发生,因为压根没有两个执行体同时摸同一块内存的时刻。
最好的心智模型:一家公司
理解 Actor 最顺手的类比不是「线程」或「进程」,而是一家公司:
- 每个 Actor 是一名员工,有自己的办公桌(私有状态),谁也翻不了他的抽屉。
- 员工之间靠递工单沟通(消息),不能冲进对方办公室直接动他的电脑。
- 每个员工桌上有一个收件箱(Mailbox),工单按到达顺序排队,他一次只处理一件。
- 员工可以招下属(创建子 Actor),组成一棵上下级树。
- 下属出了岔子,由**上级(Supervisor)**决定怎么办——让他重新来过、让他歇一会,还是把问题捅到更高层。
这个隐喻还藏着一层容易被忽略的红利:工单可以送给隔壁工位的同事,也可以送到异地分公司的同事手上——寄件人不需要知道对方坐在哪栋楼,只需要知道对方的工位编号(地址)。哪怕系统内部把处理某张工单的员工换了一个人(重启),寄件人的寄送方式也完全不变。这正是第④条金律「位置透明」在隐喻里的样子。
graph LR
sender["其他 Actor"] -->|Tell 消息| mailbox[("Mailbox<br/>FIFO 收件箱")]
mailbox --> loop{{"串行处理循环<br/>run()"}}
loop --> state[("私有状态<br/>State")]
state -.-> loop
loop -->|发消息| other["别的 Actor 的 Mailbox"]
loop -->|创建| child["子 Actor"]
公司隐喻:消息进收件箱,串行循环取件、动私有状态
这个类比之所以好用,是因为它一次性回答了并发系统里最难缠的三个问题:任务怎么分派、状态归谁管、出错谁兜底。三个问题背后其实是同一个决定——放弃「谁都能碰所有东西」的全局视角,换成「每个人只对自己的一亩三分地负责」的局部视角。
三个组成部件
任何 Actor 拆开看,都是这三样东西的组合:
State(状态)——私有变量,别人永远读不到、写不到,只能靠发消息求它自己改。在实现里,这些字段就是 Actor 结构体上的普通字段——没有 Mutex,没有原子操作,因为运行时保证的单线程语义(见下面第②条金律)已经替你把这件事做完了。
Behavior(行为)——收到一条消息时执行的逻辑,能做四件事:改自己的状态、给别人发消息、创建新 Actor、决定下一条消息用什么行为处理。它就是接口本身,只有一个方法:
// Receiver[T] 是所有 Actor 必须实现的行为接口,也是模型的心脏。//// 运行时保证:同一个 Actor 的 Receive 永远不会被并发调用(一次一条消息,单线程语义)。// 因此在 Receive 内部你可以像写单线程代码一样自由读写自己的字段,完全不需要锁。// 并发的复杂度被「关」进了 mailbox channel。//// 接口只有一个方法——遵循「接口越小,抽象越强」。type Receiver[T any] interface { Receive(ctx *Context[T], msg T)}只有一个方法是刻意的极简:Receive 拿到的 ctx 和 msg 就是它了解外部世界的全部渠道——通过 ctx 发消息、创建子 Actor、读自己的地址,除此之外它连一次额外的系统调用入口都没有。能做的事情越少,能捅的篓子也越少,这正是「接口越小、抽象越安全」的体现。
Mailbox(邮箱)——一个 FIFO 队列,缓存别人发来的消息,Actor 从里面逐条取,永远不会同时处理两条。它是一个有边界容量的队列而非无限增长的切片,容量满了发送方就会收到「忙」信号——这也是后面章节要展开的背压机制的起点。
四条金科玉律
真正撑起 Actor 的,是下面四条看似「限制」、实则是安全来源的约束。每一条都分三层看:它承诺了什么、运行时具体怎么兑现这个承诺、以及少了它系统会坏成什么样。
① 状态完全私有,只靠消息交互。
它承诺:没有任何人——哪怕是它的创建者或父 Actor——能绕开 Actor 自己去改它的状态。运行时怎么兑现这个承诺:你手上永远拿不到一个指向「另一个 Actor 对象」的指针,运行时压根不对外暴露这样的引用;你能拿到的只是它的地址(见第④条),地址只允许你发消息,不允许你伸手进去改字段。想让某个 Actor 的状态发生变化,唯一姿势是发一条它认识的消息,让它自己在 Receive 里改自己的字段。少了这一条会怎样:只要允许任何一方直接拿到别人状态的引用,系统就立刻退回「共享内存 + 忘记加锁」的老路——两个 goroutine 同时改一个 map 或结构体,测试环境跑一万次相安无事,上线后在某个巧合的时间窗口里数据错乱甚至 panic。这条律是后面三条能立得住的地基:没有私有状态,「串行处理」要保护的对象根本不存在。
② 一次只处理一条消息(单线程语义)。
它承诺:无论外部有多少个 goroutine 同时在给一个 Actor 发消息,它在任意时刻都只在执行一次 Receive。运行时怎么兑现这个承诺:每个 Actor 创建时都会起一个专属 goroutine 跑处理循环,这个循环从 mailbox 里一条一条取消息,处理完一条才取下一条,绝不会为了「提速」而开新的 goroutine 并发处理多条消息:
// run 是 Actor 的处理循环:一个 goroutine 串行地取消息、调用 Receive。// 「串行」是关键——它保证 Receive 永不被并发调用,Actor 内部因此天然无锁。func (p *process[T]) run() { defer p.system.wg.Done() p.state.Store(int32(StateRunning)) p.lifecycle(func(lc Lifecycle) { lc.Started() })
for { select { case c := <-p.ctrl: if p.handleCtrl(c) { p.terminate() return } case e := <-p.mailbox.user: if p.deliver(e) { p.terminate() return } case <-p.mailbox.done: p.terminate() return case <-p.system.ctx.Done(): p.terminate() return } }}🔑 设计钥匙
把并发的全部复杂度「关」进 Mailbox 这一个队列里,Actor 内部的世界就永远是单线程的——一把锁都不需要。这正是 Actor 模型用「异步消息」换来的东西:把「谁在并发访问」这个问题,从「代码里到处判断」收敛成「只在 Mailbox 一处排队」。
少了这一条会怎样:如果运行时允许同一个 Actor 的多条消息被并发处理,那些看似安全的字段读写立刻会变成数据竞争——相当于在一个假装单线程的地方悄悄引入了多线程,而且这种 bug 极难复现,只在消息恰好并发到达的窄窗口里现身,测试很难覆盖到。
③ 消息不可变、异步。
它承诺:发出一条消息之后,你对它的处理时机、处理结果没有任何直接控制权;发送方和接收方看到的是同一份数据的两份独立拷贝,谁改都不影响另一边。运行时怎么兑现这个承诺:Tell 是最基本的通信原语,它把消息封进一个信封投进对方 mailbox 就立刻返回,不等待、不阻塞(除非 mailbox 已满):
// Tell 异步投递一条类型为 T 的消息,不等待回应(fire-and-forget)。//// 这是最基本的 Actor 通信原语。方法接收者已绑定类型参数 T,因此当 T 是消息联合接口时,// 可以直接传入任意实现了该接口的【具体类型】——编译器保证你不会发错类型的消息。// 返回 false = 对方 mailbox 已满(背压忙信号)或已停止,此时消息进入死信队列并计数。func (p *PID[T]) Tell(msg T) bool { return p.deliver(envelope[T]{msg: msg})}约定上,消息应当是值类型,或者是发送方发出后就不再持有、不再修改的数据——一旦发出,就该当它是泼出去的水。少了这一条会怎样:如果消息是可变对象的共享引用,又允许发送方在发送后继续改它,就会出现「我发出一个对象、对方还没处理完、我这边先改了它」的连环灾难——这本质上是披着「消息传递」外衣的共享内存,前面两条律等于白立了。
④ 一切皆 Actor,靠地址(PID)通信。
它承诺:你能拿到的永远只是一个地址,不是对方的实体;这个地址背后的 Actor 部署在哪台机器、此刻是不是刚被监督者重启过,对你的代码完全透明。运行时怎么兑现这个承诺:PID[T] 就是这份「强类型地址」——只携带一个位置无关的 ID 和一个内部路由句柄,不携带对方的任何业务字段:
// PID[T] 是一个 Actor 的「强类型地址」:只能往它投递类型为 T 的消息。//// 你永远拿不到 Actor 对象本身,只能拿到它的 PID,然后 pid.Tell(msg)。// 这是「状态私有」的物理保证——没有对象引用,就无法直接读写别人的状态。//// 泛型参数 T 让「地址」自带类型信息:SessionPID = PID[SessionMsg] 无法接收 WorkerMsg,// 编译器直接拦截。ID 字段是位置无关的路径(如 /user/session-abc),// 分布式实现里同一个 ID 可指向另一台机器上的 Actor——调用方代码不变,即「位置透明」。type PID[T any] struct { id string proc *process[T]}你与 Actor 打交道的唯一入口都要经过 Context[T]——它把发消息、建子 Actor、读自己地址这些能力打包成一份权限清单,Receive 之外你连尝试的机会都没有:
// Context[T] 是 Actor 与外界交互的唯一入口。// 在 Receive 内部,Actor 通过 ctx 发消息、创建子 Actor、访问自身地址、调度定时任务。//// Context 只在「当前这次 Receive 调用」期间有效,不要跨消息保存。type Context[T any] struct { system *Actor self *PID[T] proc *process[T] msg T traceID string // 当前正在处理消息的 traceID,向下游发送时自动传播}少了这一条会怎样:如果代码里到处直接持有具体 Actor 的指针,「这个 Actor 是不是还活着」「它是不是已经被重启成了一个新实例」就会变成调用方必须操心的细节,分布式部署和监督重启都会因为「有人拿着旧引用不撒手」而寸步难行——位置透明也就无从谈起,把单机系统拆成分布式集群需要重写几乎所有调用点。
graph LR L1["① 状态私有<br/>只认消息,不认引用"] --> L2["② 串行处理<br/>run() 单线程逐条取件"] L2 --> L3["③ 消息不可变<br/>Tell 即发即忘"] L3 --> L4["④ 地址寻址<br/>PID 而非对象指针"] L4 -.->|环环相扣| L1
四条金科玉律首尾相扣:抽掉任意一条,其余三条都会失守
四条律并不是四条孤立的规矩,而是一条环环相扣的链条:私有状态让串行处理有意义(没有状态可保护,单线程也无所谓);串行处理让「消息处理时可以安全地读改状态」这件事成立;不可变异步消息让串行循环不必等待发送方,也不必担心消息在路上被改;地址寻址让这一整套机制可以脱离进程边界,无缝搬到别的机器上。抽掉任意一条,其余三条都会松动。
Let it crash:反直觉的错误哲学
传统编程崇尚防御:到处 try/catch,力求程序千万别挂。Actor 反其道而行——让它崩。一个 Actor 处理消息时出了错,别让它自己硬扛(这时它多半已经陷入脏状态),干脆任它崩掉,交给它的监督者按预设策略处置,通常是把它重启回干净的初始状态。
try/catch 把「异常之后怎么办」这个决定,摊派给每一处可能出错的调用点——写代码的人得在当下就想清楚该怎么收场,而多数时候图省事的答案是「记日志、吞掉、继续往下走」。问题是「继续往下走」建立在一个危险假设上:这个对象在异常之后还处于可用状态。现实往往不是这样——panic 发生的那一刻,字段可能已经改了一半,硬着头皮在这堆脏数据上继续跑业务逻辑,只会把错误运到更远、更难定位的地方。
Actor 运行时把兜底动作挪到了完全不同的位置:不是在业务代码里见招拆招,而是在处理循环外层统一 recover,把这次 panic 变成一次「决策请求」交给监督策略,而不是留给出错的那段代码自己决定:
// deliver 处理单条业务消息,用 recover 兜住业务 panic——这是 let-it-crash 的落地点:// 业务代码可以放心地「崩」,运行时捕获后交给监督策略决定 Restart/Stop/Resume/Escalate。// 返回 true 表示处理循环应退出。func (p *process[T]) deliver(e envelope[T]) (stop bool) { if e.traceID == "" { e.traceID = newTraceID() // 入口处若无 trace,则生成一个,保证全链路可追踪 } defer func() { if r := recover(); r != nil { stop = p.applyFailure(r) } }() p.rctx.msg = e.msg p.rctx.traceID = e.traceID p.actor.Receive(p.rctx, e.msg) p.handled.Add(1) p.system.metrics.Counter(metricProcessed).Inc() return false}监督策略拿到失败原因和历史失败记录后做出裁决——放过(Resume)、换新实例顶上(Restart)、不救了(Stop)、还是上报给上级(Escalate):
// applyFailure 按监督策略处置一次失败。返回 true 表示本 Actor 应终止。func (p *process[T]) applyFailure(reason any) (stop bool) { now := time.Now() p.failures = append(p.failures, now) if len(p.failures) > 128 { // 有界,避免长命 Actor 的失败历史无限增长 p.failures = p.failures[len(p.failures)-128:] } p.system.metrics.Counter(metricProcessErrors).Inc()
decision := p.props.supervisor.Decide(FailureContext{ Reason: reason, RestartCount: int(p.restarts.Load()), Failures: p.failures, Now: now, }) p.system.logger.Warn("actor panic", "actor", p.pid.id, "reason", fmt.Sprint(reason), "directive", decision.Directive.String(), "restart_count", p.restarts.Load(), )
switch decision.Directive { case DirectiveResume: return false // 保留状态,忽略错误 case DirectiveRestart: if !p.restartWithBackoff(reason, decision.Backoff) { return true // 退避期间系统关停,直接终止 } if decision.Scope == AllForOne && p.parent != nil { p.parent.restartChildrenExcept(p.pid.id, reason) // 兄弟一起重启 } return false case DirectiveStop: return true case DirectiveEscalate: if p.parent != nil { p.parent.escalate(reason) // 上报给父,由父的策略处置 } return true default: return false }}💡 小贴士
try/catch把「崩溃之后怎么办」摊派给每一处调用点,写代码的人得在当下拍板,几十个 catch 块里的决定往往互相不一致。Let it crash 把这个决定收拢到监督策略这一个地方——业务代码只管写 happy path,「崩溃之后怎么办」变成一份可以脱离具体调用点、统一审阅和调整的策略。
这样更好,理由有三层:线上错误大多瞬时、偶发——网络抖动、临时资源不可用,重启一下就好,不值得为它写一堆复杂的恢复逻辑;它把业务逻辑与错误恢复逻辑彻底分家,Actor 只管走 happy path,容错交给监督树;错误还被就地隔离——一个 Actor 崩了,殃及的只有它自己和它的子树,绝不牵连整个系统。重启用的是同一个工厂函数重新造一个全新实例,而不是复用可能还带着脏字段的旧对象——「干净的初始状态」不是一句口号,而是构造方式本身保证的。
对应的结构是监督树:Actor 组织成树,父管子。常见策略有四种:
| 策略 | 含义 |
|---|---|
| Restart | 重启出错的子 Actor,回到干净初始状态——let-it-crash 的默认动作 |
| Stop | 停掉它(及其子树) |
| Resume | 忽略这次错误,保留状态继续处理下一条消息 |
| Escalate | 自己搞不定,上报给上级监督者 |
小结
- Actor 世界观的原点:没有共享内存,只有私有状态 + 消息传递,并发正确性是结构上的必然,不是程序员的自觉。
- 心智模型是「一家公司」:员工(Actor)+ 工单(消息)+ 收件箱(Mailbox)+ 上下级监督树,位置透明藏在「寄件人不用知道对方在哪栋楼」这句话里。
- 四条金科玉律环环相扣:状态私有是地基,串行处理让它可信,消息不可变异步让串行循环不必等人,地址寻址让整套机制能跨机器搬家。
- Let it crash 把「崩溃之后怎么办」从业务代码里连根拔起,收拢成一份可审阅的监督策略;下一章看这个 1973 年诞生的模型如何一路活到今天的 AI 多智能体时代。