Rustの所有権を緩める道具 Rc/Arc/RefCell/Mutexと、Send/Syncが表すもの

Rustの所有権ルールでは、1つの値の持ち主は基本的に1人だけです。

let a = String::from("hello");
let b = a; // 所有権がbに移り、以降aは使えない

これはメモリ安全性の基盤になっているルールですが、「複数の場所から同じ値を参照・共有したい」「外からは不変に見せつつ内部だけ書き換えたい」といった場面ではそのままでは書けません。この制約を緩めるための道具立てを、それぞれが解決する問題ごとに整理します。

複数の持ち主を許す: RcArc

Rc(Reference Counted)は、値への参照回数を数えておき、0になった時点で実体を破棄する仕組みです。

use std::rc::Rc;

let a = Rc::new(String::from("hello")); // カウント: 1
let b = Rc::clone(&a);                   // カウント: 2(実体は複製されない)

Rc::cloneは中身をコピーするのではなく、同じ実体への参照を1つ増やすだけです。全員がスコープを抜けてカウントが0になった時点で、実体のメモリが解放されます。

参照カウントの増減(+1/-1)はアトミックではないため、複数スレッドが同時にRc::cloneすると、カウントの更新同士が競合して不正確になり得ます。この理由からRcはスレッドをまたいで使えません。カウントの増減をアトミック命令で行うようにしたものがArc(Atomically Reference Counted)で、複数スレッドから安全に共有できます。

内部可変性を許す: RefCellMutex

通常の借用ルール(&&mutを同時に持てない)はコンパイル時にチェックされます。RefCellはこのチェックを実行時に先送りする型で、borrow()/borrow_mut()のたびにルール違反がないかを確認し、違反時にはパニックします。

use std::cell::RefCell;

let cell = RefCell::new(5);
*cell.borrow_mut() += 1;

この実行時チェック用のカウンタもRcの参照カウントと同様にアトミックではないため、RefCellSendではあってもSyncではありません(1スレッドで独占して使うぶんには安全だが、複数スレッドから同時には使えません)。

Mutexは同種の「内部を書き換え可能にする」役割を、スレッドをまたいでも安全な方法で提供します。

use std::sync::Mutex;

let counter = Mutex::new(5);
{
    let mut guard = counter.lock().unwrap(); // 使用中なら空くまで待つ
    *guard += 1;
} // guardがスコープを抜けると自動的にロック解放

RefCellが「コンパイル時チェックを実行時チェックへ先送りする」道具であるのに対し、Mutexが扱う複数スレッドの実行順序はそもそもコンパイル時には決まらない問題です。安全性の担保のされ方を整理すると次のようになります。

チェックのタイミング 逸脱した場合
通常の借用ルール コンパイル時 ビルドが通らない
RefCell 実行時 パニック
Mutex チェックなし(プログラマの責任) デッドロック

Mutexはロックの取得順序をコンパイラがチェックしてくれるわけではないため、複数のMutexを扱う際にロックの取得順序を誤ると、Rustであってもデッドロック(複数スレッドが互いの持つロックを待ち合って停止する状態)を起こせます。

アトミック命令とデータ競合

一見1ステップに見える「カウントを1増やす」という処理も、CPUレベルでは「読む→計算する→書き戻す」の3ステップに分解されます。2つのスレッドが同時にこれを行うと、片方の更新が失われることがあります。

スレッドA: 読む(3)
スレッドB: 読む(3)        ← Aの書き戻し前にBも同じ3を読む
スレッドA: 3+1=4 を書く
スレッドB: 3+1=4 を書く    ← 本来5になるはずが4のまま

これがデータ競合です。CPUには「読む→計算する→書き戻す」を他スレッドに割り込まれず1操作として実行できるアトミック命令があり、AtomicUsizeArcの参照カウント操作はこれを直接利用しています。

なおMutexやセマフォも内部的にはアトミック命令を使って実装されていますが、役割は異なります。アトミック命令は「ごく短い操作を1回で終わらせる」ための部品であるのに対し、Mutex/セマフォは「条件が整うまでスレッドを待たせる(眠らせる)」という、より大掛かりな調停の仕組みです。

Send/Syncが表す境界

SendSyncはメソッドを持たないマーカートレイトで、その型がスレッドをまたいでも安全かどうかをコンパイラに伝えます。

  • Send: その値の所有権を別スレッドへ移動させても安全
  • Sync: その値への参照(&T)を複数スレッドから同時に持たせても安全

多くの型はフィールドの構成から自動的にこの2つが導出されますが、独立した性質なので、片方だけ満たすケースもあります。

Send Sync
両方満たす i32AtomicUsize、中身がSend+SyncなArc<T>
Sendのみ RefCell<T>(1スレッド専有なら安全、実行時借用チェックがスレッドをまたぐと壊れる)
Syncのみ MutexGuard<T>(参照の共有は安全だが、ロック取得時と異なるスレッドへ渡すことはできない)
どちらも満たさない Rc<T>(参照カウントがアトミックでない)

trait LoadBalancer: Send + Syncのように、トレイト定義にSend + Syncを書いておくと、そのトレイトを実装する型は必ずこの2つを満たしていなければならない、という制約になります。AtomicUsizeや読み取り専用のVecだけで構成された型は、この制約を意識せずとも自動的に満たします。

動的ディスパッチ(dyn): 型の確定を実行時まで遅らせる

ジェネリクスは、型引数がコンパイル時に1つに確定している必要があります。

fn use_lb<T: LoadBalancer>(lb: &T) { lb.next_backend(); }

これはTごとに専用のコードが生成される静的ディスパッチで、呼び出しは高速ですが、「設定次第でAかもしれないしBかもしれない」という実行時分岐を1つの変数・1つの関数シグネチャで表現できません。

fn use_lb(lb: &dyn LoadBalancer) { lb.next_backend(); }

dynを使うと、「このトレイトを実装した何か」として型を実行時まで確定させずに扱えます。実際にどの実装のメソッドを呼ぶかは、関数の一覧表(vtable)を経由して実行時に決まります。わずかな間接参照のコストと引き換えに、コンパイル時には型が1つに定まらない状況(設定やユーザー入力に応じて実装を切り替える場合など)を表現できます。