ECMAScript中的原子对象(Atomics object)实际有什么用途?

16

ECMAScript规范在第24.4节中定义了Atomics对象

在所有全局对象中,这对我来说是最不常见的,因为我直到阅读它的规范之前都不知道它的存在,而且Google也没有太多有关它的参考资料(或者也许名称太一般化,一切都淹没了?)。

根据其官方定义

Atomics对象提供操作共享内存数组单元的原子函数以及允许代理等待和分派基本事件的函数

因此,它具有处理低级内存和调节对其访问的方法的对象形式。并且其公共接口使我推测它的用途。但是对于最终用户来说,这种对象的实际用途是什么?为什么它是公开的?是否有一些例子可以说明它的用处?

谢谢


1
原子操作是ES8的一部分,而不是ES6。 - Bergi
什么是最终用户? - Bergi
2
可以阅读http://2ality.com/2017/01/shared-array-buffer.html和https://tc39.github.io/ecmascript_sharedmem/shmem.html#intro了解更多关于编程的内容。与其搜索“原子性”,不如尝试使用“共享内存”这个词。 - Bergi
@Bergi 我知道!但是我没有足够的分数来创建标签,所以我使用了那个。对于最终用户,我指的是制作真实世界应用程序的开发人员。 - Christian Vincenzo Traina
@Bergi 謝謝你提供的鏈接! - Christian Vincenzo Traina
5个回答

16

原子操作用于同步共享内存的WebWorker。它们使得对SharedArrayBuffer中的内存访问以线程安全的方式进行。共享内存使得多线程变得更加有用,因为:

  • 不需要复制数据即可将其传递给线程。
  • 线程可以在不使用事件循环的情况下进行通信。
  • 线程之间的通信速度更快。

示例:

var arr = new SharedArrayBuffer(1024);

// send a reference to the memory to any number of webworkers
workers.forEach(worker => worker.postMessage(arr));

// Normally, simultaneous access to the memory from multiple threads 
// (where at least one access is a write)
// is not safe, but the Atomics methods are thread-safe.
// This adds 2 to element 0 of arr.
Atomics.add(arr, 0, 2)

在主要浏览器上先前启用了SharedArrayBuffer,但在Spectre安全漏洞之后被禁用,因为共享内存允许实现纳秒级精度计时器,这可能会利用spectre漏洞。

为了使其更安全,浏览器需要为每个域运行不同的进程。Chrome从版本67开始执行此操作,并在版本68中重新启用了共享内存。


9
原子操作是一组“全有或全无”的小操作。下面我们来看看。
let i=0;

i++

i++ 实际上包含三个步骤:

  1. 读取当前 i 的值
  2. i 加 1
  3. 返回旧值

如果有两个线程执行相同的操作会发生什么?它们都可以同时读取同一个值 1 并在完全相同的时间内将其递增。

但是 JavaScript 不是单线程的吗?

是的!JavaScript 确实是单线程的,但浏览器/Node 允许在并行中使用多个 JavaScript 运行时(Worker Threads、Web Workers)。

Chrome 和 Node(基于 v8)为每个线程创建了一个Isolate,它们都在自己的 context 中运行。

它们唯一可以共享内存的方式是通过 ArrayBuffer / SharedArrayBuffer

下面程序的输出结果是什么?

在 node > =10 上运行(您可能需要使用 --experimental_worker 标志)

node example.js

const { isMainThread, Worker, workerData } = require('worker_threads');

if (isMainThread) {
  // main thread, create shared memory to share between threads
  const shm = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);

  process.on('exit', () => {
    // print final counter
    const res = new Int32Array(shm);
    console.log(res[0]); // expected 5 * 500,000 = 2,500,000
  });
  Array(5).fill(null).map(() => new Worker(__filename, { workerData: shm }));
} else {
  // worker thread, iteratres 500k and doing i++
  const arr = new Int32Array(workerData);
  for (let i = 0; i < 500000; i++) {
    arr[i]++;
  }
}

输出结果可能是2,500,000,但我们并不确定,在大多数情况下,它不会是2.5M,实际上,你获得相同输出的机会非常低,作为程序员,我们肯定不喜欢我们不知道最终结果的代码。
这是竞争条件的一个例子,其中n个线程互相竞争,并且没有同步。
现在出现了原子操作,它允许我们从开始到结束进行算术运算。
让我们稍微改变一下程序,然后运行:
const { isMainThread, Worker, workerData } = require('worker_threads');


if (isMainThread) {
    const shm = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
    process.on('exit', () => {
        const res = new Int32Array(shm);
        console.log(res[0]); // expected 5 * 500,000 = 2,500,000
    });
    Array(5).fill(null).map(() => new Worker(__filename, { workerData: shm }));
} else {
    const arr = new Int32Array(workerData);
    for (let i = 0; i < 500000; i++) {
        Atomics.add(arr, 0, 1);
    }
}

