《MMO 服务器开发》3.2 实例创建与回收

地图模板是只读的,真正装玩家的是地图实例。本节从零写 internal/scene 的 InstanceManager:单调递增的 InstanceID、高 16 位实例号加低 48 位序号拼出的 EntityID、Active→Draining→Closed 状态机,以及把空置超时实例回收进对象池复用。L1 全绿,另附池化对照基准。

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 分线、副本与主城/野外 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标