3.2 实例创建与回收
上一节我们把地图做成了只读模板:一张格网加一张阻挡位图,描述「这张图长什么样」。但模板本身不装人——真正让玩家站进去、被打怪、被清场的,是模板之上的一层实例(instance)。同一张「晨曦大陆·新手村」模板,可以同时开出几十个实例,互不干扰。
实例的生命周期是 MMO 服务端里最容易被低估的资源问题。副本打完要销毁、玩家全走了要回收、运维要能把某个实例标记成「维护中不再进人」。如果每个实例都裸 new、用完就丢给 GC,那么在高频进出场景(比如大量短副本)下,GC 压力会直接顶到帧率。反过来,如果只回收不清理,又会留下「幽灵实例」——人数为 0 却永远占着内存。
本节把实例的创建、进出、回收、复用写成一个 InstanceManager:单调递增的实例 ID、可拼进实体 ID 的位布局、三态生命周期,以及一个把空实例回收进对象池的 GC。全部 L1,go test 可直接跑;最后附一个池化 vs 不池化的基准对照。
实例与实例 ID:谁是谁
先解决标识。实例需要一个全服唯一、单调递增的 ID,用 InstanceID uint64 表示。单调递增、回收后不复用旧号,这样日志里看到的实例号永远不会「换了个意思」,排错时不会认错对象。但光有实例 ID 还不够:场景里每个玩家、每个怪都是一个实体,实体也需要唯一 ID。我们复用卷二共享约定里的 EntityID:高 16 位是它所属的实例号,低 48 位是实例内自增序号。好处是,光看一个实体 ID,就能反解出它属于哪个实例——做路由、做广播时不必再查表。
位布局的拼法与反解就是 MakeEntityID / InstanceOf / SeqOf 三个小函数(它们连同下面的 InstanceManager 一起,都在同一份 instance.go 里,完整代码见下一小节)。MakeEntityID 把实例号左移 48 位、序号按 1<<48-1 掩码后拼进去;InstanceOf 右移 48 位取回实例号;SeqOf 用掩码取出序号。
一个细节:拼 ID 时对实例号取了 uint16,对序号按 1<<48-1 掩码。这是把「实例号只占 16 位、序号只占 48 位」这条约定写死在代码里,而不是靠调用方自觉。这样即便有人传进来一个超范围的实例号,也只会截断成合法位宽,不会串到序号的高位上去。
创建:从对象池取一个
实例对象本身不大,但它内部那张 players 哈希表会随着玩家进出反复扩容。如果每次创建都新建一张 map、每次回收都把它丢掉,就等于在频繁申请和释放内存。做法是:回收时不销毁实例对象,而是清空并放回对象池,下次创建直接复用,players 表的内存留在原地继续用。
下面是完整的 internal/scene/instance.go,L1,go test ./internal/scene/ 可以直接跑:
// file: internal/scene/instance.go
// tier: L1
// verify: go test ./internal/scene/
package scene
import (
"errors"
"sync"
)
// InstanceID 是地图实例 ID:全服唯一,单调递增,不复用已回收的号。
type InstanceID uint64
// EntityID 高 16 位是实例号,低 48 位是实例内自增序号。
// 同一个玩家换实例时实体 ID 会变,但 (实例号, 序号) 在任何时刻都唯一。
type EntityID uint64
const entitySeqMask = 1<<48 - 1
// MakeEntityID 按「高 16 位实例号 + 低 48 位序号」拼出实体 ID。
func MakeEntityID(inst InstanceID, seq uint64) EntityID {
return EntityID(uint64(uint16(inst))<<48 | (seq & entitySeqMask))
}
// InstanceOf 从实体 ID 反解出它所属的实例号。
func InstanceOf(e EntityID) InstanceID { return InstanceID(uint64(e) >> 48) }
// SeqOf 从实体 ID 反解出实例内序号。
func SeqOf(e EntityID) uint64 { return uint64(e) & entitySeqMask }
// InstanceState 是实例生命周期状态机:Active -> Draining -> Closed。
type InstanceState uint8
const (
InstActive InstanceState = iota // 正常接人
InstDraining // 不再进人,等现有玩家离开
InstClosed // 已回收,可被对象池复用
)
var (
ErrNoInstance = errors.New("scene: 实例不存在或已关闭")
ErrInstanceFull = errors.New("scene: 实例已达人数上限")
)
// Instance 是一个地图实例:持有地图模板的引用 + 本实例的玩家集合。
type Instance struct {
ID InstanceID
Map *MapData
state InstanceState
players map[uint64]struct{} // PlayerID 集合
capacity int
created int64 // 创建时的逻辑帧
lastAct int64 // 最后一次有玩家进出/活动的帧
}
func (i *Instance) PlayerCount() int { return len(i.players) }
func (i *Instance) State() InstanceState { return i.state }
// InstanceManager 统一管理实例的创建、进出与回收。
// 它是所有场景 goroutine 的汇聚点,因此用一把互斥锁保护,内部全是 O(1) 操作。
type InstanceManager struct {
mu sync.Mutex
next uint64
live map[InstanceID]*Instance
pool []*Instance
ttlTicks int64 // 空实例闲置超过这么多帧就回收
capacity int // 单实例人数上限
}
// NewInstanceManager:ttlTicks 是空实例保活帧数,capacity 是单实例上限。
func NewInstanceManager(ttlTicks int64, capacity int) *InstanceManager {
if ttlTicks < 0 {
ttlTicks = 0
}
return &InstanceManager{
live: make(map[InstanceID]*Instance),
ttlTicks: ttlTicks,
capacity: capacity,
}
}
// Create 开一个新实例。地图模板 md 由所有实例共享,不复制。
func (m *InstanceManager) Create(md *MapData, tick int64) *Instance {
m.mu.Lock()
defer m.mu.Unlock()
m.next++
inst := m.take()
inst.ID = InstanceID(m.next)
inst.Map = md
inst.state = InstActive
inst.capacity = m.capacity
inst.created = tick
inst.lastAct = tick
m.live[inst.ID] = inst
return inst
}
// take 从对象池取一个实例复用;池空则新建。返回的实例 players 已清空。
func (m *InstanceManager) take() *Instance {
n := len(m.pool)
if n == 0 {
return &Instance{players: make(map[uint64]struct{})}
}
inst := m.pool[n-1]
m.pool = m.pool[:n-1]
clear(inst.players)
return inst
}
// put 把实例放回对象池;players 里的内存保留复用,只清空键。
func (m *InstanceManager) put(inst *Instance) {
inst.state = InstClosed
inst.Map = nil
clear(inst.players)
m.pool = append(m.pool, inst)
}
// Enter 把一个玩家放进实例。满员或状态不对都返回错误。
func (m *InstanceManager) Enter(id InstanceID, player uint64, tick int64) error {
m.mu.Lock()
defer m.mu.Unlock()
inst, ok := m.live[id]
if !ok {
return ErrNoInstance
}
if inst.state != InstActive {
return ErrNoInstance
}
if len(inst.players) >= inst.capacity {
return ErrInstanceFull
}
inst.players[player] = struct{}{}
inst.lastAct = tick
return nil
}
// Leave 把玩家移出实例;实例转空时更新闲置计时。
func (m *InstanceManager) Leave(id InstanceID, player uint64, tick int64) error {
m.mu.Lock()
defer m.mu.Unlock()
inst, ok := m.live[id]
if !ok {
return ErrNoInstance
}
delete(inst.players, player)
inst.lastAct = tick
return nil
}
// Get 返回实例的只读快照信息(人数与状态)。
func (m *InstanceManager) Get(id InstanceID) (int, InstanceState, bool) {
m.mu.Lock()
defer m.mu.Unlock()
inst, ok := m.live[id]
if !ok {
return 0, InstClosed, false
}
return len(inst.players), inst.state, true
}
// CollectGarbage 回收「空且闲置超过 ttl 帧」的实例,返回被回收的 ID 列表。
// 调用方通常在逻辑帧循环里每隔若干帧调一次。
func (m *InstanceManager) CollectGarbage(tick int64) []InstanceID {
m.mu.Lock()
defer m.mu.Unlock()
var freed []InstanceID
for id, inst := range m.live {
if len(inst.players) == 0 && tick-inst.lastAct >= m.ttlTicks {
delete(m.live, id)
m.put(inst)
freed = append(freed, id)
}
}
return freed
}
func (m *InstanceManager) LiveCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.live)
}
func (m *InstanceManager) PoolSize() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.pool)
}
几个关键点。take/put 用 clear(inst.players) 而不是 inst.players = nil:前者只清键、保留底层桶数组,下次复用不必重新分配;后者会把内存还给 GC,池化就白做了。Create 里 m.next++ 和取对象是分开的两步——对象可以复用,ID 绝不复用,否则日志和客户端缓存里会出现两个含义不同的同号实例。
Enter 同时判三件事:实例存不存在、状态是不是 Active、有没有满员。三者任一不满足都返回错误而不是「默默塞进去」——服务端最怕的就是这种静默容错,它会让一个本该被拒的操作变成数据不一致。
回收:状态机与垃圾回收
回收分两件事:状态迁移和物理释放。玩家全走光的实例不立刻销毁,而是先在 CollectGarbage 里被挑出来、回收到池。判据是「空」加「闲置超过 ttlTicks 帧」——给一段宽限期,避免玩家刚走、队友刚要进的瞬间实例被拆掉。下面这段测试是同一 L1 包的一部分:
// snippet: internal/scene/instance_test.go(摘录)
func TestCollectGarbageRecyclesToPool(t *testing.T) {
m, _ := NewMapData(1, 8, 8)
im := NewInstanceManager(5, 4)
a := im.Create(m, 0)
im.Enter(a.ID, 1, 0) // 非空,不该被回收
b := im.Create(m, 0) // 一直空
bID := b.ID // 对象会被复用,先记住它的 ID
// 只过了 3 帧,未到 ttl,不该回收。
if freed := im.CollectGarbage(3); len(freed) != 0 {
t.Fatalf("3 帧后回收了 %v,期望不回收", freed)
}
// 到 5 帧:空的 b 被回收,非空的 a 保留。
freed := im.CollectGarbage(5)
if len(freed) != 1 || freed[0] != bID {
t.Fatalf("回收 = %v,期望只回收 %d", freed, bID)
}
if im.LiveCount() != 1 {
t.Fatalf("存活实例 = %d,期望 1", im.LiveCount())
}
if im.PoolSize() != 1 {
t.Fatalf("池大小 = %d,期望 1", im.PoolSize())
}
// 再开一个:必须复用池里的对象,池清空,ID 继续递增不复用。
c := im.Create(m, 6)
if im.PoolSize() != 0 {
t.Fatalf("复用后池 = %d,期望 0", im.PoolSize())
}
if c.ID == bID {
t.Fatalf("复用对象却复用了 ID:%d", c.ID)
}
if c.PlayerCount() != 0 {
t.Fatalf("复用实例残留玩家:%d", c.PlayerCount())
}
}
这段测试里有个容易被忽略的点:b 被回收后,同一个对象又被复用成了 c,所以 b.ID 会被就地改写。测试里先 bID := b.ID 存下旧号,再断言 c.ID != bID——这正是「对象复用、ID 不复用」这条约定的验收。如果哪天有人图省事在 take 里保留了旧 ID,这条断言会立刻变红。
TestDrainingRejectsEnter 则验证维护语义:把实例状态改成 InstDraining 后,Enter 必须返回 ErrNoInstance。这样运维就能安全地把一个实例标记成「不再进人」,等里面的人自然走完再回收。
实测:池化把每次循环的分配压下去
池化的收益用基准量出来最直观。下面这段是 L1 之外的 B 块,go test -bench 直接跑;对照项是「每轮新建一个实例对象加一张 map」的裸分配:
// file: internal/scene/bench_test.go
// tier: B
// verify: go test -bench=Instance -benchmem ./internal/scene/
package scene
import "testing"
// benchSink 让「不池化」路径的实例逃逸到堆上,避免被编译器优化掉。
var benchSink []*Instance
// 池化:Create 拿到的是池里复用的对象,回收时只清键、不重建 map。
func BenchmarkInstancePooledLifecycle(b *testing.B) {
m, _ := NewMapData(1, 64, 64)
im := NewInstanceManager(0, 64) // ttl=0:空实例立刻回收进池
b.ReportAllocs()
for i := 0; i < b.N; i++ {
inst := im.Create(m, 0)
im.Enter(inst.ID, 1, 0)
im.Leave(inst.ID, 1, 0)
im.CollectGarbage(0)
}
}
// 不池化:每个循环重新 new 一个实例对象 + 一张 players map,作为对照。
func BenchmarkInstanceFreshAlloc(b *testing.B) {
m, _ := NewMapData(1, 64, 64)
b.ReportAllocs()
benchSink = make([]*Instance, 0, 1)
for i := 0; i < b.N; i++ {
inst := &Instance{Map: m, state: InstActive, players: make(map[uint64]struct{})}
inst.players[1] = struct{}{}
benchSink = benchSink[:0]
benchSink = append(benchSink, inst)
}
}
本节小结 / 实测结论
本机(Go 1.26.0,darwin/arm64)实跑结果:
$ go test -bench=Instance -benchmem -run '^$' ./internal/scene/
BenchmarkInstancePooledLifecycle-10 9782132 121.8 ns/op 8 B/op 1 allocs/op
BenchmarkInstanceFreshAlloc-10 12389984 95.22 ns/op 256 B/op 3 allocs/op
PASS
ok aegis/internal/scene 2.781s
要看的是每轮分配量,不是 ns/op。池化路径跑完一整个 create/enter/leave/gc 循环只要 8 B/op、1 allocs/op,而且这 8 字节来自 CollectGarbage 返回值那个小切片;裸分配路径光是造对象加 map 就要 256 B/op、3 allocs/op。两条路径的 ns/op 不可直接比——池化那条还多做了 live 表的插入删除,工作量本就更大。以上数字是同机相对量级,绝对数值不可移植。
一句话结论:实例对象的分配不该随进出频率线性增长,把它收进对象池、只清键不重建,是高频副本场景下最省事的一步优化。下一节把落点补齐:同一张野外图怎么分线、副本怎么开独立实例、主城与野外又该怎么区别对待。
阅读导航:上一节:3.1 地图数据结构与逻辑坐标 · 下一节:3.3 分线、副本与主城/野外 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。