《MMO 服务器开发》2.2 生命周期与对象池

2.1 的立即 Despawn 不够用:创建要先登记再可见,销毁要先结算再回收。本节把实体生命周期拆成 None/Pending/Active/Dying/Dead 五态,用一张迁移表管住所有合法跳转,再把载荷接进对象池,最后用 go test 验证回收与复用真的发生。

2.1 里 Spawn 一返回,实体就活着;Despawn 一调用,它立刻没了。这个模型干净,但在真实单服里撑不过三分钟——因为创建和销毁都不是瞬时动作。

创建时,你得先把属性、背包、任务进度挂上去,才能让别的玩家看见它;如果挂钩到一半就对外可见,别人会查到一个半成品。销毁时更麻烦:一只 Boss 倒下,要先结算掉落、广播死亡、扣仇恨、发经验,才能回收它的序号和内存。这些活儿分散在好几个系统里,任何一个还没跑完,实体就不能真正消失。本节做两件事:把生命周期拆成显式的状态机,让每一次跳转都有据可查;再引入对象池,把回收后的载荷留住复用,而不是丢给 GC。

2.2.1 生命周期状态机:为什么销毁不是瞬时动作

先看「销毁」到底卡在哪。一只精英怪死亡的处理链是这样的:

收到致死伤害
  ├─ 结算掉落(要查掉落表、可能触发保底)
  ├─ 广播 AOI 内的玩家「它死了」
  ├─ 给击杀者发经验、推任务进度
  └─ 全部完成后,才允许回收实体

从「判定死亡」到「允许回收」之间有一段窗口期。窗口期内,实体不能参与战斗(它已经死了),但也不能被回收(结算还在读它的数据)。如果只有「活着 / 死了」两个状态,这段时间就没法表达——你只能选一个,而两个选择都是错的。所以需要两个过渡态:Pending(已分配序号,尚未对玩法可见)和 Dying(已判定死亡,等待结算完成)。加上 None(未创建)、Active(正常参与玩法)、Dead(已回收,等待复用),一共五态。

  None ──Spawn──► Pending ──Activate──► Active ──Kill──► Dying ──Reap──► Dead ──► None
                    │                       ▲                │
                    └──取消创建──► Dead      └───Revive───────┘

把这套状态机写成数据——一张「从哪个状态能到哪个状态」的表——而不是一串 if。下面这段是 L1,go test ./internal/entity/ 可以直接跑:

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

import "fmt"

// State 是实体生命周期里的状态。四个状态里 Pending 和 Dying 是两个
// 「过渡态」,它们的存在是为了让创建与销毁都不是瞬时动作——创建要
// 先把序号登记好再对玩法可见,销毁要先结算(掉落、经验、任务事件)
// 再回收序号。
type State uint8

const (
	StateNone    State = iota // 未创建(槽位空闲)
	StatePending              // 已分配序号,尚未对玩法可见
	StateActive               // 正常参与玩法
	StateDying                // 已判定死亡,等待结算完成
	StateDead                 // 已回收,等待槽位复用
)

var stateName = map[State]string{
	StateNone:    "none",
	StatePending: "pending",
	StateActive:  "active",
	StateDying:   "dying",
	StateDead:    "dead",
}

func (s State) String() string {
	if n, ok := stateName[s]; ok {
		return n
	}
	return "state(" + fmt.Sprint(uint8(s)) + ")"
}

// transitions 是允许的状态迁移表:from -> 允许到达的集合。
// 把合法迁移写成数据而不是一串 if,好处是能一眼看出有没有漏掉某条
// 边,也方便用一张表跑全量断言。
var transitions = map[State][]State{
	StateNone:    {StatePending},
	StatePending: {StateActive, StateDead}, // 创建中途被取消
	StateActive:  {StateDying},
	StateDying:   {StateDead, StateActive}, // 结算回滚(被复活/取消)
	StateDead:    {StateNone},
}

// CanTransition 判断 from -> to 是否是合法迁移。
func CanTransition(from, to State) bool {
	for _, ok := range transitions[from] {
		if ok == to {
			return true
		}
	}
	return false
}

// Transition 在 CanTransition 通过时返回 to,否则返回 from 与错误。
func Transition(from, to State) (State, error) {
	if !CanTransition(from, to) {
		return from, fmt.Errorf("entity: illegal transition %s -> %s", from, to)
	}
	return to, nil
}

这张表有两个刻意的设计。其一,Active → Dead 不合法——从活到回收必须经过 Dying,杜绝「跳过结算直接删」这条捷径。其二,Dying → Active 合法——被复活的怪、被取消的结算都要能回滚,不然一次误判死亡就永久卡在 Dying 里。把这两条写进表里,比写在文档里可靠得多:谁想加一条非法边,全量断言立刻变红。

2.2.2 把状态机接回 Runtime

