目次
この本をサポートしてください: 購入する (PDF, EPUB, MOBI) または 寄付する
(広告です。ブロックしないでください。)

6. 共有メモリとアトミック操作

ECMAScript 2017の機能「共有メモリとアトミック操作」は、Lars T. Hansen氏によって設計されました。この機能は、新しいコンストラクタ`SharedArrayBuffer`と、ヘルパー関数を持つ名前空間オブジェクト`Atomics`を導入します。本章では、その詳細を説明します。

6.1 並列性と並行性

最初に、似ているようで異なる2つの用語、「並列性」と「並行性」を明確にしておきましょう。多くの定義が存在しますが、ここでは以下のように使用します。

どちらも密接に関連していますが、同じではありません。

ただし、これらの用語を正確に使い分けるのは難しいので、通常は交換しても問題ありません。

6.1.1 並列性のモデル

並列性の2つのモデルは次のとおりです。

6.2 JS並列性の歴史

6.2.1 次のステップ: `SharedArrayBuffer`

次は何でしょうか?低レベルの並列性については、方向性は非常に明確です。SIMDとGPUを可能な限りサポートすることです。しかし、高レベルの並列性については、特にPJSの失敗後、状況ははるかに不明確です。

必要なのは、多くのアプローチを試してみて、JavaScriptに高レベルの並列性をもたらす最良の方法を見つけることです。拡張可能なWebマニフェストの原則に従い、「共有メモリとアトミック操作」 (別名「共有配列バッファ」) の提案は、高レベルの構成要素を実装するために使用できる低レベルプリミティブを提供することで、これを実現します。

6.3 共有配列バッファ

共有配列バッファは、高レベルの並行性抽象化のためのプリミティブな構成要素です。複数のワーカーとメインスレッド間で`SharedArrayBuffer`オブジェクトのバイトを共有できます (バッファは共有され、バイトにアクセスするには、Typed Arrayでラップします)。この種の共有には2つの利点があります。

6.3.1 共有配列バッファの作成と送信

// main.js

const worker = new Worker('worker.js');

// To be shared
const sharedBuffer = new SharedArrayBuffer( // (A)
    10 * Int32Array.BYTES_PER_ELEMENT); // 10 elements

// Share sharedBuffer with the worker
worker.postMessage({sharedBuffer}); // clone

// Local only
const sharedArray = new Int32Array(sharedBuffer); // (B)

共有配列バッファは、通常の配列バッファを作成するのと同じ方法で作成します。コンストラクタを呼び出し、バッファのサイズをバイト単位で指定します (A行)。ワーカーと共有するのはバッファです。独自のローカルで使用するには、通常、共有配列バッファをTyped Arrayでラップします (B行)。

**警告:** 共有配列バッファを共有する正しい方法はクローンを作成することですが、一部のエンジンではまだ古いバージョンのAPIが実装されており、転送する必要があります。

worker.postMessage({sharedBuffer}, [sharedBuffer]); // transfer (deprecated)

APIの最終バージョンでは、共有配列バッファを転送すると、アクセス権が失われます。

6.3.2 共有配列バッファの受信

ワーカーの実装は次のようになります。

// worker.js

self.addEventListener('message', function (event) {
    const {sharedBuffer} = event.data;
    const sharedArray = new Int32Array(sharedBuffer); // (A)

    // ···
});

最初に、送信された共有配列バッファを抽出し、Typed Arrayでラップします (A行)。これにより、ローカルで使用できるようになります。

6.4 アトミック操作: 共有データへの安全なアクセス

6.4.1 問題: 最適化により、ワーカー間でコードが予測不能になる

シングルスレッドでは、コンパイラはマルチスレッドコードを壊す最適化を行う可能性があります。

たとえば、次のコードを考えてみましょう。

while (sharedArray[0] === 123) ;

シングルスレッドでは、ループの実行中は`sharedArray[0]`の値は変わりません (`sharedArray`が、何らかの方法でパッチが適用されていない配列またはTyped Arrayの場合)。したがって、コードは次のように最適化できます。

const tmp = sharedArray[0];
while (tmp === 123) ;

ただし、マルチスレッド設定では、この最適化により、このパターンを使用して別のスレッドで行われた変更を待つことができなくなります。

別の例は次のコードです。

// main.js
sharedArray[1] = 11;
sharedArray[2] = 22;

シングルスレッドでは、これらの書き込み操作を並べ替えることができます。なぜなら、その間に何も読み取られないからです。複数のスレッドの場合、書き込みが特定の順序で行われることを期待すると問題が発生します。

