For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/stage/4/proposal-resizablearraybuffer.md.
  • 简体中文
  • Resizable and growable ArrayBuffers S4

    中文标题:可调整大小和可增长的 ArrayBuffer

    提案概览
    提案速览

    该提案扩展了 ArrayBuffer 和 SharedArrayBuffer 构造函数,使其接受可选的​​最大长度,从而实现原地调整大小(针对 ArrayBuffer)或增长(针对 SharedArrayBuffer)。由这些缓冲区支持的 TypedArray 可以自动跟踪长度,或在越界时表现出类似已分离的行为。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    原地可调整大小和可增长的 ArrayBuffer

    阶段:4,已进入规范

    作者:Shu-yu Guo (@syg)

    提案负责人:Shu-yu Guo (@syg)

    简介

    ArrayBuffer 使得内存中的二进制数据处理成为可能,并已取得巨大成功。本提案扩展了 ArrayBuffer 构造函数,使其接受一个额外的最大长度,允许缓冲区原地增长和缩小。类似地,SharedArrayBuffer 也被扩展为接受一个额外的最大长度,允许原地增长。

    动机和使用场景

    更好的内存管理

    目前,增长缓冲区需要分配新缓冲区并复制数据。这不仅效率低下,而且在 32 位系统上还会无谓地碎片化地址空间。

    与 WebAssembly memory.grow 能力同步

    WebAssembly 内存可以增长。每次增长时,wasm 都会提供一个新的 ArrayBuffer 实例并分离旧的实例。当发生增长时,任何指向 wasm 内存的 JS 侧“指针”都需要更新。这是一个未解决的问题,目前需要轮询,这非常慢:

    // 后端的缓冲区在 wasm 中每次增长时都会被分离!
    let U8 = new Uint8Array(WebAssembly.Memory.buffer);
    
    function derefPointerIntoWasmMemory(idx) {
      // 我们是否需要因为内存增长而重新创建 U8,导致旧缓冲区被分离?
      if (U8.length === 0) {
        U8 = new Uint8Array(WebAssembly.Memory.buffer);
      }
      doSomethingWith(U8[idx]);
    }

    这还促成了诸如在 wasm 的 JS API 中增加一个类似信号处理器的同步回调来处理增长事件的提案,但由于信号处理器重入性问题难以推理,这感觉并不理想。

    拥有可增长的 ArrayBuffer 和自动跟踪的 TypedArray 可以更干净地解决这个问题。

    WebGPU 缓冲区

    WebGPU 希望将相同的 ArrayBuffer 实例重新指向不同的后端缓冲区。这在动画期间对性能很重要,因为每帧动画多次重新创建 ArrayBuffer 实例会导致 GC 压力和暂停。

    拥有可调整大小的 ArrayBuffer 可以让 WebGPU 将重新指向解释为调整大小 + 覆盖写入。在底层,浏览器可以实现 WebGPU 提供的可调整大小的 ArrayBuffer,而无需在语言中实际添加可重新指向的 ArrayBuffer

    提案

    ArrayBuffer

    class ArrayBuffer {
      // 如果 options 参数不是具有 "maxByteLength" 属性的对象,
      // 则 ArrayBuffer 不能增长也不能缩小(现状)。
      // 否则它是可调整大小的。
      //
      // 可调整大小的 ArrayBuffer 可以增长到提供的
      // options.maxByteLength,也可以缩小。
      //
      // 如果 options 是具有 "maxByteLength" 属性的对象,
      // - 如果 maxByteLength 不是有限的,则抛出 RangeError。
      // - 如果 byteLength > maxByteLength,则抛出 RangeError。
      constructor(byteLength [, options ]);
    
      // 调整缓冲区大小。
      //
      // 增长被设计为原地实现,即预先保留地址空间,
      // 但页面在增长之前不会提交物理内存。
      //
      // 缩小也被设计为原地进行,仅更改长度,
      // 不进行重新分配。
      //
      // 如果 this 值不可调整大小,则抛出 TypeError。
      // 除非 0 <= newByteLength <= this.maxByteLength,否则抛出 RangeError。
      //
      // 可能抛出 OOM。
      resize(newByteLength);
    
      // 返回一个 *非* 可调整大小的 ArrayBuffer。
      slice(start, end);
    
      // 如果 `this` 值是可调整大小的 `ArrayBuffer`,返回 true,
      // 否则返回 false。
      //
      // 没有 setter。
      get resizable();
    
      // 如果可调整大小,返回构造时传入的最大字节长度。
      // 如果不可调整大小,返回字节长度。
      //
      // 没有 setter。
      get maxByteLength();
    
      // 没有 setter。
      get byteLength();
    }

    SharedArrayBuffer

    class SharedArrayBuffer {
      // 如果 options 参数不是具有 "maxByteLength" 属性的对象,
      // 则 SharedArrayBuffer 不能增长(现状)。
      // 否则它是可增长的。
      //
      // 可增长的 SharedArrayBuffer 只能增长到提供的
      // options.maxByteLength。
      //
      // 如果 options 是具有 "maxByteLength" 属性的对象,
      // - 如果 options.maxByteLength 不是有限的,则抛出 RangeError。
      // - 如果 byteLength > options.maxByteLength,则抛出 RangeError。
      constructor(byteLength [, options ]);
    
      // 增长缓冲区。
      //
      // 增长被设计为原地实现,即预先保留地址空间,
      // 但页面在增长之前不会提交物理内存。
      //
      // 可增长的 SharedArrayBuffer 不能缩小,因为允许共享内存缩小真的非常可怕。
      //
      // 如果 `this` 值不是可增长的 SharedArrayBuffer,则抛出 TypeError。
      // 除非 this.byteLength <= newByteLength <= this.maxByteLength,否则抛出 RangeError。
      //
      // 可能抛出 OOM。
      grow(newByteLength);
    
      // 返回一个 *非* 可增长的 SharedArrayBuffer。
      slice(start, end);
    
      // 如果 `this` 值是可增长的 SharedArrayBuffer,返回 true,
      // 否则返回 false。
      //
      // 没有 setter。
      get growable();
    
      // 如果可调整大小,返回构造时传入的最大字节长度。
      // 如果不可调整大小,返回字节长度。
      //
      // 没有 setter。
      get maxByteLength();
    
      // 没有 setter。
      get byteLength();
    }

    TypedArray 的修改

    扩展 TypedArray 以利用这些缓冲区。当 TypedArray 由可调整大小的缓冲区支持时,其字节偏移长度可能会在后端缓冲区被调整大小时自动改变。

    TypedArray (buffer, [, byteOffset [, length ] ] ) 构造函数修改如下:

    • 如果 buffer 是可调整大小的 ArrayBuffer 或可增长的 SharedArrayBuffer,如果 lengthundefined,则构造的 TA 会自动跟踪后端缓冲区的长度。

    TypedArray.prototype 上的 length getter 修改如下:

    • 如果此 TA 由可调整大小的 ArrayBuffer 或可增长的 SharedArrayBuffer 支持,并且正在自动跟踪后端缓冲区的长度,则返回 floor((缓冲区字节长度 - 字节偏移) / 元素大小)。
    • 如果此 TA 由可调整大小的 ArrayBuffer 支持,并且长度超出范围,则返回 0。

    所有访问 TypedArray 索引属性的方法和内部方法修改如下:

    • 如果此 TA 由可调整大小的 ArrayBuffer 支持,并且在后端缓冲区上的翻译字节索引超出范围,则返回 undefined。
    • 如果此 TA 由可调整大小的 ArrayBuffer 支持,并且翻译的字节长度超出范围,则返回 0。

    此更改概括了分离检查:如果后端缓冲区上的固定长度窗口由于调整大小而整体或部分超出范围,则将其视为分离的缓冲区。

    此概括的边界检查在由可调整大小的 ArrayBuffer 支持的 TypedArray 的每次索引访问时执行。

    可增长的 SharedArrayBuffer 只能增长,因此由可增长的 SharedArrayBuffer 支持的 TA 不能超出范围。

    示例:

    let rab = new ArrayBuffer(1024, { maxByteLength: 1024 ** 2 });
    // 0 偏移,自动长度
    let U32a = new Uint32Array(rab);
    assert(U32a.length === 256); // (1024 - 0) / 4
    rab.resize(1024 * 2);
    assert(U32a.length === 512); // (2048 - 0) / 4
    
    // 非 0 偏移,自动长度
    let U32b = new Uint32Array(rab, 256);
    assert(U32b.length === 448); // (2048 - 256) / 4
    rab.resize(1024);
    assert(U32b.length === 192); // (1024 - 256) / 4
    
    // 非 0 偏移,固定长度
    let U32c = new Uint32Array(rab, 128, 4);
    assert(U32c.length === 4);
    rab.resize(1024 * 2);
    assert(U32c.length === 4);
    
    // 如果调整大小使 TA 的任何可访问部分超出范围,则 TA 表现得像已被分离。
    rab.resize(256);
    assertThrows(() => U32b[0]);
    assert(U32b.length === 0);
    rab.resize(132);
    // U32c 可以寻址 rab[128] 到 rab[144]。部分超出范围仍然使其表现得像已被分离。
    assertThrows(() => U32c[0]);
    assert(U32c.length === 0);
    // 调整底层缓冲区大小可以将 TA 带回范围内。
    // 新内存被清零。
    rab.resize(1024);
    assert(U32b[0] === 0);
    assert(U32b.length === 192);

    实现

    • 可调整大小的 ArrayBuffer 和可增长的 SharedArrayBuffer 都被设计为直接缓冲区,其中虚拟内存为地址范围保留,但在需要之前不由物理内存支持。

    • 由可调整大小和可增长缓冲区支持的 TypedArray 具有更复杂但类似的分离检查逻辑。性能预期是这些 TypedArray 会比由固定大小缓冲区支持的 TypedArray 慢。然而,在紧密循环中,这种概括的检查与当前的分离检查一样可以提升。

    • 建议由可调整大小和可增长缓冲区支持的 TypedArray 与由固定大小缓冲区支持的 TypedArray 具有不同的隐藏类,以便维护安全敏感的快速路径。不幸的是,这使得使用点变为多态。由多态引起的性能下降需要进行基准测试。

    安全性

    ArrayBuffer 和 TypedArray 是 Web 浏览器最常见的攻击向量之一。可调整大小给平台增加了非零的安全风险,因为可调整大小缓冲区的边界检查代码中的错误可能被轻易利用。

    这种安全风险是该提案固有的,无法完全消除。本提案试图通过以下设计选择来缓解风险:

    • 现有对 ArrayBufferSharedArrayBuffer 构造函数的使用保持固定长度,不会改造为可调整大小。内部可调整大小的缓冲区类型可能具有不同的隐藏类,以便现有代码路径可以保持分离。
    • 使部分超出范围的 TypedArray 表现得像其缓冲区被分离,而不是自动更新长度。
    • 使原地实现始终可能,以限制数据指针的移动。

    常见问题与设计权衡

    transfer() 发生了什么?它以前在这里。

    它已被分离到其自己的提案中,以进一步探索设计空间。

    为什么不让所有 ArrayBuffer 都可调整大小?

    将单参数 ArrayBuffer 改造为可调整大小而不是添加显式的选择加入重载是困难的,原因涉及语言和实现两方面:

    1. TypedArray 视图具有特定的偏移和长度,需要更新。确定哪些 TA 的长度应该更新是很混乱的。如果增长,似乎需要考虑用户意图,那些显式提供长度的不应更新。如果缩小,似乎所有视图都需要更新。这不仅需要跟踪所有创建的视图,而且推理不清晰。
    2. 浏览器和 VM 已经围绕现有 TypedArray 和 ArrayBuffer 建立了经过实战考验的代码路径,因为它们是攻击浏览器的最常见方式。通过引入新类型,我们希望保持现有路径不变。否则,我们需要审计所有现有路径(由于 Web API 使用缓冲区,这些路径很多),以确保它们能够处理增长和缩小的可能性。这很可怕,并且可能成为安全漏洞的温床。

    为什么要求最大长度?

    该 API 被设计为可原地增长实现。非原地增长(即重新分配)语义对实现提出更多挑战,并增加攻击面。原地增长保证后端存储的数据指针不会移动。

    在底层,这意味着后端存储指针可以变得不可移动。注意,数据指针的不可移动性在 JS 内部不可观察。对于可调整大小的 ArrayBuffer,以重新分配方式实现增长和缩小是符合规范的,但可能不理想。对于可增长的 SharedArrayBuffer,由于内存模型约束,重新分配实现不太可能。

    为什么可增长的 SharedArrayBuffer 不能缩小?

    缩小共享内存很可怕,而且似乎会造成糟糕的体验。

    可增长的 SharedArrayBuffer 的增长如何与内存模型协同?

    增长一个可增长的 SharedArrayBuffer 会对缓冲区长度执行 SeqCst 访问。对长度的显式访问,例如 byteLength 访问器,以及内置函数如 slice,会对缓冲区长度执行 SeqCst 访问。作为索引访问一部分的边界检查,例如通过 ta[idx]Atomics.load(ta, idx),会对缓冲区长度执行 Unordered 访问。

    这与 WebAssembly 保持一致,并为边界检查代码生成提供更多优化机会。这也意味着其他线程在未同步到显式长度访问(例如读取 byteLength 访问器)时,不保证能看到增长后的长度。

    开放问题

    应该允许 resize(0) 吗?

    目前长度为 0 总是表示分离的缓冲区。resize(0) 有用例吗?如果允许,它是否意味着分离?还是缓冲区之后应该允许再次增长?

    https://github.com/tc39/proposal-resizablearraybuffer/issues/22 指出 ArrayBuffer(0) 已经存在。因此本提案允许 resize(0)

    历史与致谢

    感谢: