1. 1.1 从并发困境到 Actor 模型
在 Go 里写并发服务,goroutine 往往是最先想到的抓手:为每个请求或每个连接起一段独立执行流,代码结构直观,也容易上手。
下面是一种典型写法:用锁保护共享数据,在多个 goroutine 里读写。
// 10000 个 goroutine 共享一个计数器
var counter int64
var mu sync.RWMutex
func handleRequest(userId int64) {
mu.Lock()
counter++
mu.Unlock()
user := users[userId] // 读共享 map
user.Balance -= 10 // 修改共享对象
users[userId] = user
}
逻辑上能工作,规模变大后,共享状态与锁的交界却会快速变脆:接口多一层、调用链长一点,就要反复判断该不该加锁、顺序对不对;问题未必在开发阶段暴露,却可能在压力下以死锁或数据错乱的形式出现。
下面三个问题在共享内存模型里很典型,这里只作引入,细节在后续开发与排查中还会反复遇到。
1.1. 1.1.1 共享内存并发的三个陷阱
1.1.1. 陷阱一:死锁
// 线程 A 持有锁 X,等锁 Y
muX.Lock()
muY.Lock()
// 线程 B 持有锁 Y,等锁 X
muY.Lock()
muX.Lock()
两个 goroutine 互相等待对方释放锁,执行无法推进。锁的个数和嵌套一多,组合空间变大,死锁风险上升;这类问题又常常依赖时序,不一定能在测试里稳定复现。
1.1.2. 陷阱二:竞态条件
// 看起来没问题
if user.Balance >= price {
user.Balance -= price // 但两个 goroutine 同时走到这里
}
两个 goroutine 同时读到 Balance = 100,都判断足够扣 50,结果各扣一次可能变成负数。加锁可以缓解,却要求你在每一处访问路径上都一致地考虑「这里是否与别处并发」——这是持续的心智负担。
1.1.3. 陷阱三:可维护性崩塌
func Handle() {
mu.Lock()
defer mu.Unlock()
// 100 行逻辑,中间调了 10 个函数
// 每个函数内部可能也需要加锁
// 哪些函数持有什么锁?调用顺序是什么?
// 改一个函数,会不会引入死锁?
}
时间一长,调用链与锁的对应关系难以记在脑子里;协作开发时,改动一处接口就可能牵动多处加锁策略, review 成本变高。
1.2. 1.1.2 Actor 模型的思路:不共享,只通信
Actor 模型里一条常被引用的表述是:
不要共享内存来通信,而要通信来协调对状态的访问。
共享内存方式:
goroutine A ←→ 共享数据 ←→ goroutine B
(需要锁来协调)
Actor 方式:
Actor A → 消息 → Actor B
(每个 Actor 独占自己的状态,对外只通过消息交互)
每个 Actor 持有私有状态;其他部分不能就地改写这份状态,只能通过发送消息,由收件方在自己的处理线上顺序执行。这样,围绕同一份状态的并发被收敛成单线程顺序处理,锁的需求自然下降。
1.2.1. 用 Actor 重写前面的例子(示意)
文献和不同语言里的 API 各异,这里不对应某一种「标准语法」,也不是本书后文 zhenyi 里可直接编译的代码,仅用骨架说明:与余额相关的变更只在一条顺序处理路径上发生。
// 教学用伪代码(类型名为示意)
type userActor struct {
balance int
}
func (a *userActor) handle(msg envelope) {
switch msg.kind {
case "deduct":
req := msg.body.(deductReq)
if a.balance >= req.amount {
a.balance -= req.amount
// 回复、日志等略
}
}
}
在工程里可以是「每用户一个这样的处理单元」,也可以是「服务级 Actor」里的一段 map + 邮箱驱动的串行逻辑;要点相同:同一用户的余额修改不会被两个 goroutine 同时切入。
本书后文采用的 zhenyi 会具体到 *zactor.Actor、RegisterHandle、*zmsg.Message 等类型,第三章与附录再对照即可。
1.3. 1.1.3 这个思路有什么代价?
Actor 不是万能方案。你把「锁与共享」换成了「消息投递与顺序约束」,复杂度仍在,只是转移了位置。
| 方面 | 共享内存 + 锁 | Actor 消息驱动 |
|---|---|---|
| 状态访问 | 直接读写,快 | 必须通过消息,有间接开销 |
| 并发控制 | 加锁 | 串行处理(单个 Actor 内) |
| 死锁风险 | 高 | 无(没有锁) |
| 竞态风险 | 高 | 无(单线程) |
| 调试难度 | 难(时序依赖) | 易(确定性执行) |
| 适合场景 | 低并发、数据密集 | 高并发、I/O 密集 |
Actor 模型适合连接多、请求频繁、单次处理相对轻量的场景,例如许多游戏服、IM、设备接入类系统:单次计算未必重,但并发与消息量大;单 Actor 内串行往往仍可接受,同时能减少锁竞争带来的抖动。
不太合适的场景:需要多个 Actor 同时读写同一份聚合数据(例如复杂跨表统计),消息路径与拷贝成本可能压过加锁方案。
1.4. 1.1.4 Go 语言的天然契合
Go 官方强调的 「不要通过共享内存通信,而要通过通信共享内存」 与 Actor 的取向一致:都倾向于用受控的通信约束对数据的访问,而不是让多路执行流直接在同一块内存上竞逐。
在第一节可以只记住两点轮廓,实现细节放到 1.2 节(邮箱)和 1.2.9(与 zhenyi 的对应):
- 一条 Actor 通常对应一条主要的处理循环(常见实现里是一个 goroutine 从邮箱取消息、依次处理),状态在这条线上更新。
- 与外界的协作以排队投递为主,思路上接近
channel用于同步的做法;是否直接用chan、邮箱底层如何实现,属于具体框架的选择,这里不展开包名。
处理循环里用 select 同时响应关闭、定时等信号,在常见实现里也很普遍,后文结合代码会看到。
标准库并不提供完整的 Actor 设施:监督与重启、邮箱批量调度、跨进程投递、运行期改参等,需要依托自研或第三方框架。zhenyi 即是在 Go 上做其中一种可落地的组合。下一节从概念上拆分 状态、行为、邮箱 等组成部分,再逐步接到本书的实现上。