// worker.js
while (sharedArray[2] !== 22) ;
console.log(sharedArray[1]); // 0 or 11

この種の最適化により、同じ共有配列バッファを操作する複数のワーカーの活動を同期させることは事実上不可能になります。

6.4.2 解決策: アトミック操作

この提案では、グローバル変数`Atomics`を提供しています。そのメソッドには、3つの主要なユースケースがあります。

6.4.2.1 ユースケース: 同期

`Atomics`メソッドを使用して、他のワーカーと同期できます。たとえば、次の2つの操作では、データの読み取りと書き込みを行うことができ、コンパイラによって並べ替えられることはありません。

アイデアは、通常の操作を使用してほとんどのデータを読み書きし、`Atomics`操作 (`load`、`store`など) によって読み書きが安全に行われるようにすることです。多くの場合、`Atomics`に基づいて実装されるロックなどのカスタム同期メカニズムを使用します。

これは、`Atomics`のおかげで常に機能する非常に簡単な例です (`sharedArray`の設定は省略しています)。

// main.js
console.log('notifying...');
Atomics.store(sharedArray, 0, 123);

// worker.js
while (Atomics.load(sharedArray, 0) !== 123) ;
console.log('notified');
6.4.2.2 ユースケース: 通知されるのを待つ

`while`ループを使用して通知を待つのはあまり効率的ではないため、`Atomics`には役立つ操作があります。

6.4.2.3 ユースケース: アトミック操作

いくつかの`Atomics`操作は算術演算を実行し、実行中に中断することはできません。これは同期に役立ちます。例えば

おおよそ、この操作は以下を実行します。

ta[index] += value;

6.4.3 問題: 断片化された値

共有メモリのもう1つの問題のある影響は、*断片化された値* (ガベージ) です。読み取り時に、中間値が表示される場合があります。新しい値がメモリに書き込まれる前の値でも、新しい値でもありません。

仕様書の「Tear-Free Reads」セクションでは、次の場合にのみティアがないと述べています。

言い換えれば、断片化された値は、同じ共有配列バッファが以下を介してアクセスされる場合に問題になります。

これらの場合に断片化された値を回避するには、`Atomics`を使用するか、同期します。

6.5 共有配列バッファの使用

6.5.1 共有配列バッファとJavaScriptの完了まで実行のセマンティクス

JavaScriptには、いわゆる*完了まで実行のセマンティクス*があります。すべての関数は、終了するまで別のスレッドによって中断されないことに依存できます。関数はトランザクションになり、操作対象のデータが中間状態にあることを誰にも見られることなく、完全なアルゴリズムを実行できます。

共有配列バッファは、完了まで実行 (RTC) を中断します。関数が処理しているデータは、関数の実行中に別のスレッドによって変更される可能性があります。ただし、コードはこのRTCの違反が発生するかどうかを完全に制御できます。共有配列バッファを使用しない場合、安全です。

これは、非同期関数が RTC に違反する方法と漠然と似ています。そこでは、キーワード await を介してブロッキング操作を選択できます。

6.5.2 共有アレイバッファと asm.js および WebAssembly

共有アレイバッファにより、emscripten は pthreads を asm.js にコンパイルできます。emscripten のドキュメントページを引用すると

[共有アレイバッファにより] Emscripten アプリケーションは、Web ワーカー間でメインメモリヒープを共有できます。これと、低レベルのアトミックおよび futex サポートのためのプリミティブにより、Emscripten は Pthreads(POSIX スレッド)API のサポートを実装できます。

つまり、マルチスレッドの C および C++ コードを asm.js にコンパイルできます。

WebAssembly にマルチスレッドを導入する最良の方法についての議論は進行中です。Web ワーカーは比較的重量級であることを考えると、WebAssembly が軽量スレッドを導入する可能性があります。スレッドがWebAssembly の将来のロードマップに含まれていることも確認できます。

6.5.3 整数以外のデータの共有

現時点では、整数(最大 32 ビット長)の配列のみを共有できます。つまり、他の種類のデータを共有する唯一の方法は、それらを整数としてエンコードすることです。役立つ可能性のあるツールには、次のものがあります。

最終的には、データ共有のための追加の、より高レベルのメカニズムが用意されるでしょう。そして、これらのメカニズムがどのようにあるべきかを理解するための実験は続けられます。

6.5.4 共有アレイバッファを使用するコードはどのくらい高速ですか?

