Mailbox 里没有锁:Actor 模型与单写者哲学
上一篇给无锁队列开完三条药方,结尾给路线三的归并写者认了一门亲戚:
上一篇的结尾
这个模式有大量严肃应用:Actor 模型里每个 actor 的 mailbox 恰好单消费者,actor 之间天然 MPSC;事件溯源靠单写者顺序追加事件、读侧全部摊开;Go 世界干脆把这套做法制度化成了"不要通过共享内存来通信"。
一句话挤了三个体系,密度太高。这篇把 Actor 模型讲透——它是三个体系里最完整的那个,把"消灭多写者"从一种优化手段上升成了世界观。看完它,另外两个自动看懂。
1. Actor 是什么:三条规则就够了
Actor 模型是 Carl Hewitt 在 1973 年提出的并发模型,比 Java 还早。Erlang 拿它在电信交换机上跑了二十多年,Akka 把它带进了 JVM 世界。模型本体小得惊人,全部规则就三条:
-
每个 actor 拥有 私有状态,外界摸不到,只有它自己能读写
-
每个 actor 有一个 mailbox,本质是个队列,别的 actor 只能往里投消息
-
actor 串行 消费 mailbox,一次处理一条,处理完才取下一条
Actor 之间没有共享内存,唯一的交互方式是发消息。
站在 mailbox 的视角:A、B、D 都是生产者,消费者只有 C 自己——多生产者单消费者,标准 MPSC。站在 C 私有状态的视角:全世界只有 C 会写它——单写者。上一篇追着修的两个问题(认领竞争、发布顺序),在这个模型里根本不会发生。
Tip
Actor 里没有锁,不是锁被解决了,是锁要保护的东西被设计掉了:没有共享状态就没有竞争,没有重入就没有死锁——至少在单个 actor 内部如此。
2. 30 行 Go:mailbox 就是 channel
具体到代码层面,上一篇的归并写者(inbox channel + 唯一转发 goroutine)已经是一个不会说话的 actor,只差消息分类。补上消息分派,就是完整骨架:
package main
import (
"fmt"
"sync"
)
// Spawn 启动一个 actor:内部 goroutine 是唯一消费者,返回值是它的 mailbox
func Spawn(handle func(msg any)) chan<- any {
mailbox := make(chan any, 64)
go func() {
for msg := range mailbox { // 串行:一次只处理一条
handle(msg)
}
}()
return mailbox
}
func main() {
count := 0 // 私有状态:只有 actor 的 goroutine 会碰它
addr := Spawn(func(msg any) {
switch m := msg.(type) {
case int:
count += m // 无锁累加,跑一千年也不会少一次
case chan int:
m <- count // 查询:附上回信地址,结果以消息的形式寄回
}
})
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
addr <- 1 // 任意 goroutine 都能投递:多生产者
}()
}
wg.Wait()
reply := make(chan int, 1)
addr <- reply
fmt.Println(<-reply) // 永远输出 1000
}
和归并写者的概念一一对应:
mailbox ←→ chan any,多生产者入口
串行消费 ←→ for msg := range mailbox
私有状态 ←→ 闭包变量,只有 actor 的 goroutine 触碰
回信 ←→ 消息里附一个 channel 当回信地址
1000 个 goroutine 无锁累加,结果恒为 1000,go run -race 干净得像没跑过并发代码——因为确实没有并发访问。mailbox 是 FIFO,回信请求排在所有累加之后被处理,连结果都是确定的。
Erlang 里这个骨架叫进程,Akka 里叫 actor class,外形不同,内脏相同:mailbox、串行循环、私有状态。剩下的差异都是工程包装——监督树、位置透明、集群分片。
3. 锁去哪了
同一个计数器,锁版本长这样:
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Add(delta int) {
c.mu.Lock()
c.n += delta
c.mu.Unlock()
}
锁版本的问题不在性能,在 纪律靠自觉:谁忘了拿锁,编译器一声不吭,等 race detector 上线才炸。actor 版把"同一时刻只有一个执行流碰这份数据"从纪律变成了结构——mailbox 一次只吐一条消息,互斥是排队的副产品。
Tip
锁保护的是临界区,靠程序员"记得加";actor 保护的是整个状态,靠结构"错不了"。前作的公式是 唯一写者 + release/acquire + 缓存行隔离,actor 直接把第一项做成了世界观,后两项自然失业。
还有个隐蔽的差异:锁版本的 Add 是同步调用,调用者阻塞在临界区外,锁内代码随时可能再调别人(重入)。actor 的投递是异步的,发完就走,要结果就附回信地址——"持锁时又调别人"这种问题在 actor 世界里无法表达,不是被解决,是没有语法。
4. 同一个思想的三种形态
上一篇那句话里的三个体系,其实是同一个思想的三次变奏: 与其同步多写者,不如消灭多写者。
| 形态 | 写路径收敛到哪 | 读 / 流路径怎么摊开 |
|---|---|---|
| Actor | 每份状态恰好一个主人(actor 自己) | N→1 的 mailbox,多 actor 并行 |
| 事件溯源 | 单写者顺序 append 的 log | log 落定即不可变,任意多读者重放 |
| Go channel | 所有权随消息流动 | 多 channel 分工,worker pool 消费 |
事件溯源:不存"当前状态",只存"发生过什么",状态由重放事件得出。写侧必须严格有序——两个写者交错 append,事件顺序就毁了——所以收敛成单写者;log 一旦落定不可变,读侧想开多少个投影(projection)都行,完全无锁。单写多读从指针级别放大到了架构级别。
Go channel:"Don't communicate by sharing memory; share memory by communicating." 把固定的共享变量换成 移动的所有权——任何时刻任何一份数据恰好只有一个持有者。Go 内存模型保证 channel 的 send happens-before 对应的 receive 完成,所以随消息传过去的值不需要任何额外同步。
单写者还有更远的亲戚:Raft 把所有写收敛到 leader,Kafka 靠分区内的 append-only log 保证顺序。单写者从来不是妥协,是秩序的来源。
5. 注意事项
- ⚠️ 死锁换了个形态回来:actor 之间没有数据竞争,但有 消息死锁——A 处理消息时同步等 B 的回复,B 又在等 A,两个 mailbox 从此沉默。纪律:处理函数里不许阻塞等待,要回复就超时 + 异步续接
- ⚠️ 背压:Erlang 的 mailbox 默认无界,消费者慢半拍,消息就无限堆积直到 OOM。生产实现(如 Akka)用有界 mailbox + 投递失败策略:丢弃、重试或卸载负载
- ⚠️ 顺序保证比想象的窄:Erlang 只保证"同一发送者到同一接收者"的顺序;两个 actor 各发一条,接收方可能任意顺序看到。跨发送者的全序,得用单调序号自己造
- ⚠️ 吞吐上限在消费侧:单 actor 串行,mailbox 的处理速度就是天花板。要横向扩就得分片——一个账户一个 actor、一台设备一个 actor,和 Kafka 的分区是同一招
- ⚠️ type switch 按动态类型精确匹配:
case chan<- int接不住投递进来的chan int——方向通道类型只是赋值兼容,不是同一类型。消息会被静默吞掉,回信永远不来,程序死在等信上。回信地址要按具体类型chan int去匹配 - ✅ 适合:一份状态有明确主人的场景——会话、账户余额、设备影子、游戏实体
- ✅ 不适合:CPU 密集的纯计算、读多写少的共享数据——后者 immutable + 多读,比 actor 还便宜
小结
无锁队列系列修的是"多写者出现之后怎么补救",招式一路加码:CAS、mutex、per-slot 状态机。Actor 模型是另一个极端:让多写者在结构上不可能出现,同步手段集体失业。
上一篇说,修 bug 的最高境界是让 bug 的前提不成立。Actor、事件溯源、CSP,全是这句话的注脚。
最后更新:2026-09-09