《游戏引擎实现原理》5.1 对象池与 freelist

本节用空闲链表实现定长对象池 ObjectPool<T>,支持 acquire/release 与耗尽时抛错或扩容两种策略;并在本机真跑 10 万次构造,对照 new/GC 给出耗时与堆增量,诚实讨论池化反而更慢的场景,最后总结何时才该池化。

5.1 对象池与 freelist

第 4 章我们把「内存」压到了裸字节层面:arena 只前进、不回收,靠一次性 reset() 释放全部。但《微光岛》里有一类东西不适合 arena——子弹、粒子、飘字、音效播放器。它们的数量随时间剧烈波动,生命周期有长有短,而且常常需要「按住一个句柄不放,几十帧后再还回来」。arena 做不到任意顺序的归还,于是我们请出这一章的第二个工具:对象池。

对象池的核心是一张空闲链表(freelist):不用的对象串成一条链,要用时从链上摘一个,用完复位再挂回去。本节先用最朴素的数组当链表,实现一个通用 ObjectPool<T>,然后真跑一组数据,看看它到底比 new 快多少——以及什么时候反而更慢。

本章与站内既有内容的分工

站内已经有人从「引擎子系统」和「架构」的角度讲过内存与池化,本节只负责「从零实现 + 真实基准」这一块:

既有文章它负责什么本节只做什么
/defold-object-pooling-memory/Defold 里对象池与内存管理的用法与子系统视角用空闲链表从零写一个通用 ObjectPool<T>,并实测
/game-engine-architecture-ecs/ECS 引擎骨架的全局架构只关心「对象从哪来、怎么复用」这一层
/posts/game/engine/Unity / Unreal 等引擎的选型对比不选型,直接手写内核

一句话:别人讲「是什么、怎么选」,本节讲「怎么搭出来、跑出什么数」。

空闲链表就是池子的骨架

池子最简单、也最稳的形态是定长:容量在构造时定死,对象预先造好。取出(acquire)就是从链表尾弹一个,归还(release)就是复位后压回去。为什么用「尾」而不是「头」?因为 Array.pop / Array.push 是 O(1),而 shift / unshift 要搬动整个数组。

下面这个文件是 helio 的对象池实现,纯 TS、Node 直跑,是本节第一块可运行产物。

// file: src/core/object-pool.ts
// tier: L1
// verify: npx vitest run src/core/object-pool.test.ts
//
// helio 内核:定长对象池 + 空闲链表(freelist)。
// 与 4.x 的 arena 不同,池化保留「对象身份」——同一个实例被反复取出、
// 复位、放回,因此适合子弹、粒子、音频源这类「生命周期短、字段形状固定」的实体。

/** 池耗尽时的策略:抛错,或临时扩容。 */
export type ExhaustionPolicy = "throw" | "grow";

export interface PoolStats {
  /** 当前容量(grow 策略下会变大)。 */
  readonly capacity: number;
  /** 空闲链表里的对象数。 */
  readonly free: number;
  /** 正在被使用的对象数。 */
  readonly inUse: number;
  /** 累计真实构造次数(池的核心收益指标)。 */
  readonly created: number;
  /** 累计「从空闲链表里取到」的次数。 */
  readonly recycled: number;
  /** 累计触到容量上限的次数。 */
  readonly exhausted: number;
}

export interface ObjectPoolOptions<T> {
  create: () => T;
  reset: (item: T) => void;
  initial: number;
  policy?: ExhaustionPolicy;
}

/**
 * 定长对象池。预分配 `initial` 个实例塞进空闲链表;
 * `acquire()` 从链表尾弹出,`release()` 复位后压回。
 */
export class ObjectPool<T> {
  readonly #create: () => T;
  readonly #reset: (item: T) => void;
  readonly #policy: ExhaustionPolicy;
  #free: T[];
  #capacity: number;
  #inUse = 0;
  #created = 0;
  #recycled = 0;
  #exhausted = 0;

  constructor(opts: ObjectPoolOptions<T>) {
    if (!Number.isInteger(opts.initial) || opts.initial < 0) {
      throw new RangeError(`initial must be a non-negative integer, got ${opts.initial}`);
    }
    this.#create = opts.create;
    this.#reset = opts.reset;
    this.#policy = opts.policy ?? "throw";
    this.#capacity = opts.initial;
    this.#free = [];
    for (let i = 0; i < opts.initial; i++) {
      this.#free.push(this.#create());
      this.#created++;
    }
  }

  get capacity(): number {
    return this.#capacity;
  }

  get inUse(): number {
    return this.#inUse;
  }

  get stats(): PoolStats {
    return {
      capacity: this.#capacity,
      free: this.#free.length,
      inUse: this.#inUse,
      created: this.#created,
      recycled: this.#recycled,
      exhausted: this.#exhausted,
    };
  }

