《MMO 服务器开发》2.1 entity runtime 与句柄

实体、句柄、运行时是三个层次。本节从零定下 EntityID 的高 16 位场景号 + 低 48 位序号位布局,用代际计数解决销毁后序号复用带来的 ABA 问题,再实现一张 O(1) 校验的运行时表,并用 16 个 go test 用例把三种过期引用逐一跑出来。

一个单服里同时活着几万个实体——玩家、怪物、NPC、掉落物、飞行道具。每个 tick 都有系统在问同一个问题:「这个 ID 现在还是刚才那个怪吗?」如果回答错了,一次延迟 300 毫秒到达的技能就会打到一个已经换人的目标上:伤害数字看着正常,玩家却投诉「我打的是空气」。

这一节把「实体怎么被引用」这件事从零做出来:一个位布局固定的 EntityID、一个带代际的 Handle、一张能在 O(1) 内完成校验的运行时表。难点不在数据结构,而在引用过期——实体销毁后序号会被复用,任何还握着旧引用的代码(延迟技能、在飞的消息、AI 的仇恨列表)都会误伤新实体。这个坑不解决,后面第 4 章的 AOI、第 6 章的结算都会在同一个地方翻车。先交代本节与站内既有文章的分工:

既有文章负责「架构方案与取舍」本书本节负责「从零实现 + 真跑」
/game-server-entity-runtime-ownership-architecture/实体运行时的所有权划分、单写者模型与整体分层落地可编译的 Runtime,把句柄校验与代际推进每一步跑出来
/game-server-player-mirror-data-architecture/玩家镜像数据的组织方式与跨服务同步策略给出 Entity 最小状态与代际字段的具体结构,作为镜像的载体

2.1.1 实体、句柄与运行时:三个层次

这三个词经常被混用,先钉死:

  • 实体(Entity):玩法对象本身,一堆状态的集合——位置、血量、buff 列表、背包引用。
  • 句柄(Handle):对实体的引用。可以复制、可以塞进队列、可以发给别的模块,体积必须小。
  • 运行时(Runtime):把句柄映射回实体、并负责校验「这个引用现在还有效吗」的容器。

为什么不能直接用指针当句柄?指针有三个致命问题:其一,GC 语言里把指针长期存进队列会让对象无法回收,一个忘掉的引用就能拖住整个实体;其二,跨场景、跨进程时指针无意义,序列化不了;其三,也是最重要的——对象被复用后,指针照旧指向同一块内存,过期引用会静默生效。所以句柄必须是一个可序列化的整数,而运行时负责在每次解引用时做校验。先定 ID 的位布局:高 16 位放场景号,让「这个实体属于哪个场景」不用查表,拿到 ID 直接右移就知道。下面这段是 L1,go test ./internal/entity/ 可以直接跑:

// file: internal/entity/id.go
// tier: L1
// verify: go test ./internal/entity/
package entity

// EntityID 的位布局:高 16 位是场景/实例号,低 48 位是实例内自增序号。
//
//	63            48 47                              0
//	+---------------+---------------------------------+
//	|   scene (16)  |            seq (48)             |
//	+---------------+---------------------------------+
//
// 高 16 位放场景号,是为了让「这个实体属于哪个场景」不用查表:
// 拿到 ID 直接右移 48 位就知道。低 48 位是场景内的自增序号,
// 2^48 ≈ 2.8e14,按每场景每秒生成 100 万个实体算也要 8 年才回绕。
type EntityID uint64

// PlayerID 是全服唯一角色 ID,与 EntityID 不是一回事:
// 玩家离线时角色没有实体,但 PlayerID 始终存在。
type PlayerID uint64

// EntityKind 区分实体的玩法类型,决定它挂哪些组件。
type EntityKind uint8

const (
	KindPlayer EntityKind = iota + 1
	KindMonster
	KindNPC
	KindDrop
	KindProjectile
)

