4.2 线性 arena 与栈式分配器
上一节我们用实测确认了一件事:ArrayBuffer 的内容不走 JS 堆,所以把数据放进去,就等于把 GC 从关键路径上拿掉。但光有一块 buffer 还不够——你得有一个分配器来决定「下一块数据放在 buffer 的哪个位置」。这一节就来写它,而且写到底。
线性 arena(linear arena,也叫 bump allocator)可能是所有分配器里最简单的一种:它只有一块连续内存和一个只前进的偏移量。 分配就是「把偏移量按对齐向上取整,再往后推 size 字节,返回旧偏移」;释放只有两种形态——要么整块 reset,要么回退到一个更早的位置。没有 free list,没有碎片整理,没有元数据头。正因为简单,它几乎是零开销的。
本节产出一个完整可跑的
src/core/arena.ts与配套的arena.test.ts。所有代码都在 Node 26.5.0 上跑过tsc --noEmit与vitest,正文里的偏移量数字全部来自真实运行结果。
bump 分配:一个只前进的偏移量
先把核心机制说清楚。arena 内部维护一个 offset,初值 0:
- 分配
size字节:把offset按对齐要求向上取整得到aligned,检查aligned + size有没有超出容量,没超就把offset推到aligned + size,返回aligned。 - 释放:不做任何事。要么
reset()把offset归零,要么rewindTo(mark)退回到某个更早的偏移。
这个设计带来三个直接后果。第一,分配是 O(1) 的,没有查找、没有分裂、没有链表遍历。第二,释放是 O(1) 的,而且只有「整块」这一种粒度。第三,内存天然连续,因为数据就是按分配顺序一个挨一个铺下去的。
代价也很明确:你不能单独释放中间某一块。 如果先分配 A 再分配 B,你没法只回收 A 而保留 B——因为 B 的偏移在 A 之后,回收 A 会让 B 变成悬垂。这听起来像缺点,但对「每帧临时数据」这种场景恰好合适:整帧的数据一起活、一起死,天然满足「整块释放」。
对齐:为什么偏移量不能直接加
如果只是 offset += size,那 arena 用两行就写完了。真正让它变得像分配器而不是字节计数器的,是对齐。
CPU 读内存是按「字」读的,GPU 更是有严格的对齐要求。一个 4 字节的 float 如果落在偏移 2 的位置,某些平台读它会直接报错,另一些平台能读但慢。所以分配器必须保证:每一块返回的内存,其起始偏移都是该类型对齐的整数倍。
对齐的实现只有一行位运算,前提是 alignment 是 2 的幂:
aligned = (offset + alignment - 1) & ~(alignment - 1)
以 alignment = 4 为例:~3 会把低两位清零。offset = 3 时,3 + 3 = 6,6 & ~3 = 4——正好把 3 向上对齐到 4。这也是为什么代码里会强制要求 alignment 是 2 的幂:不是 2 的幂就不能用这个位技巧。
代价是内部碎片:offset = 3 时分配一个 4 字节对齐的块,会浪费掉 1 个字节(偏移 3 那一个字节永远用不上)。arena 不回收这种浪费,这是它「简单」要付出的代价——但在实践中,只要对齐粒度不超过 16 字节,这点碎片可以忽略。
可运行产物:src/core/arena.ts
下面是完整的 src/core/arena.ts。它把底层 buffer 同时暴露成 Uint8Array / Float32Array / Uint32Array 三种视图,f32View / u32View / byteView 返回的是子视图(subarray),与 arena 共享同一块底层 buffer,因此是零拷贝的——写视图就是写 arena。
// file: src/core/arena.ts
// tier: L1
// verify: npx vitest run src/core/arena.test.ts
//
// helio 内核:线性 arena 分配器。
// 底层是一块 ArrayBuffer + typed array 视图——buffer 的内容不走 JS 堆,
// 这是本套书里「真手动内存」的立足点。
export type ByteOffset = number;
/** 线性 arena:只前进,不回收;`reset()` 一次性释放全部。 */
export class Arena {
readonly #buffer: ArrayBuffer;
readonly #bytes: Uint8Array;
readonly #f32: Float32Array;
readonly #u32: Uint32Array;
#offset = 0;
#highWater = 0;
constructor(capacityBytes: number) {
if (!Number.isInteger(capacityBytes) || capacityBytes <= 0) {
throw new RangeError(`Arena capacity must be a positive integer, got ${capacityBytes}`);
}
this.#buffer = new ArrayBuffer(capacityBytes);
this.#bytes = new Uint8Array(this.#buffer);
this.#f32 = new Float32Array(this.#buffer);
this.#u32 = new Uint32Array(this.#buffer);
}
get capacity(): number {
return this.#buffer.byteLength;
}
get used(): number {
return this.#offset;
}
/** 峰值占用,用于评估容量是否合理。 */
get highWaterMark(): number {
return this.#highWater;
}
get remaining(): number {
return this.#buffer.byteLength - this.#offset;
}
/** 按 alignment(必须是 2 的幂)向上对齐当前偏移后推进。 */
#bump(sizeBytes: number, alignment: number): ByteOffset {
const aligned = (this.#offset + alignment - 1) & ~(alignment - 1);
const end = aligned + sizeBytes;
if (end > this.#buffer.byteLength) {
throw new RangeError(
`Arena out of memory: need ${sizeBytes} B @ align ${alignment}, ` +
`have ${this.#buffer.byteLength - aligned} B left`,
);
}
this.#offset = end;
if (end > this.#highWater) this.#highWater = end;
return aligned;
}
allocBytes(sizeBytes: number, alignment = 8): ByteOffset {
return this.#bump(sizeBytes, alignment);
}
allocF32(count: number): ByteOffset {
return this.#bump(count * 4, 4);
}
allocU32(count: number): ByteOffset {
return this.#bump(count * 4, 4);
}
/** 返回可读写的 f32 视图(与 arena 共享底层 buffer,零拷贝)。 */
f32View(byteOffset: ByteOffset, count: number): Float32Array {
return this.#f32.subarray(byteOffset >> 2, (byteOffset >> 2) + count);
}
u32View(byteOffset: ByteOffset, count: number): Uint32Array {
return this.#u32.subarray(byteOffset >> 2, (byteOffset >> 2) + count);
}
byteView(byteOffset: ByteOffset, count: number): Uint8Array {
return this.#bytes.subarray(byteOffset, byteOffset + count);
}
/** 一次性归还全部内存(不回退 buffer,只是把偏移归零)。 */
reset(): void {
this.#offset = 0;
}
/**
* 把偏移回退到更早的位置。仅供栈式分配器使用——
* 回退后原先分配的区块视为失效,再次分配会覆盖它们。
*/
rewindTo(byteOffset: ByteOffset): void {
if (byteOffset < 0 || byteOffset > this.#offset) {
throw new RangeError(`rewindTo(${byteOffset}) out of range [0, ${this.#offset}]`);
}
this.#offset = byteOffset;
}
}
/** 栈式分配器:支持把偏移恢复到标记点,适合「帧内临时内存」。 */
export class ScratchArena {
readonly arena: Arena;
#mark = 0;
constructor(capacityBytes: number) {
this.arena = new Arena(capacityBytes);
}
mark(): ByteOffset {
this.#mark = this.arena.used;
return this.#mark;
}
release(): void {
this.arena.rewindTo(this.#mark);
}
}
几点值得单独指出。其一,容量校验在构造时就做,非法容量直接抛 RangeError,而不是等到第一次分配才炸——把错误尽早暴露。其二,越界检查带上了具体数字,报错信息里有「需要多少、对齐多少、还剩多少」,调试时不用再手算。其三,rewindTo 拒绝前向回退,byteOffset > this.#offset 会抛错,防止你把偏移推到已分配区域之外。
vitest 用例与实测输出
分配器最容易错的地方是边界:对齐算错一位、越界检查漏一个等号、reset 之后没有真正复用 buffer。下面这组用例就是冲着这些边界去的。
// file: src/core/arena.test.ts
// tier: L1
// verify: npx vitest run src/core/arena.test.ts
import { describe, expect, it } from 'vitest';
import { Arena, ScratchArena } from './arena.ts';
describe('Arena', () => {
it('按对齐推进偏移', () => {
const a = new Arena(1024);
expect(a.allocBytes(3, 1)).toBe(0);
expect(a.allocBytes(4, 4)).toBe(4); // 3 → 对齐到 4
expect(a.used).toBe(8);
});
it('越界抛 RangeError', () => {
const a = new Arena(16);
expect(() => a.allocBytes(32)).toThrow(RangeError);
});
it('f32 视图零拷贝共享底层 buffer', () => {
const a = new Arena(1024);
const off = a.allocF32(4);
const v = a.f32View(off, 4);
v[0] = 1.5;
v[3] = -2.25;
expect(a.f32View(off, 4)[0]).toBeCloseTo(1.5);
expect(a.f32View(off, 4)[3]).toBeCloseTo(-2.25);
});
it('highWaterMark 记录峰值', () => {
const a = new Arena(1024);
a.allocBytes(100);
a.allocBytes(50);
expect(a.highWaterMark).toBeGreaterThanOrEqual(150);
});
it('reset 后偏移归零但 buffer 复用', () => {
const a = new Arena(1024);
a.allocBytes(500);
a.reset();
expect(a.used).toBe(0);
expect(a.capacity).toBe(1024);
});
});
describe('ScratchArena', () => {
it('mark/release 回退临时分配', () => {
const s = new ScratchArena(1024);
s.arena.allocF32(16);
const before = s.arena.used;
s.mark();
s.arena.allocF32(64);
expect(s.arena.used).toBeGreaterThan(before);
s.release();
expect(s.arena.used).toBe(before);
});
it('rewindTo 拒绝前向回退', () => {
const s = new ScratchArena(1024);
s.mark();
s.arena.allocF32(16);
expect(() => s.arena.rewindTo(9999)).toThrow(RangeError);
});
});
在 /tmp/enginebook 里跑 npx vitest run src/core/arena.test.ts,7 个用例全部通过。为了把中间状态也看清楚,我另跑了一段脚本打印每一步的偏移量,原始输出如下(本机实测,绝对数值不可移植):
allocBytes(3,1) -> 0
allocBytes(4,4) -> 4 # 3 向上对齐到 4
used -> 8
highWaterMark -> 162 # 8 + 100 → 108,再 +50 对齐到 112 → 162
after reset used -> 0 capacity -> 1024
during scratch used -> 320 before -> 64 # 64 + 64*4 = 320
after release used -> 64
对照代码看这几行:allocBytes(3, 1) 返回 0,因为初始偏移就是 0;接着 allocBytes(4, 4) 先把偏移 3 对齐到 4,返回 4,然后把偏移推到 8。highWaterMark 到 162,是因为 allocBytes(50) 默认对齐 8,把 108 先对齐到 112,再加 50 得到 162。ScratchArena 那段更能说明问题:allocF32(16) 让偏移到 64,mark() 记下 64,allocF32(64) 再推 256 字节到 320,release() 之后精确回到 64——临时内存被整块归还,没有任何逐块释放。
highWaterMark、reset 与帧内 scratch
highWaterMark 记录的是 arena 生命周期里用过的最大偏移,它是容量调参的唯一依据。用法很直白:先用一个偏大的容量跑一遍完整场景,读 highWaterMark,然后把容量定在这个值向上取整到 2 的幂。这样既不会浪费内存,也不会在某个极限帧突然越界。注意 reset() 不会清零 highWaterMark——峰值是历史信息,重置数据不该抹掉它。
ScratchArena 则把 arena 包装成一个栈。典型用法是在帧的开始 mark() 一次,帧内的各种临时计算(可见性剔除的中间结果、临时的 draw call 列表、粒子更新缓冲)都从同一个 scratch arena 里分配;帧结束时 release() 一次,把整帧的临时数据一次性归还。由于下一帧会覆盖同样的字节,release() 之后不要再持有上一帧的视图——这和栈上变量出作用域后失效是同一个道理。
还有一个实战细节:arena 应该按用途分成几块,而不是共用一块巨大的。 比如「每帧临时」用一块 1 MB 的 scratch,「关卡常驻」用另一块大的,「持久资源」再一块。这样 reset() 的作用域才清晰——你不会因为重置临时区而误伤常驻数据。第 5 章会把这种「按标签分块 + 预算」的做法正式化。
小结
本节只做了「只前进、整块释放」这一种分配策略。它不解决「释放中间某一块」「按对象大小分桶复用」「统计每类内存占用」这些需求——那是第 5 章的对象池与 block allocator 要干的事。如果你现在就想要「能单独释放对象」的能力,正确的方向不是给 arena 加 free,而是换一种分配器(见 5.1 的 freelist 与 5.2 的 size class)。
下一节我们处理 arena 与 GPU 之间最后一道坎:对齐、padding,以及把一块 ArrayBuffer 手工切成顶点结构时,偏移量到底该落在哪里。
阅读导航:上一节:4.1 为什么要自己管内存 · 下一节:4.3 对齐、padding 与 TypedArray 上的真实布局 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。