1. 4.4 回复快速路径

4.3 讨论上行路由;本节讨论 zactor.Actor.PushCmdTypeMsgIsResponse 的分支:进程内 RPC 回包SetReply下行客户端在 Gate 上可走 IToClientFastPath。请以 zhenyi/zactor/base.gozhenyi/zgate/gate.go 为准。

1.1. 4.4.1 常规路径:响应走 Mailbox

默认情况下,业务 Actor 的响应消息要通过 Gate 的 Mailbox 排队:

业务 Actor 处理完消息
    ↓
构造响应(ToClient = true, IsResponse = true)
    ↓
投递到 Gate 的 Mailbox(Push)
    ↓
Gate Run 主循环从 Mailbox 取出
    ↓
sendClient → Channel.Send → 发回客户端

这条路径能工作,但有一个问题:多了一次 Mailbox 排队。

Gate 的 Mailbox 里可能还有其他消息在排队,响应消息要等前面的消息处理完才能被取出。对于高频回包场景(比如聊天消息广播),这个延迟是不可接受的。

1.2. 4.4.2 快速路径:跳过 Mailbox

zhenyi 提供了一种优化:响应消息跳过 Gate 的 Mailbox,直接发送

常规路径:  Actor → Gate Mailbox → 排队 → Gate 主循环 → sendClient
快速路径:  Actor → SetReply → Gate sendClient(直达)

延迟对比:

路径 延迟来源
常规 处理时间 + Mailbox 排队时间 + 发送时间
快速 处理时间 + 发送时间

在高负载下,Mailbox 排队时间可能从微秒级到毫秒级。对于实时应用,每一毫秒都有意义。

1.3. 4.4.3 实现原理

快速路径涉及两个关键点:SetReply 机制IToClientFastPath 接口

1.3.1. SetReply:RPC 响应的低延迟投递

3.3 节讲了 CallActor 的 Slot 机制。业务 Actor 通过 CallActor 发出 RPC 请求后,目标 Actor 的响应通过 SetReply 直接投递到 Slot 的 channel,不经过 Mailbox:

// 示意:idx / reqVer 由 RpcId 与 VersionShift 拆分,见 zactor/sender.go
func (m *ActorMsgSender) SetReply(data *zmsg.Message) {
    idx := data.RpcId & m.indexMask
    reqVer := data.RpcId >> VersionShift
    slot := &m.slots[idx]
    if atomic.LoadUint64(&slot.version) != reqVer {
        return
    }
    if atomic.LoadInt32(&slot.state) != SlotWaiting {
        return
    }
    slot.ch <- data.Retain()
}

这比走 Mailbox 快,因为 Mailbox 需要经过队列入队 → 取出 → 分发的流程,而 SetReply 直接写 channel。

1.3.2. IToClientFastPath:ToClient 响应直达发送

对于发送给客户端的响应(ToClient = true),还有另一层优化。

Gate Actor 可以声明 IToClientFastPath 接口:

// Gate 实现 IToClientFastPath
func (s *Server) HandleToClientFastPath(msg *zmsg.Message) bool {
    if msg == nil || !msg.ToClient {
        return false
    }
    s.sendClient(msg)  // 直接发送,不走 Mailbox
    return true
}

PushCmdTypeMsg + IsResponse 的实际顺序(摘自实现逻辑,非逐字拷贝):

func (a *Actor) Push(msg zmodel.ActorCmd) {
    if msg.Type != zmodel.CmdTypeMsg {
        a.mailBoxQueue.Enqueue(msg)
        return
    }
    m := msg.Msg
    if m == nil || !m.IsResponse {
        a.mailBoxQueue.Enqueue(msg)
        return
    }
    // 进程内 RPC:不进 mailbox,直接唤醒 Slot
    if !m.ToClient {
        a.SetReply(m)
        m.Release()
        return
    }
    // 下行客户端:仅当 Actor 声明了安全快路径(Gate 典型)
    if a.toClientFastPath != nil && a.toClientFastPath.HandleToClientFastPath(m) {
        m.Release()
        return
    }
    a.mailBoxQueue.Enqueue(msg)
}

1.3.3. 两条快速路径的对比

路径 适用场景 跳过什么
SetReply IsResponse && !ToClient 跳过接收方 Actor 邮箱,直达 RPC Slot(不一定是 Gate)
IToClientFastPath IsResponse && ToClient 且 Handler 返回 true 跳过 Gate 邮箱,sendClient

1.4. 4.4.4 为什么 Gate 可以这样做?

Gate 的 HandleToClientFastPath 做的事情很简单:

func (s *Server) HandleToClientFastPath(msg *zmsg.Message) bool {
    s.sendClient(msg)  // 查 channel + channel.Send
}

两个操作:

  1. 查 channelGetChannel(msg.SessionId),底层是 sync.Map 的 Load
  2. 发送消息channel.Send(msg),底层是把消息投递到连接的发送队列

这两个操作都是线程安全的:

  • sync.Map.Load 本身就是并发安全的
  • channel.Send 是往 MPSC 队列入队,也是并发安全的

所以 Fast Path 不会破坏 Gate Actor 的单线程不变性——它没有读写 Gate Actor 的任何业务状态,只是做了一次查 map 和一次入队。

1.5. 4.4.5 非 Gate 的 Actor 不要用 Fast Path

这个约束非常重要,值得强调。

为什么? 因为 Fast Path 在 Actor 的 Mailbox 之外执行。如果 Fast Path 里修改了 Actor 的业务状态(比如计数器、缓存、业务数据),就会和 Actor 主循环并发访问,产生 data race。

Gate 之所以安全,是因为 sendClient 只做查 map 和入队,不碰 Gate 的业务状态。

如果某个业务 Actor 也想用 Fast Path,必须保证 Fast Path 的逻辑是线程安全的。否则老老实实走 Mailbox。

1.6. 4.4.6 实际效果

快速路径的收益取决于 Gate 的负载:

场景 Mailbox 排队延迟 Fast Path 收益
低负载(QPS < 1000) < 10μs 几乎无感知
中负载(QPS 1万~10万) 10μs ~ 1ms 可感知
高负载(QPS > 10万) > 1ms 明显

对于聊天、位置同步等高频回包场景,Fast Path 能有效降低 P99 延迟。

1.7. 4.4.7 本节要点

  1. 默认:非响应或快路径未命中 → mailBoxQueue.Enqueue
  2. RPCIsResponse && !ToClientSetReply + Release(注释明确禁止在该路径触碰其它 Actor 状态)。
  3. ToClient:先尝试 toClientFastPath(Gate 实现为 sendClient),否则排队。
  4. 安全边界:快路径只允许 线程安全的查表 + 入连接发送队列;业务状态仍须留在邮箱线程。
  5. 收益:与 Gate 队列深度同量级;高并发下 P99 更敏感。

4.5 节TopicBus/NATS 与远程粘性。

results matching ""

    No results matching ""