const (
	// seqBits 是序号占用的位数。
	seqBits = 48
	// seqMask 是取低 48 位的掩码。
	seqMask = uint64(1)<<seqBits - 1
	// sceneBits 是场景号占用的位数。
	sceneBits = 16
)

// MakeEntityID 把场景号与场景内序号打包成一个 EntityID。
// 序号超过 48 位会被截断,调用方要保证不会发生。
func MakeEntityID(scene uint16, seq uint64) EntityID {
	return EntityID(uint64(scene)<<seqBits | seq&seqMask)
}

// Scene 返回高 16 位的场景号。
func (id EntityID) Scene() uint16 { return uint16(uint64(id) >> seqBits) }

// Seq 返回低 48 位的场景内序号。
func (id EntityID) Seq() uint64 { return uint64(id) & seqMask }

MakeEntityID / Scene / Seq 是整卷后面每一章都要用的地基:第 3 章的场景实例、第 4 章的 AOI 格、第 6 章的指令队列都按这个布局解析 ID。把场景号编进 ID 而不是单独存一个字段,换来的是「零次查表就能做路由」——一条消息进来,只看 ID 就知道该丢给哪个场景。

2.1.2 代际:识破过期句柄

光有 ID 还不够。看这个时序,它是 MMO 里最常见的线上事故之一:

t0   Spawn      →  序号 42 被玩家 A 占用
t1   技能释放    →  记下目标 = 42,300ms 后结算
t2   A 死亡      →  42 被回收,放回空闲栈
t3   刷怪        →  一只怪 Spawn,复用了序号 42
t4   技能结算    →  按 42 查表 → 查到的是怪,不是 A

这就是 ABA:值从 A 变成 B,看着「还是 42」,语义早已不同。修复办法是给每一格配一个代际计数 Gen,每复用一次 +1。句柄存 (ID, Gen),查找时两者都比——这就是「句柄」与「裸 ID」的全部区别。下面这段是 L1:

// file: internal/entity/handle.go
// tier: L1
// verify: go test ./internal/entity/
package entity

import "strconv"

// Handle 是对 EntityID 的「带代际」引用。
//
// 只用 EntityID 引用实体有一个经典 bug:实体 A 被销毁、它占用的
// 序号被新实体 B 复用,此时任何还握着旧 EntityID 的地方(一个延迟
// 触发的技能、一个还没处理完的网络包)都会误伤 B。这就是 ABA 问题。
//
// 解决办法是给每个序号配一个代际计数 Gen:每复用一次 +1。引用方
// 拿着 (ID, Gen),运行时查找时同时比对两者,代际不符即判为过期引用。
type Handle struct {
	ID  EntityID
	Gen uint32
}

// String 便于日志与测试输出,形如 "1:42/gen3"。
func (h Handle) String() string {
	return strconv.FormatUint(uint64(h.ID.Scene()), 10) + ":" +
		strconv.FormatUint(h.ID.Seq(), 10) + "/gen" +
		strconv.FormatUint(uint64(h.Gen), 10)
}

这里有个刻意的设计:Spawn 之后 Gen 从 0 变成 1,所以零值 Handle{} 永远不合法。任何忘了赋值的句柄(比如 var h Handle 之后忘了接返回值)都会在第一次查找时被拒,而不是碰巧指向序号 0 那个合法实体。让「未初始化」和「有效」在数值上不相交,是省掉一整类 bug 的便宜买卖。Gen 用 uint32 是有代价的:它会回绕。算一下——一个槽位每秒被复用 1000 次,2^32 ≈ 4.3e9,要 49 天才回绕一次。真回绕时最坏结果是「放过一个极老的过期引用」,在 49 天量级上可以接受。若业务上不能接受(比如实体存活周期极长、复用极频繁),把 Gen 提到 uint64 即可,代价是句柄从 12 字节变成 16 字节——放进队列时的内存账要重新算。

2.1.3 从零实现 Runtime 与句柄校验

