1. 3.6 消息对象池与引用计数

在 Actor 与网络层之间,承载业务的是 zmsg.Message:若每条路径都 new + 大切片,GC 与分配会成为热点。本节说明 zhenyi/zmsg 的池、RefCount65 字节固定逻辑头MarshalTo 写盘,与 第二章 的线协议头(znet)区分:前者是进程内/分布式总线消息头,后者是 socket framing,二者层次不同。

1.1. 3.6.1 问题:每条消息都分配新对象?

一个实时服务每秒可能处理几万条消息。如果每条消息都 new 一个新对象:

msg := &Message{Data: make([]byte, 0, 256)}
// 处理...
// 用完就扔,等 GC 回收

高频分配 + 频繁 GC → GC 暂停(STW)影响延迟

Go 的 sync.Pool 可以缓解这个问题,但它有个陷阱:GC 时会清空所有池中对象。这意味着每次 GC 之后,下一批请求都要重新分配,造成延迟毛刺。

1.2. 3.6.2 zhenyi 的方案:自研对象池

zhenyi 自己实现了一个不依赖 GC 清理的对象池,配合引用计数管理生命周期。

1.2.1. 对象池

// 真实实现见 zmsg/msgpool.go:单例 getMessagePool() 包装 zpoolobs.NewObservedPool

func GetMessage() *Message {
    msg := getMessagePool().Get()
    msg.PoolReset()
    atomic.StoreInt32(&msg.RefCount, 1)
    return msg
}

// Release():计数归零时若 cap(Data) > 4096 则置 nil,再 Put 回池

关键设计:

设计点 原因
GC 不清空 避免 GC 后重新分配的延迟毛刺
大 Data 不回收 防止池内存膨胀(> 4KB 的 Data 直接丢弃)
Get 时重置 防止上一条消息的数据残留

1.2.2. 引用计数

一条消息可能在多个地方被引用:

Actor A 发消息给 Actor B
    → msg 在 Actor A 的手里(引用计数 = 1)
    → 投递到 Actor B 的 Mailbox(Retain,计数 = 2)
    → Actor A 处理完(Release,计数 = 1)
    → Actor B 处理完(Release,计数 = 0 → 回收)
msg := GetMessage()    // refCount = 1

msg.Retain()            // refCount = 2(给 Mailbox 用)
actor.Push(msg)         // 投递

msg.Release()           // refCount = 1(A 释放自己的引用)

// Actor B 处理完:
msg.Release()           // refCount = 0 → 自动回收到池

Retain/Release 必须成对调用。 多 Retain 一次 → 内存泄漏。多 Release 一次 → 重复回收(zhenyi 会检测并报警)。

1.2.3. 重复释放检测

func (m *Message) Release() {
    newRef := atomic.AddInt32(&m.RefCount, -1)
    if newRef == 0 {
        // 正常回收
        messagePool.Put(m)
        return
    }
    if newRef < 0 {
        // 重复释放!记录告警
        zmetrics.MsgPoolDoubleRelease.Add(1)
        zlog.Error("Double release detected",
            zap.Int32("refCount", newRef),
            zap.Int32("msgId", m.MsgId))
        atomic.StoreInt32(&m.RefCount, 0)  // 修正,防止后续 Release panic
    }
}

线上环境会通过 Prometheus 指标 zhenyi_msgpool_double_release_total(代码中为 zmetrics.MsgPoolDoubleRelease)暴露重复释放次数。如果这个指标 > 0,说明有 bug,需要排查。

1.2.4. 调试模式

开发阶段可以开启 DEBUG_LIFECYCLE,追踪每条消息的生命周期:

// 开启后,每次 Retain/Release/GetMessage/Release 都会打印日志
// MSG#123 Retain (refCount: 1 -> 2)
// MSG#123 Release (refCount: 2 -> 1)
// MSG#123 Release (refCount: 1 -> 0)

线上环境关闭,避免日志爆炸。

1.3. 3.6.3 手写序列化:65 字节固定头

Message 的序列化没有用 protobuf,而是手写的二进制序列化。

const FixedHeaderSize = 65
// 1(flags) + 4(MsgId) + 8(Src) + 8(Tar) + 8(Session) +
// 8(Rpc) + 4(Seq) + 8(TraceHi) + 8(TraceLo) + 8(SpanId) = 65

为什么不用 protobuf?

方面 protobuf 手写序列化
开发效率 高(自动生成) 低(手写)
序列化速度 快(无反射,直接内存操作)
内存分配 需要(proto 内部 buffer) 零分配(MarshalTo 写入预分配 buffer)
体积 有 schema 开销 精确,无冗余

zhenyi 的消息格式是固定的,不需要 protobuf 的灵活性。手写序列化换来的是零分配 + 更快的速度

1.3.1. 位掩码压缩 bool

三个 bool 字段(ToClient、FromClient、IsResponse)没有各占一个字节,而是压缩到一个字节里:

const (
    flagToClient   = 1 << 0  // 0000 0001
    flagFromClient = 1 << 1  // 0000 0010
    flagIsResponse = 1 << 2  // 0000 0100
)

// 编码
var flags uint8
if m.ToClient   { flags |= flagToClient }
if m.FromClient { flags |= flagFromClient }
if m.IsResponse { flags |= flagIsResponse }
buf[0] = flags

// 解码
m.ToClient   = (flags & flagToClient) != 0
m.FromClient = (flags & flagFromClient) != 0
m.IsResponse = (flags & flagIsResponse) != 0

节省 2 个字节。单条消息看不多,但每秒几万条消息累积起来,就是每秒少传几十 KB 的数据。

1.3.2. LittleEndian 而非 BigEndian

网络协议通常用 BigEndian(大端序),但 zhenyi 的消息序列化用了 LittleEndian

原因是性能:x86/ARM 处理器都是 LittleEndian,用 LittleEndian 可以避免字节序转换。虽然在跨平台通信时需要注意,但 zhenyi 的服务端通常跑在相同架构上,这个选择是合理的。

1.4. 3.6.4 零分配序列化

MarshalTo 接受一个预分配的 buffer,直接写入,不分配新内存:

func (m *Message) MarshalTo(buf []byte) (int, error) {
    offset := 0
    buf[offset] = flags; offset++
    binary.LittleEndian.PutUint32(buf[offset:], uint32(m.MsgId)); offset += 4
    // ... 直接写入 buf,没有 make ...
    return offset, nil
}

配合对象池的 zpool.Buffer,可以做到整条链路零分配:

buf := zpool.GetBytesBuffer(size)  // 从池获取 buffer
n, _ := m.MarshalTo(buf.B)        // 写入,零分配
buf.B = buf.B[:n]
// 使用完毕
buf.Release()                       // 归还到池

1.5. 3.6.5 本节要点

  1. zpool 托管的 Message 池 + GetMessage/PoolReset/ refcount 初始化。
  2. Retain/Release:跨邮箱、转发路径须成对;Release 降至 0 时 cap(Data)>4096 则丢弃切片再归还
  3. 重复释放:指标 zhenyi_msgpool_double_release_totalzmetrics.MsgPoolDoubleRelease);调试可开 DEBUG_LIFECYCLE
  4. 固定逻辑头FixedHeaderSize = 65MarshalTo/Unmarshal 手写,便于热路径控制分配。
  5. 标志位:bool 压缩为 flags 单字节。
  6. LittleEndian:与同机架构一致时减少转换;若存在异构混合链路需自行约定。

第三章(运行时:Actor、通信、热调、保护、消息对象)告一段落。第四章进入统一网关(结合 第二章 接入与 第三章 Actor 边界)。

results matching ""

    No results matching ""