6.2 slot map 与世代句柄
6.1 把问题钉死了:裸索引无法区分「同一个槽位的两任住户」。解法只有一句话——在句柄里再加一份「这是第几代」的信息。这一节实现 helio 内核的 SlotMap:一个用 32 位整数句柄索引的容器,句柄失效可以在访问的瞬间被检测出来,不需要任何额外的墓碑标记或延迟清理。
句柄编码:索引 20 位 + 世代 12 位
句柄就是一个 number,但它的位被切成两段。低位放槽位索引,高位放世代计数:
| 位区间 | 含义 | 容量 |
|---|---|---|
| bit 0–19(低 20 位) | 槽位索引 index | 最多约 104 万个活跃槽位 |
| bit 20–31(高 12 位) | 世代 generation | 每个槽位可复用 4096 代 |
makeHandle(index, gen)把两段拼回一个数,再>>> 0转成无符号 32 位。handleIndex(h)用掩码取出低 20 位;handleGeneration(h)右移 20 位再掩码。- 世代用 12 位循环计数(
& 0xfff),即 4096 代后回绕。回绕意味着理论上存在「恰好过了 4096 代、旧句柄又对上」的极小概率,工程上通过把世代位加宽或干脆用 64 位句柄来回避;本节保持 12 位,先讲清机制。 NULL_HANDLE = 0是保留的空句柄:索引与世代都是 0,永远取不到值。
一个细节:索引从 0 开始,而世代从 1 开始。这样「索引 0、世代 0」就只剩空句柄一种可能,get(0) 可以直接短路返回,不必查表。
举个具体的例子,makeHandle(42, 3) 得到的句柄是这样的:
| 部分 | 二进制(低 24 位) | 十进制 |
|---|---|---|
| 世代 3(高 12 位) | 0000 0000 0011 | 3 |
| 索引 42(低 20 位) | 0000 0000 0000 0010 1010 | 42 |
| 拼合后的句柄 | 0011 0000 0000 0010 1010 | 3145770 |
handleIndex(3145770) 掩掉高 12 位得到 42,handleGeneration(3145770) 右移 20 位得到 3,一来一回无损。槽位 42 被复用后世代变成 4,句柄数值随之变成另一个数,旧句柄的世代 3 就对不上了。本机实测:node -e 跑上面这段位运算,三个数分别是 3145770、42、3。
为什么是 20 / 12 而不是 16 / 16?因为「活跃槽位上限」比「单个槽位的复用次数」更早撞到墙。一帧内同时在世的实体通常几万到几十万,20 位(约 104 万)留了余量;而一个槽位被复用 4096 次,意味着同一位置先后住过 4096 个对象,在正常游戏节奏下已经相当极端。两段的划分本质上是一个「容量预算」的取舍,可以按游戏规模调整——需要更多实体就把索引位加宽,需要更长时间才回绕就加宽世代位。
可运行产物:SlotMap 完整实现
下面是完整可编译的 src/core/slotmap.ts。它同时是「世代句柄」和「稀疏集」的雏形:#slots 是 dense 之外的第一层存储,#generations 平行记录每个槽位的世代,#free 是一个空闲槽位栈。层级 L1,Node 直跑。
// file: src/core/slotmap.ts
// tier: L1
// verify: npx vitest run src/core/slotmap.test.ts
//
// helio 内核:世代句柄(generational handle)+ 稀疏集(sparse set)。
// 用 32 位整数索引代替指针:索引 20 位 + 世代 12 位,句柄失效可检测。
/** 句柄:高位是世代,低位是槽位索引。0 是「空句柄」。 */
export type Handle = number;
const INDEX_BITS = 20;
const INDEX_MASK = (1 << INDEX_BITS) - 1;
const GEN_MASK = 0xfff; // 12 位世代
export function makeHandle(index: number, generation: number): Handle {
return (((generation & GEN_MASK) << INDEX_BITS) | (index & INDEX_MASK)) >>> 0;
}
export function handleIndex(h: Handle): number {
return h & INDEX_MASK;
}
export function handleGeneration(h: Handle): number {
return (h >>> INDEX_BITS) & GEN_MASK;
}
export const NULL_HANDLE: Handle = 0;
/** 带世代校验的稀疏集:句柄失效(dangling)在访问时立即暴露。 */
export class SlotMap<T> {
#slots: (T | undefined)[] = [];
#generations: number[] = [];
#free: number[] = [];
#live = 0;
get size(): number {
return this.#live;
}
/** 分配一个新槽位,返回句柄。 */
insert(value: T): Handle {
let index: number;
if (this.#free.length > 0) {
index = this.#free.pop()!;
} else {
index = this.#slots.length;
this.#slots.push(undefined);
this.#generations.push(1);
}
this.#slots[index] = value;
this.#live++;
return makeHandle(index, this.#generations[index]);
}
/** 句柄有效则返回其值,否则 undefined(世代不符即失效)。 */
get(h: Handle): T | undefined {
if (h === NULL_HANDLE) return undefined;
const i = handleIndex(h);
if (i >= this.#slots.length) return undefined;
if (this.#generations[i] !== handleGeneration(h)) return undefined;
return this.#slots[i];
}
has(h: Handle): boolean {
return this.get(h) !== undefined;
}
/** 校验句柄并返回其值;失效则抛错——用于「本应有效」的场景。 */
expect(h: Handle): T {
const v = this.get(h);
if (v === undefined) {
throw new Error(`Stale handle: 0x${h.toString(16)} (slot ${handleIndex(h)})`);
}
return v;
}
/** 移除并让世代自增,使所有旧句柄立即失效。 */
remove(h: Handle): boolean {
if (!this.has(h)) return false;
const i = handleIndex(h);
this.#slots[i] = undefined;
this.#generations[i] = (this.#generations[i] + 1) & GEN_MASK;
this.#free.push(i);
this.#live--;
return true;
}
/** 遍历所有活跃 (句柄, 值) 对,顺序按槽位索引。 */
*entries(): Generator<[Handle, T]> {
for (let i = 0; i < this.#slots.length; i++) {
const v = this.#slots[i];
if (v !== undefined) yield [makeHandle(i, this.#generations[i]), v];
}
}
}
remove 是全篇的关键:它先把值清空,紧接着把该槽位的世代加一,再压进 #free 备用。于是「同一个槽位被复用」和「旧句柄失效」同时成立——复用不会让旧句柄重新变有效。
测试:槽位复用但旧句柄取不到值
6.1 的裸索引复现用例,在 slot map 上必须给出相反答案。下面这个用例直接对标它:h1 被移除、h2 复用了同一槽位,但 get(h1) 必须是 undefined。
// file: src/core/slotmap.test.ts
// tier: L1
// verify: npx vitest run src/core/slotmap.test.ts
import { describe, expect, it } from 'vitest';
import {
NULL_HANDLE,
SlotMap,
handleGeneration,
handleIndex,
makeHandle,
} from './slotmap.ts';
describe('Handle 编解码', () => {
it('往返一致', () => {
const h = makeHandle(12345, 7);
expect(handleIndex(h)).toBe(12345);
expect(handleGeneration(h)).toBe(7);
});
it('NULL_HANDLE 索引与世代都是 0', () => {
expect(handleIndex(NULL_HANDLE)).toBe(0);
expect(handleGeneration(NULL_HANDLE)).toBe(0);
});
});
describe('SlotMap', () => {
it('插入后可取回', () => {
const m = new SlotMap<string>();
const h = m.insert('alpha');
expect(m.get(h)).toBe('alpha');
expect(m.size).toBe(1);
});
it('移除后旧句柄立即失效(世代自增)', () => {
const m = new SlotMap<string>();
const h = m.insert('alpha');
m.remove(h);
expect(m.get(h)).toBeUndefined();
expect(m.has(h)).toBe(false);
});
it('槽位复用但新句柄与旧句柄不等(防悬垂引用)', () => {
const m = new SlotMap<string>();
const h1 = m.insert('alpha');
m.remove(h1);
const h2 = m.insert('beta');
expect(handleIndex(h2)).toBe(handleIndex(h1)); // 复用同一槽位
expect(h2).not.toBe(h1); // 但世代不同
expect(m.get(h1)).toBeUndefined(); // 旧句柄不会读到新值
expect(m.get(h2)).toBe('beta');
});
it('expect 对失效句柄抛错', () => {
const m = new SlotMap<number>();
const h = m.insert(42);
m.remove(h);
expect(() => m.expect(h)).toThrow(/Stale handle/);
});
it('NULL_HANDLE 永远取不到值', () => {
const m = new SlotMap<number>();
m.insert(1);
expect(m.get(NULL_HANDLE)).toBeUndefined();
});
it('entries 只遍历活跃槽位', () => {
const m = new SlotMap<number>();
const a = m.insert(1);
m.insert(2);
const c = m.insert(3);
m.remove(c);
const seen = [...m.entries()].map(([, v]) => v);
expect(seen).toEqual([1, 2]);
expect(m.has(a)).toBe(true);
});
});
本机实测:npx vitest run src/core/slotmap.test.ts 全部通过。第三个用例正是 6.1 里「旧索引读到蝙蝠」的对照——同一个场景,slot map 给出的结果是 undefined。
常见坑
实现和使用 slot map 时,有几个反复出现的坑:
- 把世代写进对象里。世代属于「槽位」,不属于对象。对象被移除后世代才自增,所以世代必须和槽位平行存储(
#generations),不能塞进T里。 - 复用时不加世代。如果
insert复用空闲槽位却忘了让世代保持递增,旧句柄会重新变有效——这正是 6.1 的 bug 换了个壳。 - 拿
get的结果当长期引用。get返回的是对象引用,一旦移除,引用就变成幽灵对象(6.1 已复现)。要长期持有就存句柄,每帧重新get。 - 句柄默认值乱写。
NULL_HANDLE = 0是保留值,任何「没有对象」的默认值都应该用它,而不是-1和undefined混着来。 - 拿句柄当数组下标。槽位号要用
handleIndex(h)解出来,句柄本身带世代位,直接拿h当下标会越界。
第 2 条最容易被忽略,因为它在「槽位刚好被复用」时才暴露,单测很容易漏掉。6.2 的用例专门覆盖了它。
三种访问语义:get / has / expect
同一个句柄有三种访问方式,对应三种「我有多确定它还有效」的语气:
get(h):不确定,可能失效。失效返回undefined,适合每帧扫描时跳过已销毁对象。has(h):只想问存在性,不取值。expect(h):本应有效。失效直接抛错,用来把「逻辑上不该发生」的悬垂访问变成一次显式的崩溃——早崩早发现,而不是让它静默地读到别的对象。
expect 的报错信息里带上了句柄的十六进制值和槽位号,方便在日志里反查是哪一次分配/释放出的问题。把「失效」从一个不可观测的状态,变成一个可观测、可断言的信号,这才是世代句柄相对裸指针的全部价值。
还有一点常被忽略:句柄是纯整数,所以可以直接序列化、存进组件、写进存档。6.1 里的对象引用做不到这一点——引用一旦写进文件,下次读回来就指向不存在的东西。用句柄存「引用」是引擎能存档、能网络同步的前提。这一特性会在卷一第 17 章的序列化里继续展开。这里只需记住:句柄是「可以存起来、以后再校验」的引用,而对象引用是「存起来就坏掉」的引用。
有了 SlotMap,「删除 → 复用 → 旧句柄访问」这条最危险的路径被彻底堵死。但 entries() 的遍历是「按槽位索引扫、跳过空洞」——稀疏的槽位会让缓存不友好。6.3 用稀疏集把存储层换成紧凑的 dense 数组,让遍历重新变成一段连续内存。
阅读导航:上一节:6.1 为什么指针会失效 · 下一节:6.3 稀疏集、句柄回收与 ECS 的接缝 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。