6.1 为什么指针会失效
《微光岛》里每帧都在生成和销毁实体:飘落的萤火、被打碎的贝壳、走远的 NPC。如果这些实体之间用裸指针互相引用,只要有一个引用的对象先被回收,接下来的访问就会踩到不该踩的内存。这一节先把「失效」这件事讲清楚,并用 TypeScript 把它复现出来——不是为了写 bug,而是为了证明下一代句柄设计确实解决了它。
本章与站内既有内容的分工
站内已经有三篇内容从不同角度讲过 ECS 与引擎骨架。本节不重复它们的「是什么 / 怎么选」,只做「从零实现 + 可跑出真实产物」的那一层。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/game-engine-ecs-architecture-deep-dive/
(811 行) | 用一篇文章讲清版本化 ID + 稀疏集构成的 ECS 引擎骨架 | 把其中「句柄为什么会失效」单独拆出来,用可运行的复现用例验证 |
/game-engine-architecture-ecs/ | ECS 的理论模型、组件/系统/实体的关系 | 不解释 ECS 理论,只聚焦「引用失效」这一实现细节 |
/game-ecs-architecture/ | 游戏项目里 ECS 的落地与选型 | 不讨论选型,直接给出 helio 内核里的最小可编译实现 |
/defold-ecs-architecture/ | 从子系统视角看引擎如何组织实体 | 提供 TS 版可跑代码,而非子系统视角的说明 |
指针失效的三种形态与根源
在 C/C++ 里,指针就是一块内存地址。对象被 delete 之后,指针本身还是那个数字,它不会自己变空。于是访问它有三种可能的结果,危险程度依次上升:
- 读到垃圾数据。那块内存还没被别人用,里面是上一次留下的字节。程序不崩溃,只是拿到一堆看似合法的随机值。
- 读到 undefined / 空。在托管语言里,对象被置空后引用会变成
undefined或null。这是最「友好」的一种,至少能一眼看出问题。 - 读到另一个对象。内存被分配器回收后,恰好又分给了新建的对象。旧引用指向的地址上,现在住着一个完全无关的东西。程序不会报错,逻辑却已经错了。
把三种形态摆在一起看,差别不在「值对不对」,而在「能不能被发现」:
| 失效形态 | 访问结果 | 会崩溃吗 | 会报错吗 | 可检测性 |
|---|---|---|---|---|
| 读到未初始化/残留字节 | 随机值 | 不一定 | 否 | 低(值看着像合法数据) |
| 读到 undefined / null | 空值 | 否 | 否 | 高(一眼能看出) |
| 读到复用同一地址的新对象 | 另一个对象 | 否 | 否 | 极低(类型也完全正确) |
第三种才是真正要命的:它不崩溃、不报错、类型也对得上,只是你操作的对象换了个人。游戏里表现为「打死的怪又站起来」「给 A 装备的武器加到了 B 身上」,而且往往只在特定时序下复现。
关键点是:失效的根源不是「地址变了」,而是「地址被复用」。只要引擎会回收并复用内存(对象池、freelist、slot map 都这么做),裸引用就必然存在被复用的窗口。复用本身无法避免——否则每生成一个萤火就多占一块新内存,几分钟就把内存撑爆。既然复用不可避免,就必须让复用变得「可检测」,这正是世代句柄要做的事。
可运行产物:用裸索引复现「读到别的对象」
下面用一个最朴素的「实体表」把第三种失效复现出来。它用数组下标当句柄——这正是很多初版引擎的做法,也是对象池的雏形。层级 L1,Node 直跑。
// file: src/core/dangling-demo.ts
// tier: L1
// verify: npx vitest run src/core/dangling-demo.test.ts
//
// 反例:用「裸索引」当句柄的实体表。对象被移除、槽位被复用后,
// 旧索引会读到「另一个对象」——这正是世代句柄要解决的问题。
export type Entity = { id: number; name: string; hp: number };
export class NaiveEntityTable {
#items: (Entity | undefined)[] = [];
/** 分配一个实体:优先复用空槽(模拟对象池)。返回裸索引。 */
spawn(name: string, hp: number): number {
for (let i = 0; i < this.#items.length; i++) {
if (this.#items[i] === undefined) {
this.#items[i] = { id: i, name, hp };
return i;
}
}
const index = this.#items.length;
this.#items.push({ id: index, name, hp });
return index;
}
kill(index: number): void {
this.#items[index] = undefined;
}
/** 裸索引访问:没有任何「这个索引是否还指同一个对象」的校验。 */
at(index: number): Entity | undefined {
return this.#items[index];
}
get capacity(): number {
return this.#items.length;
}
}
对应的测试把三种情形钉死。注意第二个用例——slime 被击杀、槽位被 bat 复用之后,at(slime) 返回的不是 undefined,而是蝙蝠本体。
// file: src/core/dangling-demo.test.ts
// tier: L1
// verify: npx vitest run src/core/dangling-demo.test.ts
import { describe, expect, it } from 'vitest';
import { NaiveEntityTable } from './dangling-demo.ts';
describe('裸索引句柄的失效问题', () => {
it('对象被删后旧索引读到 undefined(最轻的一种失效)', () => {
const t = new NaiveEntityTable();
const slime = t.spawn('slime', 10);
t.kill(slime);
expect(t.at(slime)).toBeUndefined();
});
it('槽位复用后,旧索引读到「另一个对象」(最危险的一种失效)', () => {
const t = new NaiveEntityTable();
const slime = t.spawn('slime', 10);
const wolf = t.spawn('wolf', 30);
expect(slime).toBe(0);
expect(wolf).toBe(1);
// 击杀史莱姆,槽位 0 变空
t.kill(slime);
// 新生成的蝙蝠复用了槽位 0
const bat = t.spawn('bat', 5);
expect(bat).toBe(slime); // 同一个裸索引
// 旧句柄 slime 现在读到的是蝙蝠,而不是 undefined,也不报错
const stale = t.at(slime);
expect(stale).toBeDefined();
expect(stale!.name).toBe('bat');
expect(stale!.hp).toBe(5);
});
it('裸索引既无法区分「空」与「新对象」,也无法在写入时暴露错误', () => {
const t = new NaiveEntityTable();
const a = t.spawn('a', 1);
t.kill(a);
const b = t.spawn('b', 2);
// a 与 b 数值相等,语言层面无法分辨谁是谁
expect(a === b).toBe(true);
// 误写会静默改到 b 身上
t.at(a)!.hp = 999;
expect(t.at(b)!.hp).toBe(999);
});
});
本机实测:npx vitest run src/core/dangling-demo.test.ts 三个用例全过。它们并不「修好」任何东西,而是把裸索引的缺陷变成了可断言的既成事实——这是后面所有句柄设计的靶子。
另一种失效:攥着对象引用不放
裸索引之外,还有一条更隐蔽的岔路:干脆把对象引用本身当句柄用,函数返回对象、组件之间互存对象。在托管语言里,这会让对象「永远活着但已经不属于表」——GC 看到还有引用,不会回收它;可它早已从表里删掉,对它的任何修改都影响不到表。
// file: src/core/ghost-demo.ts
// tier: L1
// verify: npx vitest run src/core/ghost-demo.test.ts
//
// 第三种失效:手里攥着「对象引用」而不是句柄。对象从表里移除后,
// 引用仍然可用(GC 不会回收),但对它的修改再也影响不到表——
// 这就是「幽灵对象」:看起来一切正常,写入却丢了。
export type GhostEntity = { id: number; name: string; hp: number };
export class GhostTable {
#items: Map<number, GhostEntity> = new Map();
#next = 0;
/** 返回对象引用本身(而不是句柄)。 */
spawn(name: string, hp: number): GhostEntity {
const e = { id: this.#next++, name, hp };
this.#items.set(e.id, e);
return e;
}
kill(id: number): void {
this.#items.delete(id);
}
count(): number {
return this.#items.size;
}
/** 表内所有实体血量之和,用来观察「幽灵写入」是否真的丢了。 */
totalHp(): number {
let sum = 0;
for (const e of this.#items.values()) sum += e.hp;
return sum;
}
}
// file: src/core/ghost-demo.test.ts
// tier: L1
// verify: npx vitest run src/core/ghost-demo.test.ts
import { describe, expect, it } from 'vitest';
import { GhostTable } from './ghost-demo.ts';
describe('对象引用作为句柄的幽灵问题', () => {
it('删除后引用仍可写,但写不回表里', () => {
const t = new GhostTable();
const slime = t.spawn('slime', 10);
t.kill(slime.id);
expect(t.count()).toBe(0);
// 引用没失效:还能读、还能写
slime.hp = 999;
expect(slime.hp).toBe(999);
// 但表里已经没有任何实体,写入彻底丢失
expect(t.totalHp()).toBe(0);
});
it('幽灵对象不会报错,只会让逻辑静默走偏', () => {
const t = new GhostTable();
const a = t.spawn('a', 1);
t.kill(a.id);
// 没有异常、没有 undefined,只有「看起来改了」
expect(() => {
a.hp += 100;
}).not.toThrow();
expect(t.count()).toBe(0);
});
});
本机实测:npx vitest run src/core/ghost-demo.test.ts 两个用例全过。它和裸索引正好是「一对反例」——裸索引会读到别人的对象,对象引用则连「别人」都读不到,只是往一个没有归属的对象上写。两条路都指向同一个结论:引用的有效性必须由容器来判定,不能交给语言运行时。
托管语言里为什么也躲不过
有人会想:TypeScript 有垃圾回收,没有 delete,是不是就不会有悬垂?答案是「悬垂指针没有了,但悬垂句柄更隐蔽」。
- GC 只在没有任何引用时才回收对象。你手里还攥着旧引用,对象就一直活着,GC 不会动它——所以不会读到已释放的内存。
- 真正的问题不是「读到已释放的内存」,而是「你以为在操作 A,其实操作的是 B」。在复现里,
#items[index] = undefined清掉的是容器里的槽位,而你手里的index只是个数字,它不知道槽位已经换了住户。 - 换句话说:托管语言把「悬垂指针」换成了「悬垂句柄」。句柄是纯数据(一个
number),GC 管不到它,也不会帮你把旧句柄变空。
一个更贴近游戏的场景:玩家发射的追踪弹每帧都要找它锁定的目标。如果目标在追踪弹飞行途中被击杀——
- 目标实体从表里移除,槽位空出。
- 同一帧稍后生成了新的装饰物,复用了那个槽位。
- 下一帧追踪弹按旧索引找目标,找到了装饰物,于是转向飞向一朵花。
没有崩溃、没有报错,只有一个「AI 看起来有点傻」的 bug,而且只在「目标恰好被击杀、槽位恰好被复用」时出现。这类 bug 之所以难查,恰恰因为它不触发任何错误路径。
检测失效的三种思路
既然复用不可避免,业界用来「判断引用是否还有效」的思路主要有三种:
| 思路 | 做法 | 代价 | helio 是否采用 |
|---|---|---|---|
| 墓碑标记(tombstone) | 删除时留一个「已死」标记,访问时先查标记 | 每个对象多占空间;墓碑堆积后需要定期压实 | 否 |
| 引用计数 / 智能指针 | 记录引用数,归零才释放;弱引用可探测失效 | 每次赋值改计数,多线程要原子操作 | 否 |
| 世代句柄 | 句柄带世代,槽位复用时世代加一 | 句柄多几位;访问时比一次世代 | 是 |
墓碑的麻烦在「压实」:标记一多,遍历就得跳过大量墓碑,缓存又不友好了。智能指针(shared_ptr / weak_ptr)语义正确,但每次拷贝都要改原子计数,对每帧遍历几万次实体的引擎来说,这个开销直接落在热路径上。
世代句柄的好处是:校验成本就是「一次整数比较」,而且不增加对象本身的体积。句柄只是一个 32 位整数,可以随便拷贝、存进数组、塞进队列,不用管生命周期。这正是 helio 内核选择它的原因,也是 6.2 要完整实现的东西。
从裸索引到世代句柄
裸索引缺的不是「安全」,而是「身份」。0 这个数字在同一时刻可以代表史莱姆,下一时刻又可以代表蝙蝠,语言层面看不出区别。要区分它们,句柄里就必须再带一份「这是第几代」的信息:槽位被复用时世代加一,旧句柄的世代对不上,访问立即失败。
这正是 6.2 要实现的 slot map:把句柄编码成「低 20 位索引 + 高 12 位世代」的 32 位整数。裸索引只有索引那部分,缺的正是世代那部分。理解了失效的根源在于「地址被复用」,也就理解了为什么必须在句柄里带上世代——复用无法避免,那就让复用可被检测。
在本卷余下的部分,凡是需要「引用另一个对象」的地方,一律遵循两条约定:
- 跨生命周期引用用句柄,不用对象引用。 实体之间、组件之间、系统与资源之间,存的是
Handle(一个number),不是对象本身。 - 句柄随时可能失效,访问必须走容器。 不要缓存
get(h)的返回值跨帧使用;要长期持有就持句柄,每帧重新get。
这两条约定牺牲了一点便利(多一次查表),换来的是「悬垂访问一定会被检测到」。对一个要连续跑几小时的游戏来说,这个交换是划算的。
本节的复现用例,会在 6.2 里用 slot map 重跑一遍:同样的「删除 → 复用 → 旧句柄访问」,slot map 的答案必须是「取不到值」,而不是「读到蝙蝠」。
裸索引缺的不是「安全」,而是「身份」。0 这个数字在同一时刻可以代表史莱姆,下一时刻又可以代表蝙蝠,语言层面看不出区别。要区分它们,句柄里就必须再带一份「这是第几代」的信息:槽位被复用时世代加一,旧句柄的世代对不上,访问立即失败。
这正是 6.2 要实现的 slot map:把句柄编码成「低 20 位索引 + 高 12 位世代」的 32 位整数。裸索引只有索引那部分,缺的正是世代那部分。理解了失效的根源在于「地址被复用」,也就理解了为什么必须在句柄里带上世代——复用无法避免,那就让复用可被检测。
本节的复现用例,会在 6.2 里用 slot map 重跑一遍:同样的「删除 → 复用 → 旧句柄访问」,slot map 的答案必须是「取不到值」,而不是「读到蝙蝠」。
阅读导航:上一节:目录 · 下一节:6.2 slot map 与世代句柄 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。