Lars T. Hansen は、マンデルブロアルゴリズムの 2 つの実装を作成しました(彼の記事「JavaScript の新しい並列プリミティブを試してみる」に記載されており、オンラインで試すことができます):シリアルバージョンと複数の Web ワーカーを使用する並列バージョン。最大 4 つの Web ワーカー(したがってプロセッサコア)の場合、速度はほぼ直線的に向上し、1 秒あたり 6.9 フレーム(1 Web ワーカー)から 1 秒あたり 25.4 フレーム(4 Web ワーカー)になります。より多くの Web ワーカーはパフォーマンスをさらに向上させますが、それほど大きな向上ではありません。

Hansen は、速度の向上は印象的ですが、並列化にはコードがより複雑になるという犠牲が伴うと述べています。

6.6

より包括的な例を見てみましょう。そのコードは GitHub のリポジトリ shared-array-buffer-demo で入手できます。 オンラインで実行することもできます。

6.6.1 共有ロックの使用

メインスレッドでは、共有メモリをセットアップしてクローズドロックをエンコードし、それをワーカーに送信します(行 A)。ユーザーがクリックすると、ロックを開きます(行 B)。

// main.js

// Set up the shared memory
const sharedBuffer = new SharedArrayBuffer(
    1 * Int32Array.BYTES_PER_ELEMENT);
const sharedArray = new Int32Array(sharedBuffer);

// Set up the lock
Lock.initialize(sharedArray, 0);
const lock = new Lock(sharedArray, 0);
lock.lock(); // writes to sharedBuffer

worker.postMessage({sharedBuffer}); // (A)

document.getElementById('unlock').addEventListener(
    'click', event => {
        event.preventDefault();
        lock.unlock(); // (B)
    });

ワーカーでは、ロックのローカルバージョンをセットアップします(その状態は共有アレイバッファを介してメインスレッドと共有されます)。行 B では、ロックが解除されるまで待機します。行 A および C では、メインスレッドにテキストを送信し、メインスレッドはそれをページに表示します(その方法は前のコードフラグメントには示されていません)。つまり、これらの 2 行では、self.postMessage()console.log() のように使用しています。

// worker.js

self.addEventListener('message', function (event) {
    const {sharedBuffer} = event.data;
    const lock = new Lock(new Int32Array(sharedBuffer), 0);

    self.postMessage('Waiting for lock...'); // (A)
    lock.lock(); // (B) blocks!
    self.postMessage('Unlocked'); // (C)
});

行 B でロックを待機すると、ワーカー全体が停止することに注意してください。これは真のブロッキングであり、これまで JavaScript には存在しませんでした(非同期関数の await は近似です)。

6.6.2 共有ロックの実装

次に、SharedArrayBuffer に基づくLars T. Hansen による Lock 実装の ES6 化されたバージョンを見ていきます。

このセクションでは、(とりわけ)次の Atomics 関数が必要です

実装は、いくつかの定数とコンストラクタから始まります

const UNLOCKED = 0;
const LOCKED_NO_WAITERS = 1;
const LOCKED_POSSIBLE_WAITERS = 2;

// Number of shared Int32 locations needed by the lock.
const NUMINTS = 1;

class Lock {

    /**
     * @param iab an Int32Array wrapping a SharedArrayBuffer
     * @param ibase an index inside iab, leaving enough room for NUMINTS
     */
    constructor(iab, ibase) {
        // OMITTED: check parameters
        this.iab = iab;
        this.ibase = ibase;
    }

コンストラクタは主に、パラメータをインスタンスプロパティに格納します。

ロックするためのメソッドは次のとおりです。

/**
 * Acquire the lock, or block until we can. Locking is not recursive:
 * you must not hold the lock when calling this.
 */
lock() {
    const iab = this.iab;
    const stateIdx = this.ibase;
    var c;
    if ((c = Atomics.compareExchange(iab, stateIdx, // (A)
    UNLOCKED, LOCKED_NO_WAITERS)) !== UNLOCKED) {
        do {
            if (c === LOCKED_POSSIBLE_WAITERS // (B)
            || Atomics.compareExchange(iab, stateIdx,
            LOCKED_NO_WAITERS, LOCKED_POSSIBLE_WAITERS) !== UNLOCKED) {
                Atomics.wait(iab, stateIdx, // (C)
                    LOCKED_POSSIBLE_WAITERS, Number.POSITIVE_INFINITY);
            }
        } while ((c = Atomics.compareExchange(iab, stateIdx,
        UNLOCKED, LOCKED_POSSIBLE_WAITERS)) !== UNLOCKED);
    }
}

行 A では、現在の値が UNLOCKED の場合、ロックを LOCKED_NO_WAITERS に変更します。ロックが既にロックされている場合にのみ、then ブロックに入ります(この場合、compareExchange() は何も変更しませんでした)。

行 B(do-while ループ内)では、ロックが待機者を伴ってロックされているか、ロック解除されていないかを確認します。待機しようとしているため、現在の値が LOCKED_NO_WAITERS の場合、compareExchange()LOCKED_POSSIBLE_WAITERS にも切り替えます。

行 C では、ロック値が LOCKED_POSSIBLE_WAITERS の場合に待機します.最後のパラメータ Number.POSITIVE_INFINITY は、待機がタイムアウトしないことを意味します.

ウェイクアップ後、ロック解除されていない場合はループを続行します。ロックが UNLOCKED の場合、compareExchange()LOCKED_POSSIBLE_WAITERS にも切り替えます。 unlock() が一時的に UNLOCKED に設定してウェイクアップした後にこの値を復元する必要があるため、LOCKED_NO_WAITERS ではなく LOCKED_POSSIBLE_WAITERS を使用します。

ロック解除するためのメソッドは次のとおりです。

