四つの 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 ポインタなので、本物のデータ競合がいまや可能で、 それは三回前の Send と Sync の検査が、初めて、プログラマと本物のバグの間に立っていることを意味 する。スレッドサニタイザの下で走らせると、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 はそれに合う実現をもらい、それでもなお一致するよう保たれる。

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

← Back to Mere: 言語を作る