1. 3.1 为什么需要 Actor

第二章把字节流收敛为完整消息;消息进入业务后,需要可预期的并发语义:哪些状态可以无锁持有、跨 Actor 调用如何避免互相拖死。本节从「直接 goroutine 处理」的局限出发,说明 zhenyi 选用 服务级 Actor + 邮箱串行 的动机,与 第一章 的模型表述一致,并与本仓库 zhenyi/zactor 实现动机对齐。

1.1. 3.1.1 最直觉的做法:goroutine 直接调用

收到消息后,最常见的做法就是直接调业务函数:

func HandleRead(ch IChannel, msg IMessage) {
    switch msg.MsgId() {
    case MsgLogin:
        doLogin(msg)     // 直接调用
    case MsgChat:
        doChat(msg)      // 直接调用
    }
}

写起来简单,跑起来也没问题。但当业务变复杂后,问题就来了。

1.2. 3.1.2 问题一:共享状态的并发安全

多个 goroutine 同时处理消息,如果它们访问了同一份数据,就需要加锁:

var userCount int
var mu sync.Mutex

func doLogin(msg IMessage) {
    mu.Lock()
    userCount++
    mu.Unlock()
}

func doLogout(msg IMessage) {
    mu.Lock()
    userCount--
    mu.Unlock()
}

两个函数都要改 userCount,必须加锁。业务再复杂一点,到处都是 mu.Lock() / mu.Unlock(),锁的粒度、死锁、锁竞争……问题接踵而来。

本质问题:goroutine 是并发执行的,共享数据就有竞争风险。加锁能解决,但代码会变得复杂且容易出错。

1.3. 3.1.3 问题二:服务间调用死锁

假设你有两个服务:Gate 和 IM。Gate 收到登录请求后调用 IM 的 Actor 登录:

// Gate 的 OnRead 里
func onLogin(msg IMessage) {
    // 同步调用 IM Actor
    result, err := imActor.Call(LoginReq{...})
    // 等 IM 处理完才继续
    reply(result)
}

如果 IM Actor 又要调用 Gate 的 Actor 呢?

Gate Actor  →  同步调用  →  IM Actor
                              ↓
                         同步调用  →  Gate Actor  (等 Gate 处理,但 Gate 在等 IM)
                              ↓
                            死锁

两个执行上下文互相等待对方先完成,形成有向环上的阻塞,即循环依赖型死锁(示意;具体是否发生还取决于是否占住对方的消息处理线程)。

本质问题:同步调用在跨服务场景下,容易出现循环依赖。

1.4. 3.1.4 Actor 模型的思路:消息传递代替共享内存

Actor 模型的核心原则是:

不要通过共享内存来通信,要通过通信来共享内存。(Go 并发语境中的常见表述,与 Actor「消息传递」一致。)

具体到 Actor 模型:

┌──────────────────────┐
│       Actor          │
│                      │
│  ┌───────┐           │
│  │ Mailbox│ → 消息排队 │
│  └───┬───┘           │
│      ↓               │
│  串行处理每条消息     │  ← 同一时间只有一个 goroutine 在处理
│  不需要加锁          │
└──────────────────────┘
  • 每个 Actor 是独立的处理单元
  • 通过消息通信,不直接共享内存
  • 消息串行处理,不需要加锁
  • 天然隔离:一个 Actor 崩溃不影响其他 Actor

1.5. 3.1.5 为什么能解决前面的问题?

1.5.1. 共享状态问题 → 消除了

type IMActor struct {
    *zactor.Actor
    userCount int  // 只有一个 goroutine 访问,不需要锁
}

func (a *IMActor) Init(ctx context.Context) error {
    a.SetIActor(a)
    return nil
}

(业务 Actor 需嵌入 *zactor.Actor、实现 ziface.IActor 并在 InitSetIActor,与框架一致;仓库中不存在 base.ActorBase 类型。)

// 消息处理示例(示意)
func (a *IMActor) OnLogin(msg IMessage) {
    a.userCount++ // 不用加锁,因为消息是串行处理的
}

Actor 的消息是串行处理的,同一时间只有一个 goroutine 在跑,不存在竞争。

1.5.2. 死锁问题 → 异步消息代替同步调用

Gate Actor  →  发消息  →  IM Actor    (Gate 发完就继续,不等响应)
                         ↓
                    发消息  →  Gate Actor  (IM 发完就继续,不等响应)

不阻塞等待,就没有循环依赖。需要响应的话,用回调Request-Response 模式,而不是同步阻塞。

1.6. 3.1.6 Actor 不是万能的

Actor 模型能解决很多并发问题,但也有代价:

代价 说明
性能开销 每条消息要经过邮箱序列化和调度,比直接函数调用慢
编程模型变化 从"调用函数"变成"发消息等回复",代码组织方式不同
调试困难 消息是异步的,出问题时不像同步调用那样容易追踪调用栈

因此 Actor 并非万能。更契合的是:中长期有私有状态、被多路并发投递、又希望避免到处加锁的业务分支。

1.7. 3.1.7 zhenyi 的选择:服务级 Actor

Actor 模型有两种粒度:

粒度 数量 示例
对象级 百万级 Erlang 的每个用户进程是一个 Actor
服务级 几十个 zhenyi 的每个业务模块是一个 Actor

zhenyi 选择的是服务级 Actor——整个 IM 模块是一个 Actor,整个 Match 模块是一个 Actor。

为什么不用对象级?三个原因:

  1. 复杂度:每个在线用户一个 Actor,管理起来成本太高
  2. 层级深:Actor 套 Actor,层级多了调试和排查都是问题
  3. 性能:消息经过多层转发有额外开销

服务级粒度在运维与心智负担单 Actor 内吞吐之间折中;若单 Actor 仍需并行,可配合 AsyncRunWithMsg / worker 池(见 3.23.3),而不是无限拆到对象级 Actor。

1.8. 3.1.8 本节要点

  1. 直连业务:多 goroutine 共享可变状态 → 锁与竞态成本;同步链路过长 → 易形成阻塞环。
  2. Actor 口径:邮箱排队 + 单消费者处理路径,单 Actor 内状态可在该路径上无锁演进(仍须避免在日 mailbox 线程上阻塞)。
  3. 跨 Actor:优先 Send / 异步回复;需要 RPC 语义时用 CallActor,并遵守 3.3 的异步约束。
  4. 代价:投递与调度开销、异步排查成本;适用边界见上。
  5. zhenyi服务级 Actor(进程内数量有限),与 1.3 中对象级 Actor 讨论对照阅读。

3.2 节给出邮箱、Run 主循环与 Group 监督的结构说明。

results matching ""

    No results matching ""