Cell、RefCell、Mutex:内部可変性

このページの目次

内部可変性(Interior Mutability)は、共有参照を通じてアクセスできるオブジェクトの一部の内部状態を変更することを可能にします。これはキャッシュ、カウンター、同期キューなどで使用されます。内部可変性は「競合するアクセスが同時に存在してはならない」という要件を解除するのではなく、エンティティが別の安全であると証明できるアクセスプロトコルを選択することを可能にします。

共有参照に対する一般的な不変性の制約は、UnsafeCell でラップされた部分に対して例外があります。ただし、UnsafeCell 自体はロックを提供せず、任意の相互別名 &mut の作成やデータ競合を許可しません。UnsafeCell

Cell<T>値のムーブ、置換、またはコピーRefCell<T>動的借用ガードMutex<T>ミューテックスガード通常の共有借用を付与しないget は Copy が必要、replace は不要読者または排他的書き込み者競合はエラーまたはパニックを返す一度に1つのロック保持者競合時は待機または取得失敗共有エントリポイントは異なるが、内部アクセスプロトコルも異なるUnsafeCell は低レベルのプリミティブであり、安全なインターフェースはエイリアスと同期ルールを独自に維持する必要があります。

Cell は Copy 専用ではない

Cell::get は内部値のコピーを返すため、T: Copy を要求します。set と replace は非 Copy 型にも使用できます。replace は古い値をムーブして新しい値を入れ、内部借用を公開しません。take は T: Default の場合にデフォルト値で置き換え、into_inner は Cell を消費して T を取り出します。Cell

use std::cell::Cell;

fn main() {
    let name = Cell::new(String::from("before"));
    let old = name.replace(String::from("after"));
    assert_eq!(old, "before");
    assert_eq!(name.take(), "after");
    assert_eq!(name.into_inner(), "");
}

したがって、Cell<String> は合法かつ有用ですが、get を呼び出すことはできません。RefCell のような動的借用カウンターを持たないため、操作が絶対的にゼロコストではありません。String の置換には依然としてリソース管理が含まれる可能性があります。

「Cell は決して参照を返さない」というのも言い過ぎです。&mut Cell<T> を保持している場合、get_mut は &mut T を返すことができます。この場合、排他的借用はコンパイラによって既に保証されているため、追加のランタイムプロトコルは不要です。

シングルスレッドでも中間状態の観察を考慮する必要がある

Cell::take を使用して Vec を一時的に取り出し、処理後に set で戻すことで借用ガードを回避したように見えますが、処理中に再帰呼び出し可能な外部コードが呼び出されると、他のエントリポイントからデフォルトの空の Vec が見える可能性があります。処理中にパニックが発生すると、取り出された値は直接破棄され、元の Cell は空のままになります。Cell はこのようなアクセスがメモリレベルで安全であることを保証しますが、「取り出し—処理—戻し」がトランザクションセマンティクスを持つことは保証しません。

したがって、Cell は本来全体置換が許可されており、かつ中間状態に明確な意味を持つデータに適しています。内部構造を一時的に借用する必要がある場合は、RefCell が通常、アクセス期間の排他性をより直接的に表現できます。操作が失敗する可能性がある場合は、コミット前に新しい値を計算するか、回復可能な状態遷移を設計する必要があります。

RefCell はガードで動的借用を記録する

borrow は Ref<T> を返し、borrow_mut は RefMut<T> を返します。通常、複数の共有借用、または1つの排他的借用が許可されます。ガードが解放されて初めて借用が終了し、「最後の読み取り値が過去のものだから」とガードが自動的に無効になったと仮定してはいけません。

use std::cell::RefCell;

fn main() {
    let values = RefCell::new(vec![1, 2]);
    let reading = values.borrow();
    assert!(values.try_borrow_mut().is_err());
    assert_eq!(reading.len(), 2);
    drop(reading);
    values.borrow_mut().push(3);
    assert_eq!(&*values.borrow(), &[1, 2, 3]);
}

borrow/borrow_mut は競合時にパニックし、try_borrow/try_borrow_mut は競合を Result として返し、他の借用が終了するのを待ちません。見落としがちな競合の原因には、コールバックの再帰呼び出し、および match 全体や他の長い式の間存続する一時ガードがあります。RefCell

RefCell<T> は T: Send の場合、全体として別のスレッドに移動できますが、Sync ではありません。Rc<RefCell<T>> はシングルスレッドの共有所有権と動的借用の組み合わせであり、スレッド間で共有できるものではありません。

内部フィールドから参照を返す場合は、ガードも一緒に返す

use std::cell::{Ref, RefCell};
struct Profile {
    fields: RefCell<(String, u32)>,
}
impl Profile {
    fn name(&self) -> Ref<'_, str> {
        Ref::map(self.fields.borrow(), |fields| fields.0.as_str())
    }
}
fn main() {
    let profile = Profile { fields: RefCell::new(("Li".into(), 7)) };
    let name = profile.name();
    assert_eq!(&*name, "Li");
    assert!(profile.fields.try_borrow_mut().is_err());
    drop(name);
    profile.fields.borrow_mut().0.push('!');
    assert_eq!(&*profile.name(), "Li!");
}