状态机本身不碰实体,它只记账。真正干活的是把 2.1 的 Runtime 与状态表缝在一起的 Lifecycle:运行时管 ID 与代际,Lifecycle 管「现在能跳到哪」。下面这段是 L1:

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

import "sync"

// Lifecycle 把 2.1 的 Runtime 与四态机接起来:Runtime 负责 ID 与代际,
// Lifecycle 负责「实体当前处于哪个状态、下一步能不能走」。两者按序号
// (Seq)对齐,因此 states 的长度与 Runtime 已分配的槽位数一致。
type Lifecycle struct {
	rt *Runtime

	mu     sync.Mutex
	states []State
}

// NewLifecycle 绑定一个运行时与一个载荷池(pool 可为 nil)。非 nil 时
// 它把池接到运行时的分配/回收钩子上,于是 Spawn 从池取、Despawn 还池。
func NewLifecycle(rt *Runtime, pool *Pool) *Lifecycle {
	if pool != nil {
		rt.SetAllocator(pool.Get, pool.Put)
	}
	return &Lifecycle{rt: rt}
}

// stateOf 返回序号 seq 当前的状态;未登记过视为 StateNone。
func (l *Lifecycle) stateOf(seq uint64) State {
	l.mu.Lock()
	defer l.mu.Unlock()
	if seq >= uint64(len(l.states)) {
		return StateNone
	}
	return l.states[seq]
}

// move 按迁移表推进状态,非法迁移返回错误且不改状态。
func (l *Lifecycle) move(seq uint64, to State) error {
	l.mu.Lock()
	defer l.mu.Unlock()
	for uint64(len(l.states)) <= seq {
		l.states = append(l.states, StateNone)
	}
	next, err := Transition(l.states[seq], to)
	if err != nil {
		return err
	}
	l.states[seq] = next
	return nil
}

// Spawn 分配句柄并把状态推到 Pending;此时实体尚未对玩法可见。
func (l *Lifecycle) Spawn(kind EntityKind, player PlayerID) (Handle, error) {
	h := l.rt.Spawn(kind, player)
	if err := l.move(h.ID.Seq(), StatePending); err != nil {
		_ = l.rt.Despawn(h) // 回滚运行时分配
		return Handle{}, err
	}
	return h, nil
}

// Activate 把 Pending 推进到 Active,此后实体参与玩法。
func (l *Lifecycle) Activate(h Handle) error {
	if _, err := l.rt.Lookup(h); err != nil {
		return err
	}
	return l.move(h.ID.Seq(), StateActive)
}

// Kill 把 Active 推进到 Dying,进入结算阶段。
func (l *Lifecycle) Kill(h Handle) error {
	if _, err := l.rt.Lookup(h); err != nil {
		return err
	}
	return l.move(h.ID.Seq(), StateDying)
}

// Revive 是结算回滚:Dying -> Active(被复活或结算被取消)。
func (l *Lifecycle) Revive(h Handle) error {
	if _, err := l.rt.Lookup(h); err != nil {
		return err
	}
	return l.move(h.ID.Seq(), StateActive)
}

// Reap 完成回收:Dying -> Dead -> None,载荷由运行时的 release 钩子
// (即对象池的 Put)归还。
func (l *Lifecycle) Reap(h Handle) error {
	if _, err := l.rt.Lookup(h); err != nil {
		return err
	}
	seq := h.ID.Seq()
	if err := l.move(seq, StateDead); err != nil {
		return err
	}
	if err := l.rt.Despawn(h); err != nil {
		return err
	}
	return l.move(seq, StateNone)
}

// State 返回句柄当前的生命周期状态。
func (l *Lifecycle) State(h Handle) State { return l.stateOf(h.ID.Seq()) }

每个入口都先 Lookup 校验句柄再改状态,这一步不能省:一个已经过期的句柄(2.1 的 ErrStaleHandle)不该能推动新实体的状态。move 里那句 for len(l.states) <= seq 是懒扩展——状态切片按需长到和运行时槽位一样长,避免两处各自维护长度。

2.2.3 对象池:复用堆对象

2.1 的 free 栈复用「序号」,这里要复用「堆上的对象」。一个单服每秒可能 Spawn/Despawn 几万个临时实体(掉落物、飞行道具、AOE 判定框),每次都 new 一个再丢掉,GC 会被这些朝生暮死的对象拖垮。对象池把回收后的载荷留在池里,下次直接取回。注意边界:池只该缓存上限固定的对象,不然它自己会变成内存泄漏。所以这里用一个带 limit 的空闲栈,池满时多余的归还直接丢弃、交给 GC。下面这段是 L1:

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

import "sync"

