四つの backend で並行を動かす

型システムは並行処理を紙上で安全にした;いまそれは走らねばならない —— spawn を本物のスレッド、channel を本物の共有メモリに、四度、同一の出力に保って。同じ primitive が四つの異なるやり方で実現され、それぞれが自分の backend に合う:ホストスレッド、per-type channel の pthreads、i64-slot channel の pthreads、共有メモリ上の Wasm worker。コード生成の Part のクロージャ表現が四つすべてを可能にし、Wasm への悲観的な懸念が溶ける。

mereconcurrencybackendsthreadswebassemblylanguage-design

型システムは終わった:並行処理は紙上で安全だ。いまそれは走らねばならない。spawn は本物の スレッドに、channel は本物の共有メモリにならねばならない —— そして、コード生成を初めから統べてきた 規則により、それは四度、backend ごとに一度、どの二つも違う出力を生まずに起きねばならない。際立つのは、 同じ primitive が四つの本当に異なるやり方で出てきて、そのそれぞれが自分の backend が何であるかに 形づけられ、そしてコード生成の Part で建てられたクロージャ表現こそが四つすべてを動かすことだ。

なぜクロージャがスレッドを作るのか

スレッドは、削ぎ落とせば、「このクロージャをどこか別で走らせる」だ。そしてクロージャは、Mere の どの backend でも、精神においては常に同じに表現された:エントリポイントと、その捕捉された環境。C では 関数ポインタと環境ポインタの struct;Wasm では関数テーブルのインデックスとメモリ offset。その表現が backend 間で形において一様だから、クロージャを新しいスレッドに手渡すことはどこでも機械的に自然だ —— スレッドが要する二つのもの、走らせるコードと、それが閉じ込めたデータを、すでに持っている。並行処理の feature は、Part IV のクロージャの仕事を、再発明でなく継ぐ。

一つの primitive の四つの当てはめ

インタプリタが最も易しく、最も速く報われる。そこでのクロージャは本物の環境を持つ本物のホスト言語 クロージャなので、spawn はただホスト自身のスレッド生成で、channel はロックと条件変数を持つキューだ。 ホストのランタイムが共有された環境とスレッドを面倒みる。数十行で、元の pain point —— 一つのプロセスの 中の二つの永久 loop —— が解ける。なぜ OCaml かの賭けがもう一度報われる:ホストの並行性が Mere の ものになる。

C backend は、並行処理が理論的であることをやめるところだ。spawn はクロージャを heap にコピーし、 それを呼ぶ trampoline で POSIX スレッドを起動する;channel は要素型ごとに単相化される —— 具体型ごとに 一つの channel 実装、言語がすでにそのベクタを特殊化するのに合わせて —— それぞれが mutex と条件変数を 持つリングバッファだ。ここでは環境が共有された heap ポインタなので、本物のデータ競合がいまや可能で、 それは三回前の SendSync の検査が、初めて、プログラマと本物のバグの間に立っていることを意味 する。スレッドサニタイザの下で走らせると、channel は競合ゼロを示す —— 型システムの仕事が、競合検出器が 何も見つけないことで検証される。

その backend は名づける価値のある危険を浮かべた。default region —— クロージャの環境が確保される —— は共有バンプアロケータで、そのポインタをバンプするのはアトミックでないので、二つのスレッドが同時に 確保すると競合するだろう。修正は一見より軽かった:その region は決して解放されない不死の arena なので、 解くべき lifetime 問題は無く、あるのはバンプの競合だけ —— そして共有 arena の確保への単一のロックが それを閉じ、per-block region は thread-local な stack 値なので lock-free のままだ。設計はかつて各子 スレッドに自分の region を与えることを提案した;根本原因究明が、それは正しさには不要だと示し、可能な 将来の最適化としてだけ残した。

LLVM backend は C と同じスレッドだが、違う channel だ。C が型ごとに一つの channel を単相化する ところ、LLVM は単一の総称的 i64-slot channel を使う —— そのスカラー値がすべて八バイトに収まる から、要素を i64 slot へ/から cast でき、一つの channel があらゆる型に仕える。その近道には穴が あった:集約 —— タプルとレコード —— はレジスタに収まらず、一つに拡げられず、コンパイラはその試みを 拒否した。修正は集約を heap に box し、代わりに slot にポインタを通す。backend 固有の最適化の、 backend 固有のエッジケース、見つけて閉じられた。

Wasm backend は、設計ノートが悲観的だった一つだ:子で Mere のクロージャを走らせる道がまったく 無いと懸念していた。その懸念は溶けた。共有メモリスレッドで、worker は同じモジュールを一つの共有 メモリと一つの共有関数テーブルの上に再 instantiate し、クロージャ表現 —— テーブルインデックスと メモリ offset —— は worker で変わらず有効だ、両方が二つの instance の共有するメモリを指すから。 spawn は host import を通して worker を起動する;channel は、ホストのアトミック primitive で操作 される共有メモリの小領域で、繊細なロックと待機の論理を手書きの Wasm の外に保つ。古い悲観は単に 間違っていた、以前のクロージャ設計がすでに子を到達可能にしていたから。

確保する worker だけが生むバグ

Wasm はもう一つ微妙な失敗を持っていた。共有データをただ読む worker は問題ないが、確保する worker —— カリー化された呼び出しが中間クロージャを建てるとき暗黙にそうするように —— は instance ごとの確保 ポインタをバンプし、二つの worker が同じ共有メモリへ自分のポインタをバンプすると衝突して deadlock する。修正はバンプをアトミックにすることでなく、各 worker に確保する共有メモリの互いに素な領域を 手渡すことだった:ホストが各 worker の確保ポインタを、起動後に、別々の重ならない高位 offset に設定 する。生成されたコードへの変更なし、アトミックなし —— ただ分割された空間。以前 deadlock した プログラム、四つの worker がそれぞれカリー化された仕事をする、が完走した。

一つの挙動、四つの機構

最後に、spawn は四つの backend で走り、channel はそれらを越えて走り、データ並列プログラム —— 仕事を いくつかの worker へ fan-out し、部分結果を channel で fan-in する —— がそのどれでも同じ数を生む: インタプリタ、C、LLVM、Wasm。機構はこれ以上ないほど違う —— ホストスレッド、per-type channel の pthreads、box された i64 slot の pthreads、ホスト駆動のアトミックと分割された確保を伴う共有メモリの worker —— そしてそれこそ、コード生成の規律全体が作るために建てられた点だ。parity は観測される出力に 住み、実装にでない;各 backend はそれに合う実現をもらい、それでもなお一致するよう保たれる。

並行処理はいま、安全に、どこでも存在する。だが spawnchannel は生の primitive —— 並列性の アセンブリ言語で、大半のコードがそれで表現したがるべき形ではない。次のステップは、よくある場合を 人間的にすることだ:人が実際に手を伸ばす形、並列なマップを与え、それを四度書かずに四つの backend で 得ること。次回:par_map。

← Back to Mere: 言語を作る