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(复用而非新分配),同时序号复用、代际前进。一个用例同时验证了生命周期、序号复用、对象复用三件事。
本节小结
- 创建和销毁都不是瞬时动作,所以需要过渡态。
Pending让「登记完再可见」成为可能,Dying让「判定死亡」和「回收序号」之间塞得下一整条结算链。 - 把合法迁移写成表,而不是 if。 一张
transitions表能一眼看出缺边、能跑全量断言,还能顺手禁掉Active → Dead这种「跳过结算」的捷径。对象池则只该缓存上限固定的对象:池满就丢弃、归还就清空,两条纪律缺一不可,否则池会变成内存泄漏,或者把旧数据漏给下一个使用者。
一句话收束:生命周期管的是「什么时候能改」,对象池管的是「改完的内存还给谁」。 两者都服务于同一个目标——让实体在高速创建销毁下既正确又便宜。下一节把视角再抬高一层:既然实体分散在多个运行时里,那「谁有权写它」和「它从 A 运行时搬到 B 运行时」该怎么管,这就是 2.3 的运行时所有权与迁移。
阅读导航:上一节:2.1 entity runtime 与句柄 · 下一节:2.3 运行时所有权与迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。