1. 3.6 消息对象池与引用计数
在 Actor 与网络层之间,承载业务的是 zmsg.Message:若每条路径都 new + 大切片,GC 与分配会成为热点。本节说明 zhenyi/zmsg 的池、RefCount、65 字节固定逻辑头与 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 本节要点
- 池:
zpool托管的Message池 +GetMessage/PoolReset/ refcount 初始化。 - Retain/Release:跨邮箱、转发路径须成对;
Release降至 0 时cap(Data)>4096则丢弃切片再归还。 - 重复释放:指标
zhenyi_msgpool_double_release_total(zmetrics.MsgPoolDoubleRelease);调试可开DEBUG_LIFECYCLE。 - 固定逻辑头:
FixedHeaderSize = 65,MarshalTo/Unmarshal手写,便于热路径控制分配。 - 标志位:bool 压缩为 flags 单字节。
- LittleEndian:与同机架构一致时减少转换;若存在异构混合链路需自行约定。
第三章(运行时:Actor、通信、热调、保护、消息对象)告一段落。第四章进入统一网关(结合 第二章 接入与 第三章 Actor 边界)。