现在输出将始终为2,500,000

奖励,使用原子操作的互斥锁

有时候,我们希望只有一个线程可以同时访问某个操作,让我们看一下下面的类。

class Mutex {

    /**
     * 
     * @param {Mutex} mutex 
     * @param {Int32Array} resource 
     * @param {number} onceFlagCell 
     * @param {(done)=>void} cb
     */
    static once(mutex, resource, onceFlagCell, cb) {
        if (Atomics.load(resource, onceFlagCell) === 1) {
            return;
        }
        mutex.lock();
        // maybe someone already flagged it
        if (Atomics.load(resource, onceFlagCell) === 1) {
            mutex.unlock();
            return;
        }
        cb(() => {
            Atomics.store(resource, onceFlagCell, 1);
            mutex.unlock();
        });
    }
    /**
     * 
     * @param {Int32Array} resource 
     * @param {number} cell 
     */
    constructor(resource, cell) {
        this.resource = resource;
        this.cell = cell;
        this.lockAcquired = false;
    }

    /**
     * locks the mutex
     */
    lock() {
        if (this.lockAcquired) {
            console.warn('you already acquired the lock you stupid');
            return;
        }
        const { resource, cell } = this;
        while (true) {
            // lock is already acquired, wait
            if (Atomics.load(resource, cell) > 0) {
                while ('ok' !== Atomics.wait(resource, cell, 0));
            }
            const countOfAcquiresBeforeMe = Atomics.add(resource, cell, 1);
            // someone was faster than me, try again later
            if (countOfAcquiresBeforeMe >= 1) {
                Atomics.sub(resource, cell, 1);
                continue;
            }
            this.lockAcquired = true;
            return;
        }
    }

    /**
     * unlocks the mutex
     */
    unlock() {
        if (!this.lockAcquired) {
            console.warn('you didn\'t acquire the lock you stupid');
            return;
        }
        Atomics.sub(this.resource, this.cell, 1);
        Atomics.notify(this.resource, this.cell, 1);
        this.lockAcquired = false;
    }
}

现在,您需要分配SharedArrayBuffer并在所有线程之间共享它们,并确保每次只有1个线程进入critical section
使用node > 10运行 node --experimental_worker example.js
const { isMainThread, Worker, workerData, threadId } = require('worker_threads');


const { promisify } = require('util');
const doSomethingFakeThatTakesTimeAndShouldBeAtomic = promisify(setTimeout);

if (isMainThread) {
    const shm = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
    Array(5).fill(null).map(() => new Worker(__filename, { workerData: shm }));
} else {
    (async () => {
        const arr = new Int32Array(workerData);
        const mutex = new Mutex(arr, 0);
        mutex.lock();
        console.log(`[${threadId}] ${new Date().toISOString()}`);
        await doSomethingFakeThatTakesTimeAndShouldBeAtomic(1000);
        mutex.unlock();
    })();
}

7
如果您需要进行一些复杂的计算,您可能需要使用WebWorkers,以便在重型任务并行处理时,您的主脚本可以继续工作。 Atomics解决的问题是WebWorkers之间如何轻松、快速、可靠地通信。您可以在这里阅读有关ArrayBuffer、SharedArrayBuffer、Atomics以及如何利用它们的信息。 如果:
  • 您正在创建简单的东西(例如商店、论坛等)
那么您不必担心它。 如果:
  • 如果你想要创建一些复杂的或者占用内存较多的东西(例如 figma 或者 google drive
  • 如果你想要使用 WebAssembly 或者 webgl 并且想要优化性能
  • 如果你需要创建一些复杂的 Node.js 模块
  • 或者如果你正在通过 Electron 创建一个像 Skype 或者 Discord 这样的复杂应用程序

4
除了Arseniy-II和Simon Paris所说的,原子操作还可以在将JavaScript引擎嵌入到某些主机应用程序中时使用(以实现脚本)。这样,人们可以直接从不同的并发线程同时访问共享内存,无论是从JS还是从C/C++或其他你的主机应用程序所编写的语言,都可以不涉及JavaScript API来进行访问。

2
我用Web Worker和SharedArrayBuffer编写了一个脚本来展示Atomics的使用方法:
<!DOCTYPE html><html><head></head><body><script>
   var arr = new SharedArrayBuffer(256);
   new Int16Array(arr)[0]=0;
   var workers=[];
   for (let i=0; i<1000; i++) workers.push(new Worker('worker.js'));
   workers.forEach(w => w.postMessage(new Int16Array(arr)));
</script></body></html>

然后使用独立的文件 worker.js:
// worker.js
onmessage = function(e) {
    e.data[0]++;                 // last line is 981 only? wth?!
    //Atomics.add(e.data,0,1);   // last line is exactly 1000. right...
    console.log(e.data[0]);
}

正如您所见,如果没有 Atomics 提供的互斥锁保证,有时加法将不会被正确执行。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接