  /** 取一个对象:优先复用空闲链表,链表空则构造(或按策略扩容/抛错)。 */
  acquire(): T {
    const reused = this.#free.pop();
    if (reused !== undefined) {
      this.#recycled++;
      this.#inUse++;
      return reused;
    }
    if (this.#created >= this.#capacity) {
      this.#exhausted++;
      if (this.#policy === "throw") {
        throw new RangeError(
          `ObjectPool exhausted: ${this.#inUse} in use, capacity ${this.#capacity}`,
        );
      }
      this.#capacity++;
    }
    const fresh = this.#create();
    this.#created++;
    this.#inUse++;
    return fresh;
  }

  /** 复位后放回空闲链表。重复 release 同一对象会破坏计数,故显式拦截。 */
  release(item: T): void {
    if (this.#inUse <= 0) {
      throw new RangeError("ObjectPool.release called with nothing in use");
    }
    this.#reset(item);
    this.#free.push(item);
    this.#inUse--;
  }

  /** 池中空闲对象数——用来判断「池是否开得太大」。 */
  get freeCount(): number {
    return this.#free.length;
  }
}

有三处设计值得单独说明。第一,create 与 reset 是外部注入的。池子本身不该知道对象长什么样——粒子有 life,子弹有 vx/vy,音频源有 buffer,它们各自的复位逻辑只有调用方清楚。把这两个函数做成参数,池子就成了一个与业务无关的容器。

第二,created 才是池子的收益指标。注意 recycled 会把「从预分配链表里取到」也计入——所以一个预分配 1024 的池子跑 10 万次 acquire,recycled 会涨到 10 万,而 created 始终是 1024。判断池子是否有效,看的是 created 有没有失控增长。

第三,release 里那句 inUse <= 0 的拦截不是洁癖。同一对象被 release 两次,会让它同时出现在空闲链表和「使用中」两处,计数就此崩坏。用一个廉价的前置判断把这种错误尽早炸出来,比事后对着一堆诡异的复用 bug 强。

可运行产物:池 vs new 的 10 万次构造

光有实现不算数,得拿数据说话。下面是基准脚本的核心循环(完整脚本在 bench/object-pool-bench.ts),三组负载都在 10 万次量级上:

// snippet: bench/object-pool-bench.ts 的核心循环
function churnPool(): { ms: number; created: number } {
  const pool = new ObjectPool<Particle>({
    create: makeParticle, reset: resetParticle, initial: 1024,
  });
  const t0 = performance.now();
  for (let i = 0; i < N; i++) {
    const p = pool.acquire();
    p.x = i; p.life = 1; sink += p.x;
    pool.release(p);
  }
  return { ms: performance.now() - t0, created: pool.stats.created };
}

function churnNew(): number {
  const t0 = performance.now();
  for (let i = 0; i < N; i++) {
    const p: Particle = { x: 0, y: 0, vx: 0, vy: 0, life: 0, alive: false };
    p.x = i; p.life = 1; sink += p.x; // p 在此失去引用,交给 GC
  }
  return performance.now() - t0;
}

本机实测(Node 26.5.0,node --expose-gc bench/object-pool-bench.ts,每组取 9 次运行的中位数):

负载池new + 丢弃结论
A:10 万次 acquire/release1.12 ms(created=1024)1.41 ms(created=100000)new 慢 1.26×
B:一次性造 10 万个且不释放2.25 ms2.03 ms池慢 1.11×
C:堆增量(不强制 GC)+1679 KiB+8818 KiB池的未回收字节少 5.2×

A 组是池子的主场:预分配 1024 个对象,10 万次循环里 created 始终是 1024,recycled 涨到 100000——一次新构造都没发生。C 组更说明问题:不强制 GC 时,new 版本在账上留下了 8.6 MiB 待回收的年轻对象,而池版本只有 1.6 MiB。

基准之外,还要有断言把「池子语义」钉死。下面这个测试文件覆盖复用同一实例、复位字段、耗尽抛错、扩容、计数守恒五件事,是本节第二块可运行产物:

// file: src/core/object-pool.test.ts
// tier: L1
// verify: npx vitest run src/core/object-pool.test.ts
import { describe, expect, it } from 'vitest';
import { ObjectPool } from './object-pool.ts';

interface Bullet {
  x: number;
  y: number;
  vx: number;
  vy: number;
  alive: boolean;
}

function makeBullet(): Bullet {
  return { x: 0, y: 0, vx: 0, vy: 0, alive: false };
}

function resetBullet(b: Bullet): void {
  b.x = 0;
  b.y = 0;
  b.vx = 0;
  b.vy = 0;
  b.alive = false;
}

