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