関数内で一時的な Ref を作成し、その中の &str をガードなしの通常の参照として返すことはできません。関数が終了すると、動的借用の権限が消滅します。Ref::map はガードの可視範囲をフィールドに投影し、RefCell 全体の共有借用状態を保持します。name のみを読み取っている場合でも、同じ RefCell から排他的な数値フィールドを同時に借用することはできません。マッピングは、1つの RefCell を自動的に2つの独立した借用ユニットに分割するものではありません。Ref::map

外部コードを呼び出す前に動的借用を終了する

use std::cell::RefCell;
fn notify(queue: &RefCell<Vec<u32>>, callback: impl FnOnce(usize)) {
    let count = {
        let reading = queue.borrow();
        reading.len()
    };
    callback(count);
}
fn main() {
    let queue = RefCell::new(vec![1, 2]);
    notify(&queue, |count| {
        assert_eq!(count, 2);
        queue.borrow_mut().push(3);
    });
    assert_eq!(&*queue.borrow(), &[1, 2, 3]);
}

ここでは必要なスカラー値をまず取り出し、ガードを解放してから callback を呼び出すため、コールバックはキューを変更できます。reading がまだ生存している間に callback が borrow_mut を呼び出すと、借用競合が発生します。代償として、count は値を取得した時点の状態のみを表し、コールバックの実行後、それがキューの現在の長さと等しいことは保証されません。ビジネスロジックで状態の安定性を維持する必要がある場合は、コールバックの処理内容を制限するか、イベントをコミットしてから外部で処理するように変更する必要があります。

「シングルスレッドなので競合はない」という理由で再帰呼び出しを無視しないでください。シングルスレッドでは2つの機械命令ストリームが同時に実行されることはありませんが、外部コールバックは元の操作が完了する前に同じオブジェクトに再帰呼び出しを行うことができます。

最後に読んだ後も guard は残るか

読み取りと guard の解放を別の操作にしています。Ref を取得して一度読んでも RefMut の取得は失敗します。すべての共有 guard を解放して初めて独占借用が可能になります。通常の参照の最終使用の考え方だけでは Ref や MutexGuard は解放されません。

図を準備しています
最後に読んだ後も guard は残るか

動的借用は guard の寿命で管理され、最後の読み取りでは解除されません。

Mutex は排他的アクセスをスレッド間で拡張する

std::sync::Mutex::lock はロックの取得を待機し、LockResult<MutexGuard<T>> を返します。ガードの解放でロックが解除されます。ロックが保護するのは、同じプロトコルに従うデータアクセスであり、一部のコードがロックを迂回して整合性を期待することはできません。

try_lock はブロックせず、TryLockResult(つまり Result)を返します。エラーは WouldBlock と Poisoned に区別されます。以下では、別のスレッドがロックを保持している間に競合分岐を検証し、同じスレッドでの再帰的なロック取得の未定義動作を避けています:

use std::sync::{Mutex, TryLockError, mpsc};
use std::thread;

fn main() {
    let value = Mutex::new(1);
    thread::scope(|scope| {
        let (ready_tx, ready_rx) = mpsc::channel();
        let (done_tx, done_rx) = mpsc::channel();
        let shared = &value;
        scope.spawn(move || {
            let mut guard = shared.lock().unwrap();
            ready_tx.send(()).unwrap();
            done_rx.recv().unwrap();
            *guard += 1;
        });
        ready_rx.recv().unwrap();
        assert!(matches!(value.try_lock(), Err(TryLockError::WouldBlock)));
        done_tx.send(()).unwrap();
    });
    assert_eq!(*value.lock().unwrap(), 2);
}

poison は、以前にロック保持スレッドがパニック展開中にクリティカルセクションを離れ、ビジネス状態が不完全である可能性があることを呼び出し者に通知します。これは推奨される検出であり、完全なセキュリティ証明ではありません。回復時には不変条件を評価する必要があり、無条件に無視することも、unsafe コードが UB を回避するために poison のみを依存させることもできません。Mutex

低レベルの実装はプラットフォームと標準ライブラリのバージョンに依存しており、Linux 上で常に pthread_mutex_t であるとは限りません。これを選択するのは、主にそのミューテックス契約によるものであり、特定の実装の内部フィールドによるものではありません。

ガードの取得残高と目標上限をチェック新状態の計算失敗する検査を先に行う両フィールドを同時に更新ガード解放後に外部から見える他の参加者はコミット前またはコミット後の帳簿しか見えない前提:関連するすべての読み取りと変更はこのロックのプロトコルに従う同じロックが完全な状態変換を保護するミューテックスはクリティカルセクションのインターリーブを防止します。ビジネス検証、エラー時の不変更、および外部副作用は別途設計する必要があります。