function newPool(initial: number, policy?: 'throw' | 'grow'): ObjectPool<Bullet> {
  return new ObjectPool<Bullet>({ create: makeBullet, reset: resetBullet, initial, policy });
}

describe('ObjectPool', () => {
  it('预分配不重复构造,且 acquire 复用同一实例', () => {
    const pool = newPool(4);
    expect(pool.stats.created).toBe(4);
    const a = pool.acquire();
    pool.release(a);
    const b = pool.acquire();
    expect(b).toBe(a); // 复用同一实例
    expect(pool.stats.recycled).toBe(2); // 两次 acquire 都来自空闲链表
    expect(pool.stats.created).toBe(4); // 全程没有新增构造
  });

  it('release 会复位字段', () => {
    const pool = newPool(1);
    const b = pool.acquire();
    b.x = 42;
    b.alive = true;
    pool.release(b);
    const again = pool.acquire();
    expect(again.x).toBe(0);
    expect(again.alive).toBe(false);
  });

  it('耗尽时 throw 策略抛 RangeError', () => {
    const pool = newPool(1, 'throw');
    pool.acquire();
    expect(() => pool.acquire()).toThrow(RangeError);
    expect(pool.stats.exhausted).toBe(1);
  });

  it('耗尽时 grow 策略扩容并构造新对象', () => {
    const pool = newPool(1, 'grow');
    const a = pool.acquire();
    const b = pool.acquire();
    expect(b).not.toBe(a);
    expect(pool.stats.created).toBe(2);
    expect(pool.stats.capacity).toBe(2);
  });

  it('inUse / free 计数守恒', () => {
    const pool = newPool(3);
    const xs = [pool.acquire(), pool.acquire()];
    expect(pool.stats.inUse).toBe(2);
    expect(pool.freeCount).toBe(1);
    pool.release(xs[0]!);
    expect(pool.stats.inUse).toBe(1);
    expect(pool.freeCount).toBe(2);
  });

  it('空池 release 抛错(防重复归还)', () => {
    const pool = newPool(2);
    expect(() => pool.release(makeBullet())).toThrow(RangeError);
  });
});

本机实测 npx vitest run src/core/object-pool.test.ts:Test Files 1 passed (1)、Tests 6 passed (6)。其中「复用同一实例」那条正是池子与 arena 的分水岭——arena 每次给你一块新地址,池子给你同一个对象。

什么时候池化反而更慢

上面 A 组只快了 1.26×,B 组甚至慢了 1.11×。这两个数字都真实,且都在告诉我们同一件事:池化不是免费的午餐。

先看 B 组为什么输。B 组「造 10 万个对象,全程持有,从不归还」——这恰恰是池子最不擅长的工作负载。每次 acquire 都要走一遍「弹链表 → 判空 → 判容量 → 扩容 → create」的路径,reset 那一步还完全白费(对象根本没被复用)。new 只做一件事:分配。当复用率是零时,池子额外付的是簿记成本,收益是零。

再看 A 组为什么赢得不多。在 C++ 里对象池动辄快几倍,是因为原生 malloc 要走系统分配器;而 V8 的年轻代分配本身就是「指针碰撞」——极快。JS 里 new 一个短命对象本来就便宜,池子能省下的主要是 GC 压力(C 组的 5.2×),而不是构造时间本身。

由此可以总结出一条够用的判据:

  • 该池化:对象会被反复创建/销毁、字段形状固定、生命周期跨越多个帧、且 GC 压力已经体现在帧时间抖动上。
  • 不该池化:对象只造一次就不再释放(用数组或 arena 更直接)、对象很小且纯函数式用完即弃、或者复用率低到簿记成本盖过收益。
  • 中间态:先用 new 跑,用 5.3 的分配计数快照确认「同一类对象每帧确实在反复生死」,再决定要不要池化。

换句话说,池化是一剂针对 GC 抖动的药,不是「让分配变快」的银弹。先测,再池。

小结

  • 对象池 = 预分配 + 空闲链表;acquire 弹尾、release 复位压回,都是 O(1)。
  • created 是收益指标:预分配 1024、跑 10 万次循环后它仍是 1024。
  • 本机实测:高频 churn 下池比 new 快 1.26×,但一次性创建场景池反而慢 1.11×。
  • 池化的真实价值在降低 GC 压力(未回收字节少 5.2×),不是构造速度。
  • 先测复用率,再决定池不池。

下一节我们把这套「复用」的思路下沉到裸内存:用 2 的幂把 ArrayBuffer 切成大小类,每类一条空闲链表——那才是对象池在字节层面的样子。

阅读导航:上一节:目录 · 下一节:5.2 块分配器与大小类 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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