17.3 实例分配与回收
大厅把房间管住了,但房间只是一个逻辑概念——真正承载一局副本、一个战场的,是地图实例:一块加载好的地图、一份独立的世界状态、一段可以被单独回收的内存。匹配成局之后,服务端必须从某个地方「拿出」一个实例,把玩家放进去;对局结束,再把它还回去。
这件事有两个容易踩的坑。第一个是分配策略:如果无脑挑第一个空闲实例,一个 20 人的小副本可能吃掉一个能装 200 人的世界实例,等到真需要世界实例时反而没得用了。第二个是回收时机:对局一结束就立刻把实例标成空闲是错的——上一局的 AI 状态、掉落缓存、临时对象还没清干净,下一个玩家进来可能撞见上一局的残留。
本节实现单服内的实例池 internal/match/pool.go:预热一批容量不同的实例,按 best-fit 分配,用完置为 draining 并冷却一段时间再复用。注意范围:卷二只做单服内的实例池——「从这台服的池子里拿一个」;真正的跨服调度(哪个大区、哪台物理机、跨服战场怎么撮合)涉及多服的资源视图与一致性,留到卷三。全部代码是 L1,go test ./internal/match/ 可以直接跑。
实例池:预热与 best-fit 分配
池子在启动时预热一批实例,每个实例有一个容量上限(能装多少人)。分配时的策略是 best-fit:在所有「空闲且容量 ≥ 需求」的实例里,挑容量最小的那个。这样大容量实例会被留给真正需要它的场景,小需求不会造成浪费。
下面这段是 L1,go test ./internal/match/ 可以直接跑:
// file: internal/match/pool.go
// tier: L1
// verify: go test ./internal/match/
package match
import (
"errors"
"sync"
)
// InstanceID 是地图实例 ID(卷二共享类型,uint64)。
type InstanceID uint64
var (
// ErrNoInstance 表示池内没有可用的空闲实例。
ErrNoInstance = errors.New("match: 实例池已耗尽,无可用实例")
// ErrUnknownInstance 表示分配/回收了一个池内不存在的实例。
ErrUnknownInstance = errors.New("match: 未知实例 ID")
)
// Instance 是一个单服内的地图实例。卷二只做单服实例池,
// 真正的跨服调度(哪个大区、哪台物理机)留到卷三。
type Instance struct {
ID InstanceID
Capacity int
State string // idle | allocated | draining
CooldownUntil int64 // draining 期间不可复用的截止时刻(ms)
Uses int // 累计被分配次数,用于灰度与磨损观测
}
// Pool 是单服内的实例池,带预热、best-fit 分配与冷却回收。
type Pool struct {
mu sync.Mutex
instances []*Instance
nextID InstanceID
cooldown int64
allocated int
recycled int
}
// NewPool 预热 n 个容量为 capacity 的实例,回收冷却 cooldown 毫秒。
func NewPool(n, capacity int, cooldown int64) *Pool {
p := &Pool{cooldown: cooldown}
for i := 0; i < n; i++ {
p.nextID++
p.instances = append(p.instances, &Instance{ID: p.nextID, Capacity: capacity, State: "idle"})
}
return p
}
// Allocate 按 best-fit 挑一个容量 ≥ need 的空闲实例:优先刚好够用的,
// 免得一个 50 人副本吃掉能装 200 人的世界实例。
func (p *Pool) Allocate(need int, now int64) (*Instance, error) {
p.mu.Lock()
defer p.mu.Unlock()
var best *Instance
for _, in := range p.instances {
if in.State != "idle" || now < in.CooldownUntil || in.Capacity < need {
continue
}
if best == nil || in.Capacity < best.Capacity {
best = in
}
}
if best == nil {
return nil, ErrNoInstance
}
best.State = "allocated"
best.Uses++
p.allocated++
return best, nil
}
// Release 把实例置为 draining 并进入冷却;冷却结束前 Allocate 不会碰它。
// 冷却的意义是:上一局的残留状态(AI、掉落、缓存)需要时间被清干净。
func (p *Pool) Release(id InstanceID, now int64) error {
p.mu.Lock()
defer p.mu.Unlock()
in := p.find(id)
if in == nil {
return ErrUnknownInstance
}
in.State = "draining"
in.CooldownUntil = now + p.cooldown
p.recycled++
return nil
}
// Tick 把冷却到期的 draining 实例重新标为 idle,使其回到可分配集合。
func (p *Pool) Tick(now int64) int {
p.mu.Lock()
defer p.mu.Unlock()
n := 0
for _, in := range p.instances {
if in.State == "draining" && now >= in.CooldownUntil {
in.State = "idle"
in.CooldownUntil = 0
n++
}
}
return n
}
func (p *Pool) find(id InstanceID) *Instance {
for _, in := range p.instances {
if in.ID == id {
return in
}
}
return nil
}
// Stats 返回 (总实例数, 空闲数, 累计分配数, 累计回收数)。
func (p *Pool) Stats() (total, idle, allocated, recycled int) {
p.mu.Lock()
defer p.mu.Unlock()
for _, in := range p.instances {
if in.State == "idle" {
idle++
}
}
return len(p.instances), idle, p.allocated, p.recycled
}
Allocate 里有三个条件缺一不可:State == "idle"(不能是已分配或冷却中)、now >= CooldownUntil(冷却已过)、Capacity >= need(装得下)。任一条不满足就跳过,全部跳完还没有候选,就返回 ErrNoInstance 交给上层——上层可以据此把请求排队、或者临时新建实例,这个决策不该塞进池子里。
池子内部用一把 sync.Mutex 保护,因为实例池会被多个匹配协程并发访问。与大厅刻意「单线程 tick、不加锁」不同,这里是有状态的共享资源,必须自己兜住并发。
回收与冷却:为什么不能立刻复用
对局结束时调 Release,实例不是立刻变空闲,而是先进入 draining 状态并带上一个冷却截止时刻。冷却期内 Allocate 会跳过它;只有 Tick 发现冷却到期,才把它转回 idle。
为什么非要冷却?因为「对局结束」和「实例干净」不是同一时刻。战斗系统刚把最后一个玩家送走时,AI 还在跑收尾逻辑、掉落物对象还挂在场上、临时分配的缓存还没释放。如果这时立刻把实例给下一局,新玩家进来可能看到上一局的尸体、捡到没清掉的掉落。冷却期就是给清理留出的时间窗,代价是这段时间该实例不可用——所以冷却时长要按「清理最长耗时」来定,而不是拍脑袋给个大数。
另一个细节是 Uses:复用实例时状态复位(State 回到 idle、CooldownUntil 清零),但累计使用次数不清零。它是观测实例「磨损」的指标——如果一个实例的 Uses 远高于同批实例,说明 best-fit 总是选中它,可能需要调整容量分布。
还有一点必须写进注释:Release 传入的 now 与 Tick 传入的 now 必须来自同一个时钟源。如果 Release 用墙钟、Tick 用单调钟,冷却判定就会在时钟回拨时错乱——要么提前复用(脏数据),要么永远不复活(实例泄漏)。池子本身不读时钟,全部时间点由调用方注入,就是为了把这件事变成显式约定,而不是散落在各处的隐式假设。
用测试验证分配、耗尽与复用
实例池的每条分支都得有测试兜底:best-fit 选对了没、池耗尽报错没、冷却期能不能挡住复用、复用后状态复位没。下面是 internal/match/pool_test.go 里三段的摘录,属于同一个已跑通的 L1 包:
// snippet: internal/match/pool_test.go(摘录)
// best-fit:需求 20 人时,200 人的世界实例不该被吃掉,应优先给 20 人实例。
func TestAllocateBestFit(t *testing.T) {
p := NewPool(0, 0, 0)
p.instances = []*Instance{
{ID: 1, Capacity: 200, State: "idle"},
{ID: 2, Capacity: 50, State: "idle"},
{ID: 3, Capacity: 20, State: "idle"},
}
in, err := p.Allocate(20, 0)
if err != nil || in.ID != 3 {
t.Fatalf("best-fit 选中 %v (err=%v),期望 ID=3", in, err)
}
}
// 冷却:释放后到冷却结束前不可复用,Tick 到期后才回到空闲集合。
func TestRecycleCooldown(t *testing.T) {
const cd = 5000
p := NewPool(1, 10, cd)
in, _ := p.Allocate(1, 0)
p.Release(in.ID, 0)
if in.State != "draining" {
t.Fatalf("释放后状态 %q,期望 draining", in.State)
}
if _, err := p.Allocate(1, cd-1); !errors.Is(err, ErrNoInstance) {
t.Fatalf("冷却期内不该可分配,实际 %v", err)
}
if n := p.Tick(cd); n != 1 {
t.Fatalf("冷却到期应转回 1 个实例,实际 %d", n)
}
again, err := p.Allocate(1, cd)
if err != nil || again.Uses != 2 {
t.Fatalf("复用失败或 Uses=%d,期望 2", again.Uses)
}
}
// 池耗尽:全部占用后 Allocate 必须返回 ErrNoInstance。
func TestPoolExhausted(t *testing.T) {
p := NewPool(2, 10, 0)
p.Allocate(5, 0)
p.Allocate(5, 0)
if _, err := p.Allocate(5, 0); !errors.Is(err, ErrNoInstance) {
t.Fatalf("耗尽时应返回 ErrNoInstance,实际 %v", err)
}
}
TestAllocateBestFit 用三个容量悬殊的实例验证 best-fit:需求 20 时选中的必须是容量 20 的 ID=3,而不是容量 200 的 ID=1。TestRecycleCooldown 是最关键的一条:cd-1 时刻必须分配失败(冷却挡住),Tick(cd) 之后必须能复用,且 Uses 从 1 变成 2。TestPoolExhausted 覆盖了最坏情况——池子空了就老老实实报错,绝不能返回一个已分配的实例造成两个对局撞进同一块地图。
其余用例覆盖 Release 未知实例报 ErrUnknownInstance、需求超过所有实例容量时报 ErrNoInstance、以及 Stats 的四个计数口径。
本节小结 / 实测结论
本机(Go 1.26.0,darwin/arm64)实跑结果:
$ go test ./internal/match/
ok aegis/internal/match 1.170s (19 个 test 全绿,含 7 个实例池用例)
实例池的七条用例覆盖了分配、耗尽、容量不足、冷却、未知实例与统计口径。整套代码只用标准库(errors、sync),没有任何中间件依赖。绝对数值不可移植:冷却时长、容量分布、预热数量都该按《晨曦大陆》的实际负载来配,本节的默认值只保证语义正确。
到这里,单服的匹配链路就完整了:17.1 产出「谁和谁配」,17.2 保证房间不变成幽灵,17.3 把实例从池子里拿出、用完冷却再还回去。这三块拼起来,单服的「凑局」能力就成立了。而「这台服」之外的世界——多服、跨服、分区——正是卷三要展开的战场;本章刻意把边界停在单服,就是为了让每一层的责任都清楚。下一章,我们把这些玩法系统整体接进卷一的 aegis 网络层,跑出一个真正可玩的《晨曦大陆》单服。
阅读导航:上一节:17.2 大厅生命周期:防幽灵房间 · 下一节:18.1 把玩法系统接进 aegis 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。