同じソースを、ホストなしでコンパイルする

Mere が走らせられるあらゆる web と database のワークロードは、ソケットも HTTP サーバも crypto も Node ホストの供給に頼っていた。native backend はコマンドラインツールを生めても、ポートで listen したり database と話したりするものは生めなかった。その gap を閉じるのは native runtime をゼロから発明することでなく、Wasm backend がすでに使うメモリモデルを借りることだった —— だから同じライブラリがまったく変更なく native コンパイルされ、同じソースが、無変更で、いまや Node 上でも、Postgres と手書き C の速度で話す単一の自己完結バイナリとしても走る。

merenativec-backendfull-stacklanguage-design

この時点で Mere は web 上で大きな到達を持っていた:HTTP サーバ、実の wire protocol を話す手書きの Postgres クライアント、Redis クライアント、ORM、そのすべてが Mere ソースから使えた。だがその最後の 一片まで同じやり方で走った —— Node ホストの下の WebAssembly として、ホストがソケットを、listen する サーバを、暗号 primitive を供給して。native backend、実の実行可能ファイルを生むほう、はコマンドライン ツールをコンパイルできてそれ以上はできなかった。ソケットを開く手段も、接続を accept する手段も、 パスワードを hash する手段もなかった。言語の最も有能な半分が、サーバをまったく走らせられなかった。

これは片づけておく papercut でなかった;native path が欠く丸ごと一つの能力で、それが気づかれずに来た 理由は CLI の話でと同じだ。Node ホストが常にこれらの呼び出しに答えていたので、言語はそれ自身で 答えるよう作られたことがなかった。問いは、何もないところから二つ目の runtime を建てずにその gap を どう閉じるか、だった。

メモリモデルを発明せず借りる

database と話すライブラリは Wasm backend のメモリモデルに対して書かれていた。wire protocol はバイト だ —— バッファを割り当て、ビッグエンディアンの長さ prefix を書き、文字列をコピーして入れ、バイトを 読み返す —— そして Wasm 側ではその語彙が、32bit オフセットでアドレスされる linear memory の単一の 平らな領域への操作に lower された。ライブラリはそういう操作で満ちていた。それらを何か別の idiom で native に再実装することは、ライブラリも書き直すことを意味しただろう。

だから native runtime は自分自身のモデルを発明しなかった;すでに存在するものを借りた。C backend は 平らなバイトアリーナを生やした —— 32bit オフセットでアドレスされる大きな static バッファ、まさに Wasm の linear memory の形 —— そしてそれに対して同じメモリ語彙を実装した:割り当て、バイトと ビッグエンディアン整数を読み書き、文字列をコピーして入れ、文字列を読み出す。プログラムがそれらの 操作の一つを使うと、backend は未定義のホスト関数への参照を emit するのをやめ、代わりに実の native 実装を emit した。ライブラリはまったく変わらなかった。それらはメモリモデルに対して書かれていて、 C backend は同じモデルを別の場所で供給しただけだ。これは連載が戻り続ける規律だ:新しい問題が現れた とき、最初の一手はすでに建てられた機構に寄りかかること。何年もの決定前に建てられた Wasm backend の メモリモデルが、native な database を可能にするものだと判明した。

同じ名前の下のソケット

アリーナの上にソケットが載った:POSIX の tcp_connecttcp_writetcp_readtcp_close、native に実装され —— そして決定的に、Node ホストが使っていたのと同じ名前の下で export された。それが parity の全トリックだ。ライブラリが tcp_write を import する;Wasm path ではその名前が Node ホストの 一片に解決し、native path では C 実装に解決し、その上のライブラリはどちらかを知りも気にもしない。 一つの Mere ソースがいまや二通りにコンパイルする:Node ホスト向けの WebAssembly か、native バイナリ か、両方で同じ振る舞いで。Postgres クライアントが実際に C コンパイラを通して走らされた最初のとき、 それは backend の文字列 escape の潜伏バグを洗い出した —— escape される代わりに生で書かれていた キャリッジリターンで、wire protocol が運ぶまさにその種のバイナリ寄りの payload を壊していた。その場で 修正され、そして注記に値する、なぜなら同じ小さな漏れが、ずっと後、ずっと高くつく形で、誰も予期しない 場所で再浮上するからだ。

一つのバイナリ

このすべての狙いは単一の成果物だった。blog アプリケーション —— signup、login、logout、セッション、 投稿の所有権を持つ実の web アプリ —— が、数百キロバイトの native 実行可能ファイルへまっすぐに コンパイルされ、Node も Wasm もどこにもない。それはソケットで listen し、native な accept ループを 通して HTTP リクエストをパースし、実の TCP 接続の上で Postgres と話し、native な SHA-256 でパスワードを hash し、オペレーティングシステムの乱数からセッション識別子を鋳造し、あらゆる endpoint で curl に 答える。アーク全体が狙っていた旗艦の一文 —— Mere で web と database のアプリケーションを書き、一つの バイナリとして届ける —— がいまや願望でなく文字通りの、実証された事実になった。

トンネルなしの crypto

一片は TLS への依存を強いるように見え、そして強いなかった。パスワード認証に設定された Postgres への 接続は SCRAM ハンドシェイクの完了を意味し、それは実の暗号を要する:SHA-256、HMAC、PBKDF2、base64。 それらは native に実装されリファレンスベクタと照合された。心地よい驚きは、SCRAM が challenge-response プロトコルだということだ —— パスワードを決して送らずにその知識を証明する —— だから安全であるのに transport 暗号を要さない。native バイナリがパスワード保護された database に平の TCP 接続の上で、TLS スタックまったくなしで認証できた。そして下の runtime が Postgres 特有でなく generic だったので、同じ アリーナとソケット、加えて二つの小さな hex ヘルパが、Redis クライアントを native に駆動するのにも 十分だった。runtime は database の feature でなかった;クライアント一家全体が立つ土台だった。

数字

native path を足すことは、それが実際に速いのかという明らかな問いを招き、そしてこれは言語が実の答えを 持つ最初のときだった。再帰的な fib(35) を native コンパイルするとおよそ 30 ミリ秒で走った。同じ計算を 手で C に書き最適化つきでコンパイルするとおよそ 30 ミリ秒で走った。Mere→C→C コンパイラは、普通の 再帰コードで、手で書かれた C と同格の native コードを生んだ —— そして他のベンチマークにわたって native path は interpreter より 40 から 80 倍速く走り、末尾呼び出しは実のループに変わり、割当の重い コードは病理を見せなかった。生成コードに赤信号はなかった。数字が語る物語はタグラインになるほど 単純だ:Mere を native コンパイルすると、C の速度が得られる。

このアークが実際に足したのは feature でなく、すでに書かれたすべてへの二度目の生だった。ライブラリは 変わらず;ソースは変わらず;ホストがただ任意になった。寛大な runtime の中で生きてきた言語が、 オペレーティングシステムの上に直接立つことを学び、そしてそうしたのは新しい土台を発明することでなく、 古い土台を新しい場所へ運ぶことによってだった。

← Back to Mere: 言語を作る