运行时用一条 slots 数组按序号索引,配一个 free 空闲栈做复用。查找时依次比场景号、比存活、比代际——三道校验对应三种错误,调用方要分开处理。下面这段是 L1:

// file: internal/entity/runtime.go
// tier: L1
// verify: go test ./internal/entity/
package entity

import (
	"errors"
	"sync"
)

var (
	// ErrStaleHandle 表示句柄的代际与槽位当前代际不符(引用已过期)。
	ErrStaleHandle = errors.New("entity: stale handle")
	// ErrNotFound 表示句柄指向的槽位当前没有活实体。
	ErrNotFound = errors.New("entity: not found")
	// ErrBadScene 表示 EntityID 的场景号与运行时不一致。
	ErrBadScene = errors.New("entity: scene mismatch")
)

// Entity 是实体的最小状态。真实项目里这里会是属性、位置、组件表,
// 本节只保留能验证句柄语义所必需的字段。
type Entity struct {
	Handle Handle
	Kind   EntityKind
	Player PlayerID // 只有玩家实体非 0
	HP     int32
}

// slot 是运行时里的一格。所有实体按序号索引到 slots,复用同一格。
type slot struct {
	gen   uint32 // 代际,每次这格被重新分配就 +1
	alive bool   // 这一格当前是否有活实体
	kind  EntityKind
	ent   *Entity
}

// Runtime 是单场景内的实体表:把 EntityID 映射到实体,并用代际
// 识破过期句柄。所有方法都可并发调用。
type Runtime struct {
	scene uint16

	mu    sync.RWMutex
	slots []slot
	free  []uint64 // 空闲序号栈,复用时从尾部取
	next  uint64   // 从未分配过的下一个序号
	live  int

	alloc   func() *Entity // 载荷分配钩子,nil 表示直接 new;2.2 接对象池
	release func(*Entity)  // 载荷回收钩子,nil 表示交给 GC
}

// NewRuntime 创建一个属于 scene 的实体运行时。
func NewRuntime(scene uint16) *Runtime {
	return &Runtime{scene: scene}
}

// Spawn 分配一个新实体。序号优先复用 free 里的(LIFO),
// 没有可复用的就向后增长。
func (r *Runtime) Spawn(kind EntityKind, player PlayerID) Handle {
	r.mu.Lock()
	defer r.mu.Unlock()

	var seq uint64
	if n := len(r.free); n > 0 {
		seq = r.free[n-1]
		r.free = r.free[:n-1]
	} else {
		seq = r.next
		r.next++
		if seq >= uint64(len(r.slots)) {
			r.slots = append(r.slots, slot{})
		}
	}

	s := &r.slots[seq]
	if s.alive {
		panic("entity: slot reused while alive")
	}
	// 复用时代际 +1;首次分配时 gen 从 0 变 1,这样「零值句柄」
	// 永远不可能是合法的。
	s.gen++
	s.alive = true
	s.kind = kind

	h := Handle{ID: MakeEntityID(r.scene, seq), Gen: s.gen}
	ent := new(Entity)
	if r.alloc != nil {
		ent = r.alloc()
	}
	*ent = Entity{Handle: h, Kind: kind, Player: player}
	s.ent = ent
	r.live++
	return h
}

// Lookup 校验句柄并返回实体。代际不符返回 ErrStaleHandle,
// 槽位空闲返回 ErrNotFound。
func (r *Runtime) Lookup(h Handle) (*Entity, error) {
	if h.ID.Scene() != r.scene {
		return nil, ErrBadScene
	}
	seq := h.ID.Seq()
	r.mu.RLock()
	defer r.mu.RUnlock()
	if seq >= uint64(len(r.slots)) {
		return nil, ErrNotFound
	}
	s := &r.slots[seq]
	if !s.alive {
		return nil, ErrNotFound
	}
	if s.gen != h.Gen {
		return nil, ErrStaleHandle
	}
	return s.ent, nil
}