ロックが保護するのは不変条件であり、単なる変数ではない

以下では、1つのロックで2つの口座残高を保護し、送金時に合計額を維持し、残高不足またはターゲットのオーバーフロー時に元の状態を保持します。すべての新しい値を先に計算してからコミットすることで、最初のフィールドがすでに修正され、2番目のフィールドの検証で失敗するという状況を避けます。

use std::sync::Mutex;
#[derive(Debug, PartialEq, Eq)]
enum TransferError { Insufficient, Overflow }
fn transfer(balances: &Mutex<(u64, u64)>, amount: u64)
    -> Result<(), TransferError>
{
    let mut state = balances.lock().unwrap();
    let left = state.0.checked_sub(amount).ok_or(TransferError::Insufficient)?;
    let right = state.1.checked_add(amount).ok_or(TransferError::Overflow)?;
    *state = (left, right);
    Ok(())
}
fn main() {
    let balances = Mutex::new((100, 20));
    assert_eq!(transfer(&balances, 30), Ok(()));
    assert_eq!(*balances.lock().unwrap(), (70, 50));
    assert_eq!(transfer(&balances, 80), Err(TransferError::Insufficient));
    assert_eq!(*balances.lock().unwrap(), (70, 50));
    let full = Mutex::new((10, u64::MAX));
    assert_eq!(transfer(&full, 1), Err(TransferError::Overflow));
    assert_eq!(*full.lock().unwrap(), (10, u64::MAX));
}

この例では、中毒を回復不能なエラーとして扱い、unwrap しています。これは算術とコミットの順序を示すのに適していますが、実際のインターフェースではロックエラーを返すか、回復戦略を定義する必要があります。各残高に個別のロックを割り当てると、ロックを解除する順序によって中間状態が露出します。同時に2つのロックを保持すると、デッドロックを避けるためにロック取得順序を統一する必要があります。2つのアトミック整数が自動的に1つのアトミック送金を構成することはありません。

ロック内には不変条件を維持するために必要な読み取りとコミットを含めるべきですが、低速なI/O、未知のコールバック、または長時間の計算は慎重に扱う必要があります。計算をロックの外に移動するとロック保持時間が短縮されますが、計算が依存する古い状態が変更されていないか確認する必要があります。必要に応じてバージョンを記録し、再ロック後に検証します。ロック外での計算が最新データに対応しているとデフォルトで仮定してはいけません。

中毒はすでに書き込まれたデータをロールバックしない

PoisonError はガードを返すことができるため、呼び出し者は状態のチェックと修復を行う機会を得ます。into_inner を呼び出すのはアクセス権限を取り出すだけであり、clear_poison はフラグをクリアするだけであり、どちらもビジネス不変条件を自動的に回復しません。以下では、制御されたスレッドパニックを構築し、既知の完全な状態を使用して破損した状態を置き換えます。

use std::sync::Mutex;
use std::thread;
fn main() {
    let state = Mutex::new((50u32, 50u32));
    thread::scope(|scope| {
        let shared = &state;
        let failed = scope.spawn(move || {
            let mut guard = shared.lock().unwrap();
            guard.0 = 0;
            panic!("1つのフィールドのみを更新");
        });
        assert!(failed.join().is_err());
    });
    assert!(state.is_poisoned());
    {
        let mut guard = match state.lock() {
            Ok(_) => panic!("中毒状態を期待"),
            Err(error) => error.into_inner(),
        };
        assert_eq!(*guard, (0, 50));
        *guard = (50, 50); // 教育目的で既知の信頼できる完全状態;実際の回復には信頼できる根拠が必要。
        state.clear_poison();
    }
    assert!(!state.is_poisoned());
    assert_eq!(*state.lock().unwrap(), (50, 50));
}

この例はパニック展開に依存し、join を通じてスレッドの失敗を観察しています。panic=abort の場合、プロセスは終了し、このフローに従って回復することはできません。現実の残高を空からリセットすることはできず、信頼できるスナップショット、ログ、またはビジネス照合が必要です。中毒検出自体が、すべてのパニックシナリオで必ずしも信頼できる境界として機能するわけではありません。Mutex poisoning

3つの選択の境界

問題CellRefCellMutex
&self 経由での変更置換などの操作をサポート動的借用を通じてロック保持ガードを通じて
アクセス競合通常の共有借用を維持しない借用エラーまたはパニックを返す待機するか、try_lock がエラーを返す
T: Send 時の全体としての Sendはいはいはい
Sync を実装可能いいえいいえT: Send の場合はい

値全体を置き換えるだけの場合、Cell がしばしば単純です。内部構造を一時的に借用し、シングルスレッドに限定する必要がある場合は、RefCell がより適しています。スレッド間で複数のフィールドの共通不変条件を維持する場合は、Mutex が通常、推論が容易です。非同期シナリオでは、待機が実行子をブロックするかどうか、およびガードが await をまたぐかどうかを考慮する必要があります。Tokio runtime を参照してください。