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.ActorRegisterHandle*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 上做其中一种可落地的组合。下一节从概念上拆分 状态、行为、邮箱 等组成部分,再逐步接到本书的实现上。

results matching ""

    No results matching ""