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/proposal-structs.md.
  • 简体中文
  • Structs: Fixed Layout Objects and Some Synchronization Primitives S2

    中文标题:结构体:固定布局对象和部分同步原语

    提案概览
    提案速览

    该提案引入了四个逻辑特性:非共享结构体(固定布局对象)、共享结构体(用于共享内存多线程)以及互斥锁和条件变量同步原语。它旨在使 JavaScript 能够支持高性能的共享内存应用,同时提供一种相比类具有更高性能上限的替代方案。

    Note

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

    JavaScript 结构体:固定布局对象和部分同步原语

    阶段:2

    作者:Shu-yu Guo (@syg)

    冠军:Shu-yu Guo (@syg), Ron Buckton (@rbuckton)

    简介

    本提案引入了四个(4)逻辑特性:

    • 结构体,或非共享结构体,它们是固定布局的对象。它们的行为类似于 class 实例,但具有更多有利于优化和分析的限制。
    • 共享结构体,它们是进一步受限的结构体,可以被多个代理共享并并行访问。它们支持共享内存多线程。
    • 互斥锁和条件变量,它们是用于同步访问共享内存的高级抽象。

    本提案旨在保持最小化,但本身仍然有用,无需后续提案。

    动机是:

    • 通过解锁共享内存多线程,使得在 JavaScript 和 Web 上编写高性能应用成为可能。
    • 为开发者提供一种 class 的替代方案,该方案更青睐更高的性能上限和静态可分析性,而不是灵活性。

    像 JavaScript 中的其他共享内存特性一样,它的表达能力很强,但正确使用的难度很高。本提案既作为向更高级、更易用(例如从头开始无数据竞争)的并行抽象迈进的渐进步骤,也为需要这种表达能力的专家程序员提供逃生舱。

    本提案对共享内存遵循的两个设计原则是:

    1. 看起来原子的语法应该是原子的。(例如,共享结构体上的点运算符应该只访问现有字段,并且不会撕裂。)
    2. 从共享对象到非共享对象没有引用。共享和非共享堆在概念上是分离的,直接引用只朝向一个方向。

    结构体

    非共享结构体是对 JavaScript class 的改进。它们像类一样是声明性的,但结构体实例的布局是固定的。

    结构体具有以下属性:

    • 它们以完整性级别密封的方式创建(参见 Object.seal)。换句话说,它们具有固定布局。具体来说,它们不能添加新属性。它们的 [[Prototype]] 的值不能改变。每个语法上声明的字段都是可写、可枚举且不可配置的。
    • 它们具有“一次性初始化”。一旦结构体实例对 JavaScript 代码可见,其所有字段(包括其超类的字段)都已初始化为 undefined
    • 结构体 constructor 方法在入口处有一个可用的 this 值,即一次性初始化的实例。因此,无法表达返回覆盖。仍然允许调用 super(),但不是必需的。
    • 它们只能扩展其他结构体。
    • 结构体构造函数本身也是密封的。
    • 结构体方法是非通用的。它们的 this 值必须是结构体或其子类的实例。

    结构体声明使用 struct 关键字,并与 class 声明共享语法。

    一些示例:

    struct Box {
      constructor(x) { this.x = x; }
      x;
    }
    
    let box = new Box(0);
    box.x = 42;  // x 已声明
    assertThrows(() => { box.y = 8.8; });        // 结构体是密封的
    assertThrows(() => { box.__proto__ = {}; }); // 结构体是密封的
    struct Point extends Box { // 允许,因为 Box 也是结构体
      constructor(x, y) {
        this.y = y;  // this 值立即可用
        super(x);    // 调用超类构造函数
        return {};   // 返回值被丢弃,没有返回覆盖
      }
    
      distance(other) {
        return Math.sqrt((other.x - this.x) ** 2 +
                         (other.y - this.y) ** 2);
      }
    
      y;
    }
    
    let p = new Point(1, 2);
    let fake = { x: 4, y: 5 };
    // 方法是非通用的
    assertThrows(() => Point.prototype.distance.call(fake, p));
    p.distance(fake); // 允许,接收者是 Point

    共享结构体

    共享结构体是具有更多受限行为的结构体,以便它们可以在不同代理之间共享。它们的字段可以被多个代理并行访问。

    共享结构体除了上述结构体列出的属性外,还具有以下额外属性:

    • 它们只能扩展其他共享结构体。
    • 它们具有 null 原型。
    • 它们不能包含实例方法。
    • 它们的实例可以无复制地传递给其他代理。
    • 它们的字段只能引用原始值或其他共享结构体。也就是说,它们不能指向非共享值。
    • 它们不能被冻结,因为那会改变它们的形状,而形状必须不可变才能适合共享。
    // main.js
    shared struct SharedBox {
      x;
    }
    
    let sharedBox = new SharedBox();
    let sharedBox2 = new SharedBox();
    
    sharedBox.x = 42;          // x 已声明,右侧是原始值
    sharedBox.x = sharedBox2;  // x 已声明,右侧是共享的
    assertThrows(() => { sharedBox.x = {}; }) // 右侧不是共享结构体
    
    // 可以编程测试一个值是否可以共享
    assert(Reflect.canBeShared(sharedBox2));
    assert(!Reflect.canBeShared({}));
    
    let worker = new Worker('worker.js');
    worker.postMessage({ sharedBox });
    
    sharedBox.x = "main";      // x 已声明,右侧是原始值
    console.log(sharedBox.x);
    // worker.js
    onmessage = function(e) {
      let sharedBox = e.data.sharedBox;
      sharedBox.x = "worker";  // x 已声明,右侧是原始值
      console.log(sharedBox.x);
    };

    上述程序允许打印任何交错结果:

    • main main
    • main worker
    • worker worker
    • worker main
    共享数组

    共享数组是固定长度的数组,可以在代理之间共享。它们是共享结构体的特例。共享数组没有特殊语法。它们有一个只读的 length 属性。

    let sharedArray = new SharedArray(100);
    assert(sharedArray.length === 100);
    for (i = 0; i < sharedArray.length; i++) {
      // 像共享结构体一样,所有元素初始化为 undefined
      assert(sharedArray[i] === undefined);
    }
    
    let worker = new Worker('worker_array.js');
    worker.postMessage({ sharedArray });
    
    sharedArray[0] = "main";
    console.log(sharedArray[0]);
    // worker_array.js
    onmessage = function(e) {
      let sharedArray = e.data.sharedArray;
      sharedArray[0] = "worker";
      console.log(sharedArray[0]);
    };

    与共享结构体示例一样,上述程序也允许打印任何交错结果:

    • main main
    • main worker
    • worker worker
    • worker main
    内存模型

    默认情况下,共享结构体上的字段访问是无序的。顺序一致访问通过以下对现有 Atomics 静态方法的新重载执行。

    以下伪代码描述了新重载。

    class Atomics {
      // ... 现有内容
    
      // 在 struct[fieldName] 上执行 seq-cst 加载。
      static load(struct, fieldName);
    
      // 在 struct[fieldName] 上执行 value 的 seq-cst 存储。
      static store(struct, fieldName, value);
    
      // 在 struct[fieldName] 上使用 newValue 执行 seq-cst 交换。
      //
      // newValue 必须是原始值或共享结构体。
      //
      // 返回旧值。
      static exchange(struct, fieldName, newValue);
    
      // 在 struct[fieldName] 上执行 seq-cst 比较交换。
      //
      // 如果 struct[fieldName] 中的现有值等于 expected,则将其替换为
      // replacement。
      //
      // replacement 必须是原始值或共享结构体。
      //
      // 返回替换前 struct[fieldName] 中的值,
      // 无论值是否被交换。
      static compareExchange(struct, fieldName, expected, replacement);
    }

    共享结构体字段访问永远不会撕裂,无论内存顺序如何。也就是说,读取共享结构体字段时,只会看到共享结构体字段的一次写入,绝不会看到多次写入的混合。

    互斥锁和条件变量

    需要更高级的同步原语来帮助编写线程安全代码。本提案添加了 Atomics.MutexAtomics.Condition

    Atomics.Mutex

    Atomics.Mutex 是非递归互斥锁。互斥锁本身是没有任何字段的共享结构体。它通过 Atomics.Mutex 上的静态方法使用。(如果每 Realm 原型成为本提案的一部分,这些静态方法将变成原型方法。)

    以下伪代码描述了 API。

    shared struct Atomics.Mutex {
      // 创建一个新的互斥锁。
      constructor();
    
      // 尝试在 mutex 上获取锁。
      //
      // 如果互斥锁已被锁定,则阻塞当前代理,直到获取锁。
      //
      // 如果 unlockToken 不是 undefined,则它必须是一个空的 UnlockToken。
      //
      // 如果 unlockToken 是 undefined,则返回一个新的 UnlockToken。
      // 否则返回传入的 unlockToken。
      static lock(mutex: Mutex,
                  unlockToken: UnlockToken|undefined = undefined)
                 : UnlockedToken;
    
      // 尝试在 mutex 上获取锁。
      //
      // 如果 timeout 不是 Infinity,则只阻塞 timeout 毫秒。如果
      // 操作超时而未获取锁,则返回 null。
      //
      // 如果 timeout 是 0,则如果无法获取锁,则立即返回而不阻塞。
      //
      // unlockToken 的行为与 lock 方法中的相同。
      static lockIfAvailable(mutex: Mutex,
                             timeout: Number,
                             unlockToken: UnlockToken|undefined = undefined)
                            : UnlockToken|null;
    }

    互斥锁只能通过 UnlockToken 解锁,UnlockToken 是解锁能力。这些令牌是普通对象,不是共享结构体。对于高性能应用,应用可以分配一个空的 UnlockToken 并重用它。此 API 的灵感来自 Rust,旨在最小化误用(例如双重解锁)。

    class Atomics.Mutex.UnlockToken {
      // 创建一个空的 UnlockToken。
      constructor();
    
      // 如果此令牌非空,则返回 true。
      get locked(): bool;
    
      // 如果此令牌非空,则解锁底层互斥锁并返回 true。
      // 否则返回 false。
      unlock(): bool;
    
      // 类似于 unlock,但返回 undefined 而不是 bool。
      [Symbol.dispose](): undefined;
    }

    例如,

    shared struct MicrosoftSharePoint {
      x;
      y;
      mutex;
    }
    
    let point = new MicrosoftSharePoint();
    point.mutex = new Atomics.Mutex();
    
    let worker = new Worker('worker_mutex.js');
    worker.postMessage({ point });
    
    // 假设此代理可以阻塞
    {
      using lock = Atomics.Mutex.lock(point.mutex);
      point.x = "main";
      point.y = "main";
    }
    
    {
      using lock = Atomics.Mutex.lock(point.mutex);
      console.log(point.x, point.y);
    }
    // worker_mutex.js
    onmessage = function(e) {
      let point = e.data.point;
      {
        using lock = Atomics.Mutex.lock(point.mutex);
        point.x = "worker";
        point.y = "worker";
      }
    };

    上述程序打印以下之一:

    • main main
    • worker worker

    也就是说,因为在锁下访问了 xy 字段,所以没有代理能观察到 main 和 worker 值的混合。

    Atomics.Condition

    Atomics.Condition 是条件变量。它是一个没有字段的共享结构体。它通过 Atomics.Condition 上的静态方法使用。(如果每 Realm 原型成为本提案的一部分,这些静态方法将变成原型方法。)

    以下伪代码描述了 API。

    shared struct Atomics.Condition {
      // 创建一个新的条件变量。
      constructor();
    
      // 原子地解锁 unlockToken 并阻塞当前代理,直到 cv 被通知。
      //
      // unlockToken 必须非空。
      //
      // 当代理被恢复时,重新获取 unlockToken 中互斥锁的底层锁,必要时阻塞。
      //
      // 返回 undefined。
      static wait(cv: Condition,
                  unlockToken: UnlockToken): undefined
    
      // 原子地解锁 unlockToken 并阻塞当前代理,直到 cv 被通知或超时。
      //
      // unlockToken 必须非空。
      //
      // timeout 以毫秒为单位。
      //
      // 如果 predicate 不是 undefined,则此方法在 predicate 返回 true 后返回,或
      // 超时后返回。如果超时,则返回 predicate 的最终返回值。每当 predicate 执行时,
      // 会获取 unlockToken 底层互斥锁的锁。
      //
      // 如果 predicate 是 undefined,则如果等待被通知返回 true,超时返回 false。
      static waitFor(cv: Condition,
                     unlockToken: UnlockToken,
                     timeout: Number,
                     predicate: Callable|undefined): bool
    
      // 通知在条件变量 cv 上等待的 count 个等待者。
      //
      // 返回被通知的等待者数量。
      static notify(cv: Condition,
                    count: Number = Infinity)
                   : Number;
    }

    开放问题

    向共享结构体附加方法

    因为函数是深度不可共享的,共享结构体目前没有方法。然而,这是一个严重的人体工程学痛点。它也与封装相悖,封装可能带来实际的危害,鼓励更多线程不安全的代码。当前正在探索支持方法的方案,正在讨论中,旨在为共享结构体提供以下附加特性:

    1. 一个每 Realm 的原型对象,它是一个普通对象,因此可以包含方法。这相当于将共享结构体上的 [[Prototype]] 内部字段变为线程本地存储。
    2. 一种关联机制,用于关联不同代理上相同“逻辑”共享结构体声明的求值。

    这是一个涉及面广的话题,有专门的文档。参见 ATTACHING-BEHAVIOR.md

    未来工作

    异步锁定和等待

    异步锁定计划为后续工作,但不在本提案范围内。参见 ASYNC-LOCKING-WAITING.md 了解 lockAsyncwaitAsync

    WasmGC 互操作性

    WasmGC 提案 向 Wasm 添加了固定布局、垃圾回收对象。虽然这些对象的类型系统细节尚未确定,但与 JavaScript 的互操作性是一个要求。

    WasmGC 对象具有不透明的存储,并且不被线性内存别名化,因此它们不能像今天所有 Wasm 内存通过 ArrayBuffer 暴露那样暴露。我们提议将结构体作为 WasmGC 对象在 JS 中的反映。

    暴露给 JS 的 WasmGC 对象应该与结构体表现相同,除了需要 WasmGC 要求的额外类型检查,JS 结构体不要求。JS 结构体也是将 JS 反映为 WasmGC 对象的好基础,但这目前留作未来工作,因为它可能需要类型化字段扩展才有价值。

    此外,WasmGC 本身最终将支持多线程。我们应该保持 JavaScript 和 Wasm 之间单一的内存模型,就像今天一样,即使有更高级的对象抽象。

    超出范围

    值语义、不可变性和运算符重载

    本提案不打算探索具有值语义的对象空间,包括不可变性和运算符重载。结构体具有与其他对象相同的身份,并设计为像其他对象一样使用。值语义是一个足够的偏离,可能更适合通过其他专注于该空间的提案来解决。

    复杂的类型系统

    本提案不打算探索复杂的类型和运行时保护系统。它甚至比最接近的精神祖先 Typed Objects 提案 更小,因为我们不提出尺寸字段的整数类型。(类型化和尺寸字段留作未来工作。)

    二进制数据覆盖视图

    本提案不打算探索在 ArrayBuffer 中覆盖结构化视图的空间。这是出于对 WasmGC 集成的需求,而 WasmGC 对象同样是透明的。

    结构化覆盖本质上是关于内存别名化的,我们认为这属于不同的问题域,有显著的性能缺点,并且今天在用户空间中已经可解决。例如,参见 buffer-backed objects

    值得注意的是,在 JavaScript 中的结构化覆盖本质上涉及每代理分配非共享包装器。如果应用具有复杂结构的共享状态,例如大型对象图,通过每代理的包装器集合重建该结构会抵消共享内存的内存使用优势。结构化覆盖适用于共享状态本身结构简单的特定应用架构,如字节缓冲区。

    实现指南

    不可变形状

    结构体预先声明固定布局。引擎应该为这样的对象创建不可变形状。优化器可以优化字段访问,而无需担心去优化。

    共享结构体:确保字段是指针宽度并对齐

    共享结构体应该存储字段,使得底层架构可以执行原子存储和原子加载。这通常意味着字段至少是指针宽度并已对齐。

    共享结构体:字符串将很困难

    除了字符串,共享原始值在引擎中通常微不足道,特别是对于 NaN 装箱实现。

    生产引擎中的字符串有原地修改以转换表示形式,以便针对不同的用例进行优化(例如绳索、切片、规范化等)。共享字符串可能是实现中最具挑战性的部分。

    可以通过共享时复制来支持共享字符串,但可能太慢。如果可能,上述原地修改的无锁实现是理想的。

    同步原语:它们必须移动 GC 安全

    生产引擎使用移动垃圾回收器,例如分代回收器和整理回收器。如果 JS 同步原语在底层实现为 OS 级别的同步原语,那些原语很可能依赖于内存中不变的地址,并且_不是_移动 GC 安全的。

    引擎可以选择固定这些对象并使其不可移动。

    引擎也可以选择完全在用户空间中实现同步原语。例如,WebKit 的 ParkingLot](https://webkit.org/blog/6161/locking-in-webkit/) 是 Linux futex 的用户空间实现。这可能还有其他好处,例如改进的性能和可调性。