Guide / Reinhardt 0.4.0-alpha.13; moonc 0.10.11+6ff76a5f9

CopyできるSignalは、何をコピーするのか?

この記事の内容

RustとMoonBitの実験をダウンロード。展開してframework-internals/README.mdに従ってください。Rust 1.96.0、Moon CLI 0.1.20260827、moonc 0.10.11+6ff76a5f9、Node 22.22.1、Python 3.12、ネイティブのCツールチェーンで検証しています。初回ビルドでは依存を取得します。SQLiteの実験はそれぞれ別のメモリ内DBを使い、サーバーは不要です。

python3 verify.py

2つのクロージャで1つの値

Rustの2つのコールバックで変化する値を共有するとき、moveクロージャごとにcloneを書くのを省きたいとします。 CopyできるSignalなら、両方のコールバックに同じ保存値を指すハンドルを渡せます。 ハンドルのコピーでは中の値をcloneしませんが、getで所有する値を取り出すとcloneが発生し得ます。

値のclone回数を数え、別のコールバックで更新し、最後に所有者を解放します。 サンプルはReinhardt Pagesのリアクティブ基盤、Reinhardt Core 0.4.0-alpha.13に固定しています。 0.3.15は参照カウントで共有する別の設計なので、この説明はバージョンを限定しています。

スコープが値を所有する

まず値の寿命を考えます。ハンドルをコピーしても、所有する責任が消えるわけではありません。 リアクティブスコープが保存用のスロットを所有し、Signalはその1つを探すためのキーを持ちます。 キーにはスコープ、スロット番号、世代、ノード識別子、種類、所有スレッドの情報が入ります。

単なる生ポインタではなく、コピーしたハンドルが値を個別に所有するわけでもありません。 アクセス時はスコープを検索し、キーが期待する型の有効な保存場所を指すか確認します。 これにより、解放後の古いハンドルが無関係な状態を黙って読むのを防ぎます。

所有権はスレッドローカルなので、中身が整数でもハンドルはSendでもSyncでもありません。 別スレッドに処理を移すなら、明示的なメッセージ交換など、別の共有方法が必要です。

ハンドルをコールバックにコピーする

この所有関係を踏まえて、Rustの例を見てみましょう。 文字列と、Clone実装が呼ばれるたびに増えるカウンターを持つ値を作ります。 このカウンターが測るのは値のcloneだけで、メモリ確保回数や時間ではありません。

scope.enterの中で、1つ目のmoveクロージャはgetを、2つ目はupdateを呼びます。 両方のクロージャは同じSignalのキーをコピーして取り込み、元の変数も引き続き使えます。 Copyの型制約でこの性質をコンパイル時に確認し、カウンターが0であることから値がcloneされていないことも確認します。

ハンドルのcloneを繰り返す構文は省けますが、文字列の所有権にかかる処理までなくなるわけではありません。 大きなコレクションをSignalから読むコードでも、この違いを意識してください。

実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。

let read = move || signal.get();
let edit = move || signal.update(|p| p.text.push('!'));
assert_eq!(clones.get(), 0);
println!("handle_captures=2 payload_clones=0");
let owned = read();

操作を数える

付属のコマンドを実行し、出力の最初の3行を見てください。 この実験では、2つのハンドル取り込みで値のcloneは0回です。 読み取りクロージャを呼ぶと、helloを含む所有された値が返り、cloneカウンターは1になります。

更新クロージャは、保存してある文字列に感嘆符を直接追加します。 Signal::with_untrackedで更新後の長さを調べると6で、追加のcloneはありません。 ただし、この借用による参照はリアクティブな依存関係を登録しません。次の説明で重要になる点です。

次にgetを呼ぶEffectを作り、1回更新して、保留された処理を明示的に実行します。 実行回数は、初回と更新後の合計2回になります。

Rust / Reinhardt

handle_captures=2 payload_clones=0
owned_get=1 payload_clones=1
update_and_borrow length=6 extra_payload_clones=0
tracked_effect_runs=2 after_one_update_and_flush
disposed_read=rejected disposed_write=rejected
PASS reinhardt-core=0.4.0-alpha.13; native; no timing benchmark

MoonBit

handle_captures=2 payload_clones=0
owned_get=1 payload_clones=1
update_and_borrow length=6 extra_payload_clones=0
tracked_effect_runs=2 after_one_update_and_flush
disposed_read=rejected disposed_write=rejected
PASS MoonBit arena; explicit payload copy; stale generation and dynamic edges

借用で契約が変わる

Effectは読み取りを将来の処理につなぎます。追跡されるgetは、このSignalに依存する処理を記録します。 書き込みはリアクティブランタイムに通知され、変更されたノードに依存する処理を実行対象にできます。 この実験では明示的なflushで順序を確定しています。ブラウザのスケジューリングや描画時間は測っていません。

すべてのgetをSignal::with_untrackedに置き換えると、値のcloneだけでなく、その読み取りの依存追跡も失われます。 表示が更新されなくなる可能性があるため、機械的な最適化ではなく、意味の変更として判断してください。 保存値の変更にはupdate、購読が不要な参照にはuntrackedの操作を使います。

ハンドルのコピー、所有するスナップショット、購読は、それぞれプログラムの別の問題を扱います。

コピーしたキーは所有者より長く生きられる

最後に、通常のRust変数にコピーしたハンドルを残したまま、スコープを解放します。 変数は存在していても、それが参照していた値の所有者はもういません。 サンプルでは、解放後にエラーを返せる読み取りと書き込みの両方が拒否されることを確認します。

コンポーネントが消えた後で非同期の応答が届いた場合に役立つ境界です。 この状況には失敗を処理できるAPIを使います。このリリースでは、解放済みノードへの通常のgetはpanicします。 アプリがリアクティブな状態を使う間は、スコープを有効に保つ必要があります。

Copy型であっても、Rustの借用ライフタイムではなく、実行時に管理する寿命のルールを持てます。

MoonBitで仕組みを再現する

次に、値を所有するアリーナをMoonBitで作り、同じ観測結果を再現します。 ハンドルにはアリーナ、スロット番号、世代番号を持たせます。 所有する値を読むときだけ、明示的なコピー関数で配列を複製します。

実行した検証では、取り込みで0回、getで1回、更新と参照では追加なしという結果を再現しています。 解放後にスロットを再利用しても、古い世代のハンドルは読み書きを拒否します。 この単一所有者のモデルでは、Reinhardtの入れ子のスコープやスレッド検査を省略しています。MoonBitの代入はRustのCopyトレイトと同じではありません。

両方を実行し、リンクしたソースから省略した境界を1つ調べてみてください。

実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。

pub struct Signal[T] {
  runtime : Runtime[T]
  index : Int
  generation : Int
}

ソースの対応と検証の限界

PORTING.mdに、固定したフレームワークの関数と実行できるMoonBit実装の対応、および省略した機能を記載しています。ランタイムの表現は異なり、完全な移植ではなく範囲を限定した調査です。Node 22のSQLite APIではexperimentalの警告が出ます。

Reinhardt contribution guide.