    /**
     * Unlock a lock that is held.  Anyone can unlock a lock that
     * is held; nobody can unlock a lock that is not held.
     */
    unlock() {
        const iab = this.iab;
        const stateIdx = this.ibase;
        var v0 = Atomics.sub(iab, stateIdx, 1); // A

        // Wake up a waiter if there are any
        if (v0 !== LOCKED_NO_WAITERS) {
            Atomics.store(iab, stateIdx, UNLOCKED);
            Atomics.wake(iab, stateIdx, 1);
        }
    }

    // ···
}

行 A では、v0 は 1 が減算される*前*の iab[stateIdx] の値を取得します。減算は、(たとえば)LOCKED_NO_WAITERS から UNLOCKED へ、LOCKED_POSSIBLE_WAITERS から LOCKED へと移行することを意味します。

値が以前に LOCKED_NO_WAITERS だった場合、現在は UNLOCKED であり、すべて問題ありません(ウェイクアップする人はいません)。

それ以外の場合、値は LOCKED_POSSIBLE_WAITERS または UNLOCKED のいずれかでした。前者の場合、現在はロック解除されており、誰かをウェイクアップする必要があります(通常は再びロックします)。後者の場合、減算によって作成された不正な値を修正する必要があり、wake() は単に何もしません。

6.6.3 例の結論

これにより、SharedArrayBuffer に基づくロックがどのように機能するかについての概要がわかります。マルチスレッドコードは、いつでも状況が変化する可能性があるため、記述するのが非常に難しいことに注意してください。ケーススタディ:lock.js は、Linux カーネルの futex 実装を文書化した論文に基づいています。そして、その論文のタイトルは「Futexes are tricky」(PDF)です。

共有アレイバッファを使用した並列プログラミングについてさらに詳しく知りたい場合は、synchronic.jsそれが基づいているドキュメント(PDF)をご覧ください。

6.7 共有メモリとアトミックの API

6.7.1 SharedArrayBuffer

コンストラクタ

静的プロパティ

インスタンスプロパティ

6.7.2 Atomics

Atomics 関数のメインオペランドは、Int8ArrayUint8ArrayInt16ArrayUint16ArrayInt32Array、または Uint32Array のインスタンスである必要があります。 SharedArrayBuffer をラップする必要があります。

すべての関数は、操作をアトミックに実行します。ストア操作の順序は固定されており、コンパイラや CPU によって並べ替えられることはありません。

6.7.2.1 ロードとストア
6.7.2.2 Typed Array 要素の簡単な変更

次の各関数は、指定されたインデックスにある Typed Array 要素を変更します。要素とパラメータに演算子を適用し、結果を要素に書き戻します。要素の*元の値*を返します。

6.7.2.3 待機とウェイクアップ

待機とウェイクアップには、パラメータ taInt32Array のインスタンスである必要があります。

6.7.2.4 その他

6.8 FAQ

6.8.1 どのブラウザが共有アレイバッファをサポートしていますか?

現時点では、次のブラウザを認識しています

6.9 参考文献

Shared Array Bufferと関連技術に関する詳細情報

並列処理に関連するその他のJavaScript技術

並列処理の背景

謝辞: この章のレビューと、SharedArrayBufferに関する質問への回答をしてくださったLars T. Hansen氏に深く感謝いたします。

次: 7. Object.entries()Object.values()