《游戏引擎实现原理》6.1 为什么指针会失效

对象被回收或移动之后,旧引用要么读到 undefined,要么更糟——读到恰好复用了同一块内存的另一个对象。本节用 TypeScript 复现裸索引句柄的三种失效形态,说明内核为什么必须用「索引 + 世代」的句柄替代裸指针,并给出可运行的验证用例。

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 之后,指针本身还是那个数字,它不会自己变空。于是访问它有三种可能的结果,危险程度依次上升:

  1. 读到垃圾数据。那块内存还没被别人用,里面是上一次留下的字节。程序不崩溃,只是拿到一堆看似合法的随机值。
  2. 读到 undefined / 空。在托管语言里,对象被置空后引用会变成 undefined 或 null。这是最「友好」的一种,至少能一眼看出问题。
  3. 读到另一个对象。内存被分配器回收后,恰好又分给了新建的对象。旧引用指向的地址上,现在住着一个完全无关的东西。程序不会报错,逻辑却已经错了。

把三种形态摆在一起看,差别不在「值对不对」,而在「能不能被发现」:

失效形态访问结果会崩溃吗会报错吗可检测性
读到未初始化/残留字节随机值不一定否低(值看着像合法数据)
读到 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 管不到它,也不会帮你把旧句柄变空。

一个更贴近游戏的场景:玩家发射的追踪弹每帧都要找它锁定的目标。如果目标在追踪弹飞行途中被击杀——

  1. 目标实体从表里移除,槽位空出。
  2. 同一帧稍后生成了新的装饰物,复用了那个槽位。
  3. 下一帧追踪弹按旧索引找目标,找到了装饰物,于是转向飞向一朵花。

没有崩溃、没有报错,只有一个「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 与世代句柄 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径