// Despawn 立即回收实体并把它占用的序号放回 free。代际在此刻不动,
// 等下一次 Spawn 复用这格时再 +1——这样「销毁后、复用前」的
// 过期句柄命中 ErrNotFound,复用后命中 ErrStaleHandle。
func (r *Runtime) Despawn(h Handle) error {
	if h.ID.Scene() != r.scene {
		return ErrBadScene
	}
	seq := h.ID.Seq()
	r.mu.Lock()
	defer r.mu.Unlock()
	if seq >= uint64(len(r.slots)) {
		return ErrNotFound
	}
	s := &r.slots[seq]
	if !s.alive {
		return ErrNotFound
	}
	if s.gen != h.Gen {
		return ErrStaleHandle
	}
	s.alive = false
	ent := s.ent
	s.ent = nil
	r.free = append(r.free, seq)
	r.live--
	if r.release != nil {
		r.release(ent)
	}
	return nil
}

// Len 返回当前活实体数量。
func (r *Runtime) Len() int {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.live
}

// Cap 返回已分配的槽位数(含空闲),等于历史最大并发实体数。
func (r *Runtime) Cap() int {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return len(r.slots)
}

三个错误各有语义,调用方绝不能一视同仁:

错误触发条件正确处理
ErrBadSceneID 的场景号与运行时不一致消息投错了场景,应路由而不是重试
ErrNotFound槽位空闲目标已销毁,丢弃
ErrStaleHandle代际不符序号被复用,目标已换人,丢弃

注意 Despawn 的一个细节:它在销毁时不动代际,把 +1 推迟到下一次 Spawn 复用这格时。这样「销毁后、复用前」的旧句柄命中 ErrNotFound,「复用后」命中 ErrStaleHandle——两种过期被区分开,排障时能直接看出是哪一类。如果把代际提前到 Despawn 就 +1,这两种情况会合并成 ErrStaleHandle,日志里就分不清「目标没了」和「目标换人了」。下面把测试跑出来(节选;全量用例覆盖位布局往返、代际推进、三种错误、空闲栈复用):

=== RUN   TestSpawnLookup
--- PASS: TestSpawnLookup (0.00s)
=== RUN   TestStaleHandleAfterReuse
--- PASS: TestStaleHandleAfterReuse (0.00s)
=== RUN   TestNotFoundAfterDespawn
--- PASS: TestNotFoundAfterDespawn (0.00s)
=== RUN   TestFreeListReuse
--- PASS: TestFreeListReuse (0.00s)
PASS
ok  	aegis/internal/entity	0.220s

其中 TestStaleHandleAfterReuse 是 ABA 的回归测试:它显式复用同一格,断言旧句柄报 ErrStaleHandle、新句柄仍然有效。任何把代际校验删掉的「优化」都会让它立刻变红——这就是把事故场景固化成测试的价值。TestFreeListReuse 则验证槽位是被复用的:连续 100 次 Spawn 再全部 Despawn 再 Spawn,Cap() 始终停在 100,不会无限增长。

本节小结

  1. 实体、句柄、运行时是三个层次,别混用。 实体是状态,句柄是可复制的引用,运行时负责校验。句柄必须是可序列化的整数,指针当不了句柄。
  2. 代际是防 ABA 的唯一手段。 序号会复用,只比 ID 一定误伤新实体;Gen 每次复用 +1,让「未初始化」「已销毁」「被复用」三种状态在数值上互不相交。校验要分三种错误:场景不符要路由,找不到要丢弃,代际不符要丢弃。

一句话收束:句柄的全部价值,在于它能在解引用时告诉你「你拿的这张票已经过期了」。 下一节接着往下走——既然销毁不是瞬时的(要结算、要广播),2.1 里这个「立即 Despawn」的模型就不够用了,我们需要一套显式的生命周期状态机和一个对象池。

阅读导航:上一节:目录 · 下一节:2.2 生命周期与对象池 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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