返ってくるメモリ

長命プログラム編のフィナーレ: region ブロックが body の割当てを本当に回収するようにする。第一段は copy-on-store で container に中身を所有させた。第二段は全ての値割当てを thread-local な current region に向け、ブロックの結果を copy-out し、channel の荷を per-message region 経由にした。その間、三つの別々の伏兵が末尾呼び出しを殺しに来た —— stack struct の escape するアドレス、inline された asprintf、destructor を持たない thread-local キャッシュ。終わってみれば、慣用的な行カウンタは wc より下の定数メモリで走り、この Part を開いたサーバは 40 万コマンドの下でフラットを保つ。

merememory-modelregionsserverlanguage-design

問題は三話前に名指されていた: コマンドごとの値は reply が書かれた瞬間に論理的には死ぬのに、 生き残らねばならない少数の値と同じ不死の region に混ざって積まれる。どんな修理も、まず生存者に 答えねばならない —— スコープ付き region が body の割当てを全て回収するなら、長命の map に store された文字列や、別 thread へ送られた文字列はどうなるのか。ここへ杜撰に答えれば、回収は use-after-free の生成器になる。だから修理は意図して二段で出荷された。生存者が先だ。

第一段: container が中身を所有する

最初のリリースは観測可能な振る舞いを何一つ変えず、そのことを自分の changelog に書いた。store は copy するようになった: map_set は key と value を map 自身の region へ deep copy し、vector への push は要素を copy する。copy は型ごとの copy 関数を通る —— derive 一家が常にそうであったのと同じ 特殊化で。文字列はバイトを copy し、tuple・record・variant のノードは構造ごと copy し、スカラと closure は素通りし、そして入れ子の container はポインタとして copy する。可変 container は identity であり、それを alias することにこそ意味があるからだ。文字列は不変なので、この全ては プログラムから観測できない。リリースの全体が lifetime についての約束だった —— store したものは、 store した者より長生きする —— それを試す機構に先立って交わされた約束だ。意図して退屈であり、 この後の全ての前提条件だった。

第二段: スコープが取り返す

二つ目のリリースが本懐だった。値の割当て —— 文字列、cons cell、variant ノード —— が不死の region の 直書きをやめ、thread-local な current region を向くようになった。region ブロックは body の間 自身を current にする。body が割り当てる全てはブロックに着地し、exit でブロックは自分の結果を 第一段の機構で外側の region へ deep copy し、残りを解放する。設計ノートに列挙した escape 経路は 一つずつ答えを得た: 結果は copy-out。container への store は第一段で既に安全。channel の send は 荷を per-message region へ deep copy し、受信側が自分の current へ汲み出して解放する —— 送信側の scratch はメッセージの飛行中に死んでよい。closure の環境は意図して不死のまま —— 小さな既知の leak と 引き換えに、決して dangle しない。そして container はブロックの結果になれない —— typer の既存の region-escape 検査が先に発火し、codegen の guard が後ろに控える。失敗経路まで勘定に入った: ブロックを longjmp で飛び越える fail は、解放済みメモリを指したままにせず current region を復元する。

末尾呼び出しへの道の三つの伏兵

設計は即座に機能し、実装は三度死にかけた。全て同じ場所 —— 深いループでだ。末尾再帰ループの中の per-iteration region ブロックが 9 万反復でスタックを溢れさせた —— ブロックの stack 確保 struct の アドレスが thread-local へ escape し、C コンパイラが自己呼び出しのジャンプ化を黙って拒否していた。 ブロック region を heap 確保にし、thread ごと 1 段のキャッシュでホットループのコストを malloc でなく ポインタ交換にすると、escape は消えた。ループはまだ死んだ。emit された C の二分探索が第二の伏兵を 見つけた: 整数整形の helper がループに inline され、ローカルのアドレスasprintf に渡していた —— 同じ阻害、新しい犯人。整形一家は今や noinline だ。第三の伏兵はサーバで待っていた: メモリが接続 ごとに育つ。thread のキャッシュ region には destructor が無く、接続ごとに spawn するサーバは thread を 絶えず引退させるからだ。spawn の trampoline が、thread の終わりごとにキャッシュを解放するように なった。三つの別の衣装、一つの教訓: C を経由してコンパイルする言語では、末尾呼び出しは意図して 守り続ける約束だ —— 最適化器の保守主義を相手に、escape したアドレスを一つずつ潰しながら。

領収書

二つの数字がこのアークを閉じる。慣用的な string 方言の行カウンタ —— 行あたり 30 バイトの永久残留を 測った、あの probe —— は各行を region ブロックに包み、800 万行を 1.5 メガバイト・定数・wc 自身より 下で走った。同じカウンタが二話前には 4 分の 1 ギガバイトを要した。そしてこの Part を開いた key-value サーバは、handler と store owner に per-command region を採用し、新規接続で 8 万コマンド× 5 ラウンドを 受けた: 常駐メモリは 10 メガバイト前後を振動し、始まった場所で終わった。8 千での abort から、無際限の 成長へ、そしてフラットへ —— 残る micro-edge はいつもどおり書き残して(上書き過多の store は今も緩やかに 育つ。bump region は置換された旧値を解放できないからだ)。この Part をサーバ一つ生かせずに始めた言語は、 一つを無期限に走らせられる言語として Part を終える。長命のプログラムが要求するものは、結局のところ 言語であることの大部分だった: 健全な型、正直なアロケータ、そして thread を越えて守られる約束。

← Back to Mere: 言語を作る