目录 · 第 2 / 16 章
ActorPart I · 为什么与是什么

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 拿到的 ctxmsg 就是它了解外部世界的全部渠道——通过 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 多智能体时代。
源码

正在读取完整文件…