// Pool 复用 *Entity 载荷,避免每次 Spawn 都新分配一个对象。
//
// 注意它与 2.1 里 Runtime 的 free 栈不是一回事:free 栈复用「序号」
// (ID 空间),Pool 复用「堆上的对象」(内存空间)。两者配合才能做到
// 「既不复用 ID 语义、又不产生垃圾」。
//
// 这里用一个带锁的空闲栈而不是 sync.Pool,原因是 sync.Pool 会在 GC
// 时清空、行为不确定;对「上限固定」的对象池,显式栈更可控。当对象
// 很大、GC 压力是真瓶颈时,再换成 sync.Pool 换吞吐。
type Pool struct {
	mu    sync.Mutex
	free  []*Entity
	limit int   // 池中最多保留多少个空闲对象;<=0 表示不限
	made  int64 // 真正 new 出来的次数
	got   int64 // Get 调用次数
}

// NewPool 创建容量上限为 limit 的载荷池。
func NewPool(limit int) *Pool { return &Pool{limit: limit} }

// SetAllocator 把运行时的载荷分配/回收钩子接到对象池上:此后 Spawn
// 从池取、Despawn 还池。传 nil 恢复成直接 new。它必须在首次 Spawn
// 之前调用。方法定义在这里而不是 runtime.go,是因为「接线」属于池
// 的职责,运行时本身不依赖池。
func (r *Runtime) SetAllocator(alloc func() *Entity, release func(*Entity)) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.alloc, r.release = alloc, release
}

// Get 取出一个 Entity。池非空则复用,否则新分配。
func (pl *Pool) Get() *Entity {
	pl.mu.Lock()
	defer pl.mu.Unlock()
	pl.got++
	if n := len(pl.free); n > 0 {
		e := pl.free[n-1]
		pl.free = pl.free[:n-1]
		return e
	}
	pl.made++
	return new(Entity)
}

// Put 归还一个 Entity。池满时多余对象直接交给 GC,不无限增长。
func (pl *Pool) Put(e *Entity) {
	if e == nil {
		return
	}
	pl.mu.Lock()
	defer pl.mu.Unlock()
	if pl.limit > 0 && len(pl.free) >= pl.limit {
		return
	}
	*e = Entity{} // 清空,避免旧引用被下一个使用者看到
	pl.free = append(pl.free, e)
}

// Idle 返回池中当前空闲对象数。
func (pl *Pool) Idle() int {
	pl.mu.Lock()
	defer pl.mu.Unlock()
	return len(pl.free)
}

// Made 返回累计新分配次数,用来验证复用是否真的生效。
func (pl *Pool) Made() int64 {
	pl.mu.Lock()
	defer pl.mu.Unlock()
	return pl.made
}

// Got 返回累计 Get 次数。
func (pl *Pool) Got() int64 {
	pl.mu.Lock()
	defer pl.mu.Unlock()
	return pl.got
}

Put 里那句 *e = Entity{} 是安全底线:归还的对象必须清空,否则下一个使用者会读到上一个实体的 Handle、Player、HP——这类「幽灵数据」引发的 bug 极难定位。清空的代价是每次归还写一遍结构体,相比一次 new 仍然便宜得多。把测试跑出来,重点看回收与复用是否真的发生:

=== RUN   TestTransitionsAllowed
--- PASS: TestTransitionsAllowed (0.00s)
=== RUN   TestTransitionsRejected
--- PASS: TestTransitionsRejected (0.00s)
=== RUN   TestLifecycleHappyPath
--- PASS: TestLifecycleHappyPath (0.00s)
=== RUN   TestReapReturnsToPool
--- PASS: TestReapReturnsToPool (0.00s)
PASS
ok  	aegis/internal/entity	0.220s

TestReapReturnsToPool 串起了本节三条线:Spawn 时 Made()==1(从池里新分配了一个),Reap 后 Idle()==1(载荷还回了池),再 Spawn 时 Made() 仍是 1(复用而非新分配),同时序号复用、代际前进。一个用例同时验证了生命周期、序号复用、对象复用三件事。

本节小结

  1. 创建和销毁都不是瞬时动作,所以需要过渡态。 Pending 让「登记完再可见」成为可能,Dying 让「判定死亡」和「回收序号」之间塞得下一整条结算链。
  2. 把合法迁移写成表,而不是 if。 一张 transitions 表能一眼看出缺边、能跑全量断言,还能顺手禁掉 Active → Dead 这种「跳过结算」的捷径。对象池则只该缓存上限固定的对象:池满就丢弃、归还就清空,两条纪律缺一不可,否则池会变成内存泄漏,或者把旧数据漏给下一个使用者。

一句话收束:生命周期管的是「什么时候能改」,对象池管的是「改完的内存还给谁」。 两者都服务于同一个目标——让实体在高速创建销毁下既正确又便宜。下一节把视角再抬高一层:既然实体分散在多个运行时里,那「谁有权写它」和「它从 A 运行时搬到 B 运行时」该怎么管,这就是 2.3 的运行时所有权与迁移。

阅读导航:上一节:2.1 entity runtime 与句柄 · 下一节:2.3 运行时所有权与迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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