ECMAScript 2017の機能「共有メモリとアトミック操作」は、Lars T. Hansen氏によって設計されました。この機能は、新しいコンストラクタ`SharedArrayBuffer`と、ヘルパー関数を持つ名前空間オブジェクト`Atomics`を導入します。本章では、その詳細を説明します。
最初に、似ているようで異なる2つの用語、「並列性」と「並行性」を明確にしておきましょう。多くの定義が存在しますが、ここでは以下のように使用します。
どちらも密接に関連していますが、同じではありません。
ただし、これらの用語を正確に使い分けるのは難しいので、通常は交換しても問題ありません。
並列性の2つのモデルは次のとおりです。
次は何でしょうか?低レベルの並列性については、方向性は非常に明確です。SIMDとGPUを可能な限りサポートすることです。しかし、高レベルの並列性については、特にPJSの失敗後、状況ははるかに不明確です。
必要なのは、多くのアプローチを試してみて、JavaScriptに高レベルの並列性をもたらす最良の方法を見つけることです。拡張可能なWebマニフェストの原則に従い、「共有メモリとアトミック操作」 (別名「共有配列バッファ」) の提案は、高レベルの構成要素を実装するために使用できる低レベルプリミティブを提供することで、これを実現します。
共有配列バッファは、高レベルの並行性抽象化のためのプリミティブな構成要素です。複数のワーカーとメインスレッド間で`SharedArrayBuffer`オブジェクトのバイトを共有できます (バッファは共有され、バイトにアクセスするには、Typed Arrayでラップします)。この種の共有には2つの利点があります。
// 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の最終バージョンでは、共有配列バッファを転送すると、アクセス権が失われます。
ワーカーの実装は次のようになります。
// worker.js
self.addEventListener('message', function (event) {
const {sharedBuffer} = event.data;
const sharedArray = new Int32Array(sharedBuffer); // (A)
// ···
});
最初に、送信された共有配列バッファを抽出し、Typed Arrayでラップします (A行)。これにより、ローカルで使用できるようになります。
シングルスレッドでは、コンパイラはマルチスレッドコードを壊す最適化を行う可能性があります。
たとえば、次のコードを考えてみましょう。
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
この種の最適化により、同じ共有配列バッファを操作する複数のワーカーの活動を同期させることは事実上不可能になります。
この提案では、グローバル変数`Atomics`を提供しています。そのメソッドには、3つの主要なユースケースがあります。
`Atomics`メソッドを使用して、他のワーカーと同期できます。たとえば、次の2つの操作では、データの読み取りと書き込みを行うことができ、コンパイラによって並べ替えられることはありません。
Atomics.load(ta : TypedArray<T>, index) : TAtomics.store(ta : TypedArray<T>, index, value : T) : Tアイデアは、通常の操作を使用してほとんどのデータを読み書きし、`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');
`while`ループを使用して通知を待つのはあまり効率的ではないため、`Atomics`には役立つ操作があります。
Atomics.wait(ta: Int32Array, index, value, timeout)Atomics.wake(ta : Int32Array, index, count)いくつかの`Atomics`操作は算術演算を実行し、実行中に中断することはできません。これは同期に役立ちます。例えば
Atomics.add(ta : TypedArray<T>, index, value) : Tおおよそ、この操作は以下を実行します。
ta[index] += value;
共有メモリのもう1つの問題のある影響は、*断片化された値* (ガベージ) です。読み取り時に、中間値が表示される場合があります。新しい値がメモリに書き込まれる前の値でも、新しい値でもありません。
仕様書の「Tear-Free Reads」セクションでは、次の場合にのみティアがないと述べています。
sharedArray.byteOffset % sharedArray.BYTES_PER_ELEMENT === 0
言い換えれば、断片化された値は、同じ共有配列バッファが以下を介してアクセスされる場合に問題になります。
これらの場合に断片化された値を回避するには、`Atomics`を使用するか、同期します。
JavaScriptには、いわゆる*完了まで実行のセマンティクス*があります。すべての関数は、終了するまで別のスレッドによって中断されないことに依存できます。関数はトランザクションになり、操作対象のデータが中間状態にあることを誰にも見られることなく、完全なアルゴリズムを実行できます。
共有配列バッファは、完了まで実行 (RTC) を中断します。関数が処理しているデータは、関数の実行中に別のスレッドによって変更される可能性があります。ただし、コードはこのRTCの違反が発生するかどうかを完全に制御できます。共有配列バッファを使用しない場合、安全です。
これは、非同期関数が RTC に違反する方法と漠然と似ています。そこでは、キーワード await を介してブロッキング操作を選択できます。
共有アレイバッファにより、emscripten は pthreads を asm.js にコンパイルできます。emscripten のドキュメントページを引用すると
[共有アレイバッファにより] Emscripten アプリケーションは、Web ワーカー間でメインメモリヒープを共有できます。これと、低レベルのアトミックおよび futex サポートのためのプリミティブにより、Emscripten は Pthreads(POSIX スレッド)API のサポートを実装できます。
つまり、マルチスレッドの C および C++ コードを asm.js にコンパイルできます。
WebAssembly にマルチスレッドを導入する最良の方法についての議論は進行中です。Web ワーカーは比較的重量級であることを考えると、WebAssembly が軽量スレッドを導入する可能性があります。スレッドがWebAssembly の将来のロードマップに含まれていることも確認できます。
現時点では、整数(最大 32 ビット長)の配列のみを共有できます。つまり、他の種類のデータを共有する唯一の方法は、それらを整数としてエンコードすることです。役立つ可能性のあるツールには、次のものがあります。
TextEncoder および TextDecoder: 前者は文字列を Uint8Array のインスタンスに変換します。後者はその逆を行います。ArrayBuffer および SharedArrayBuffer)に複雑なデータ構造(構造体、クラス、配列)を格納する方法で JavaScript を拡張します。 JavaScript+FlatJS はプレーンな JavaScript にコンパイルされます。 JavaScript の方言(TypeScript など)がサポートされています。最終的には、データ共有のための追加の、より高レベルのメカニズムが用意されるでしょう。そして、これらのメカニズムがどのようにあるべきかを理解するための実験は続けられます。
Lars T. Hansen は、マンデルブロアルゴリズムの 2 つの実装を作成しました(彼の記事「JavaScript の新しい並列プリミティブを試してみる」に記載されており、オンラインで試すことができます):シリアルバージョンと複数の Web ワーカーを使用する並列バージョン。最大 4 つの Web ワーカー(したがってプロセッサコア)の場合、速度はほぼ直線的に向上し、1 秒あたり 6.9 フレーム(1 Web ワーカー)から 1 秒あたり 25.4 フレーム(4 Web ワーカー)になります。より多くの Web ワーカーはパフォーマンスをさらに向上させますが、それほど大きな向上ではありません。
Hansen は、速度の向上は印象的ですが、並列化にはコードがより複雑になるという犠牲が伴うと述べています。
より包括的な例を見てみましょう。そのコードは GitHub のリポジトリ shared-array-buffer-demo で入手できます。 オンラインで実行することもできます。
メインスレッドでは、共有メモリをセットアップしてクローズドロックをエンコードし、それをワーカーに送信します(行 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 は近似です)。
次に、SharedArrayBuffer に基づくLars T. Hansen による Lock 実装の ES6 化されたバージョンを見ていきます。
このセクションでは、(とりわけ)次の Atomics 関数が必要です
Atomics.compareExchange(ta : TypedArray<T>, index, expectedValue, replacementValue) : Tta の index における現在の要素が expectedValue の場合、それを replacementValue に置き換えます。 index における以前の(または変更されていない)要素を返します。実装は、いくつかの定数とコンストラクタから始まります
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() は単に何もしません。
これにより、SharedArrayBuffer に基づくロックがどのように機能するかについての概要がわかります。マルチスレッドコードは、いつでも状況が変化する可能性があるため、記述するのが非常に難しいことに注意してください。ケーススタディ:lock.js は、Linux カーネルの futex 実装を文書化した論文に基づいています。そして、その論文のタイトルは「Futexes are tricky」(PDF)です。
共有アレイバッファを使用した並列プログラミングについてさらに詳しく知りたい場合は、synchronic.js とそれが基づいているドキュメント(PDF)をご覧ください。
SharedArrayBuffer コンストラクタ
new SharedArrayBuffer(length)length バイトのバッファを作成します。静的プロパティ
get SharedArrayBuffer[Symbol.species]this を返します。 slice() が何を返すかを制御するためにオーバーライドします。インスタンスプロパティ
get SharedArrayBuffer.prototype.byteLength()SharedArrayBuffer.prototype.slice(start, end)this.constructor[Symbol.species] の新しいインスタンスを作成し、start から(含む)end まで(含まない)のインデックスにあるバイトでそれを埋めます。Atomics Atomics 関数のメインオペランドは、Int8Array、Uint8Array、Int16Array、Uint16Array、Int32Array、または Uint32Array のインスタンスである必要があります。 SharedArrayBuffer をラップする必要があります。
すべての関数は、操作をアトミックに実行します。ストア操作の順序は固定されており、コンパイラや CPU によって並べ替えられることはありません。
Atomics.load(ta : TypedArray<T>, index) : Tta の index における要素を読み取り、返します。Atomics.store(ta : TypedArray<T>, index, value : T) : Tvalue を ta の index に書き込み、value を返します。Atomics.exchange(ta : TypedArray<T>, index, value : T) : Tta の index における要素を value に設定し、そのインデックスにおける以前の値を返します。Atomics.compareExchange(ta : TypedArray<T>, index, expectedValue, replacementValue) : Tta の index における現在の要素が expectedValue の場合、それを replacementValue に置き換えます。 index における以前の(または変更されていない)要素を返します。次の各関数は、指定されたインデックスにある Typed Array 要素を変更します。要素とパラメータに演算子を適用し、結果を要素に書き戻します。要素の*元の値*を返します。
Atomics.add(ta : TypedArray<T>, index, value) : Tta[index] += value を実行し、ta[index] の元の値を返します。Atomics.sub(ta : TypedArray<T>, index, value) : Tta[index] -= value を実行し、ta[index] の元の値を返します。Atomics.and(ta : TypedArray<T>, index, value) : Tta[index] &= value を実行し、ta[index] の元の値を返します。Atomics.or(ta : TypedArray<T>, index, value) : Tta[index] |= value を実行し、ta[index] の元の値を返します。Atomics.xor(ta : TypedArray<T>, index, value) : Tta[index] ^= value を実行し、ta[index] の元の値を返します。待機とウェイクアップには、パラメータ ta が Int32Array のインスタンスである必要があります。
Atomics.wait(ta: Int32Array, index, value, timeout=Number.POSITIVE_INFINITY) : ('not-equal' | 'ok' | 'timed-out')ta[index] の現在の値が value でない場合、'not-equal' を返します。それ以外の場合、Atomics.wake() によってウェイクアップされるか、スリープがタイムアウトするまでスリープします。前者の場合、'ok' を返します。後者の場合、'timed-out' を返します。 timeout はミリ秒単位で指定されます。この関数の機能のニーモニック:「ta[index] が value の場合に待機する」Atomics.wake(ta : Int32Array, index, count)ta[index] で待機している count ワーカーをウェイクアップします。Atomics.isLockFree(size)size(バイト単位)のオペランドをロックせずに操作できるかどうかを問い合わせることができます。これにより、アルゴリズムは、組み込みプリミティブ(compareExchange() など)に依存するか、独自のロックを使用するかを判断できます。 Atomics.isLockFree(4) は常に true を返します。これは、現在関連するすべてのサポートがそうであるためです。現時点では、次のブラウザを認識しています
about:config に移動し、javascript.options.shared_memory を true に設定しますchrome://flags/ 経由(「JavaScript での実験的な SharedArrayBuffer サポートを有効にする」)--js-flags=--harmony-sharedarraybuffer --enable-blink-feature=SharedArrayBufferShared Array Bufferと関連技術に関する詳細情報
並列処理に関連するその他のJavaScript技術
並列処理の背景
謝辞: この章のレビューと、SharedArrayBufferに関する質問への回答をしてくださったLars T. Hansen氏に深く感謝いたします。