FABRKNT
スタックを読む — 中級への橋渡し
ソース読みのための Rust
レッスン 7 / 10·CONTENT12 分25 XP
コース
スタックを読む — 中級への橋渡し
レッスンの役割
CONTENT
順序
7 / 10

レッスン6 — 所有権の共有:Arc・Mutex・RwLock

問い

Rust の所有権は「1 つの所有者」だが、複数スレッドで共有したいときは? Arc(参照カウント)+ Mutex / RwLock(排他制御)の組み合わせ。並行コードで頻出する 3 プリミティブを理解。

原理(最小モデル)

  • Rc<T>. Reference Counted、同一スレッド内で複数所有、Rc::clone で参照カウント++、最後のドロップで解放、!Send / !Sync
  • Arc<T>. Atomic Reference Counted、Rc のスレッド間版、Send + Sync、atomic 操作で参照カウント、Tokio タスクで頻出。
  • Mutex<T>. 排他制御、lock() で MutexGuard 取得 + ドロップで解放、デッドロック注意、同期版(std::sync::Mutex)と非同期版(tokio::sync::Mutex)。
  • RwLock<T>. 読み書き分離、read() 複数可 / write() 排他、読みが多い場合に有利、同様に同期版と非同期版。
  • Arc<Mutex<T>>. 最頻出パターン、複数スレッドで T を共有 + 排他制御、let shared = Arc::new(Mutex::new(value)); + shared.clone() でスレッドへ。
  • Atomic primitives. AtomicUsize / AtomicBool 等、ロックなしで read-modify-write、Ordering で memory ordering 制御、最も軽量。
  • Cell<T> / RefCell<T>. 内部可変性、&self から内側を変更、Cell は Copy 型のみ、RefCell は実行時 borrow check、シングルスレッド限定。
  • Tokio の tokio::sync::*. async 版 Mutex / RwLock / oneshot / mpsc、.await で待機、async タスクで使う、std::sync::Mutex.await 内で持つとデッドロック。

具体例 + ステップで組み立てる

所有権の共有: Arc・Mutex・RwLock

Rust の「ただ一人のオーナー」ルールは厳しい — そして 90% の場合は助かる。残り 10% は 複数の場所から同じ値を保持 する必要があり、スレッド跨ぎだったり、変更ありだったりする。それがこのレッスン。

Reth と ExEx のコードは ArcMutex で埋め尽くされる。本レッスン前だとラッパーは雑音に見えやすいが、通過後は、非同期タスク間で状態共有を支える必須部品として読める。

問題 — なぜ所有権だけでは不十分か

インデクサを想像してみてください:

  • チェーンから新ブロックを受け取る (1 タスク)
  • RPC サーバが現在の状態をクエリする (多タスク)
  • 定期的に状態をディスクへスナップショットとして書き出す (1 タスク)

3 つすべてが同じインメモリ状態にアクセスする必要がある。素の Rust 所有権は「ただ一人のオーナー」を要求する — でもここでは 本当に 共有アクセスが必要。

答える質問が 2 つ:

  1. 誰が値を所有するか? 複数の場所。
  2. 誰が変更できるか? 場合による。

Arc — 多重所有、不変共有

Arc<T> ("Atomically Reference-Counted") は複数のオーナーが値を共有できるようにし、最後のオーナーが drop した時に値を解放する。

use std::sync::Arc;

let data = Arc::new(String::from("hello"));
let clone1 = Arc::clone(&data);   // refcount: 1 → 2
let clone2 = Arc::clone(&data);   // refcount: 2 → 3

std::thread::spawn(move || {
    println!("{}", clone1);       // 別スレッドへ move 可能
});
println!("{}", clone2);
// clone2 drop → refcount: 3 → 2
// clone1 drop (spawn 先で) → refcount: 2 → 1
// data drop → refcount: 1 → 0 → メモリ解放

重要な性質が 2 つ:

Arc::clone は安い

Arc::clone(&x) は内部の T を deep copy しない。やるのは:

  1. アトミックにカウンタをインクリメント
  2. 同じヒープ確保への新しいポインタを返す

コスト: アトミック add 1 回。内部の T は触らない。だから Reth で Arc::clone(&shared_state) がどこにでもある — 実質タダ。

Arc は読み取り専用共有を提供

Arc<T> 経由では T の &self メソッドしか呼べない。変更不可。設計上そう — 同じ Arc<T> を持つ複数スレッドは同時に読み書きできない、同期なしでは。Rust が型レベルで強制する。

変更が必要なら、最初に T を MutexRwLock で包む。

Arc vs Rc

Rc<T> は同じ形だが シングルスレッド。通常 (非アトミック) カウンタを使う。速いがスレッド跨ぎでは健全でない (unsound)。Reth と ExEx は Arc 一本 — マルチスレッド設計だから。

Mutex — 排他読み書きアクセス

Mutex<T> は値をロックでラップする。読み書きには lock() が必要:

use std::sync::{Arc, Mutex};

let counter = Arc::new(Mutex::new(0u64));

let c = Arc::clone(&counter);
std::thread::spawn(move || {
    let mut guard = c.lock().unwrap();   // ロック取得
    *guard += 1;                          // 変更
    // guard がここで drop → ロック解放
});

lock()MutexGuard<T> を返す — スマートポインタで:

  • Deref 実装&T (読み) や &mut T (書き) のように使える
  • drop 時にロック解放 (RAII パターン — 手動 unlock 不要)
  • 他スレッドがロック保持中なら呼び出しスレッドをブロック

.unwrap() は何で panic するか — poisoning

.lock().unwrap() を至る所で見るはず。なぜ unwrap?

lock()Result<MutexGuard, PoisonError> を返すから。エラーケースは mutex poisoning — スレッドがロック保持中に panic すると、mutex は「poisoned」とマークされ、後続のロッカーがデータが不整合かもしれないと知る。

ほとんどの Reth コードは特別扱いせず poisoning を伝播 (panic) させる選択。.unwrap() が正しい呼び方なのは、部分更新の不整合を推論できない時。

自分のログで Mutex poisoning を見たら、根本のバグは元の panic であってロックではない — 何が panic したかを見つけて修正する。

パターン: Arc<Mutex<T>>

この組み合わせは至るところで目にします:

let shared_state: Arc<Mutex<MyState>> = Arc::new(Mutex::new(MyState::default()));

for _ in 0..10 {
    let s = Arc::clone(&shared_state);
    std::thread::spawn(move || {
        let mut guard = s.lock().unwrap();
        guard.do_work();
    });
}

Arc が多オーナーポインタを提供; Mutex が変更安全性を提供。組み合わせ: スレッドセーフな共有可変状態。

RwLock — 多 reader OR 単 writer

Mutex は排他 — 一度に 1 スレッド、読みでも。読みが書きを大幅に上回る場合は無駄。

RwLock<T> は許す:

  • 多くの並行 reader (.read())
  • または排他的な 1 writer (.write())
  • 同時には決して両方ではない
use std::sync::{Arc, RwLock};

let cache = Arc::new(RwLock::new(HashMap::<String, u64>::new()));

// 多スレッドが同時に読める
let c = Arc::clone(&cache);
std::thread::spawn(move || {
    let guard = c.read().unwrap();      // 共有読み
    if let Some(v) = guard.get("key") {
        println!("{}", v);
    }
});

// 1 スレッドが書く (全 reader と他 writer をブロック)
let c2 = Arc::clone(&cache);
std::thread::spawn(move || {
    let mut guard = c2.write().unwrap();
    guard.insert("key".to_string(), 42);
});

選び方:

パターン選ぶもの
50/50 の読み書きMutex (シンプル)
多読み・少書き (キャッシュ・config)RwLock
書き専用のタイトな内側ループMutex
シングルスレッドどちらも不要 — RefCell を使う

RwLock は writer に対して Mutex よりやや重い (簿記が多い)、なので読みが支配的でない限り手を出さない。

async の話 — tokio::sync::Mutex

標準 std::sync::Mutex同期的: .lock() は OS スレッドをブロックする。async コンテキスト (Tokio) ではスレッドをブロックするのは悪い — 同じワーカー上の他タスクを飢えさせる。

async コードには tokio::sync::Mutex

use tokio::sync::Mutex;
let m = Arc::new(Mutex::new(0u64));

let m_clone = Arc::clone(&m);
tokio::spawn(async move {
    let mut guard = m_clone.lock().await;   // .await、.unwrap() ではない
    *guard += 1;
});

違い:

  • .lock() は Future を返す — .await する
  • 待機中タスクは runtime に yield する (ワーカーをブロックしない)
  • poisoning の概念なし (async タスク内の panic は Tokio が処理)

Reth は両方使う: .await を跨がない高速クリティカルセクションには std::sync::Mutex、await を跨ぐ guarded 状態には tokio::sync::Mutex

経験則: クリティカルセクションが .await を含むなら tokio::sync::Mutex。そうでなければ std::sync::Mutex で OK、やや速い。

Reth の実パターン — DB ハンドルの共有

Reth が DB をタスク間で共有するパターンの単純化版:

struct Node {
    db: Arc<Database>,                     // 共有、不変ハンドル
    blockchain_tree: Arc<RwLock<Tree>>,    // 共有、ほぼ読み
    metrics: Arc<Mutex<MetricsCollector>>, // 共有、書き重め
}

impl Node {
    fn spawn_indexer(&self) {
        let db = Arc::clone(&self.db);
        let metrics = Arc::clone(&self.metrics);
        tokio::spawn(async move {
            // db: Arc 経由で読み専用、ロック不要
            // metrics: カウンタ更新にロック
            metrics.lock().unwrap().increment("blocks_indexed");
        });
    }
}

3 つの異なる共有パターンが 1 つの struct に。各選択は意図的 — ロック粒度がアクセスパターンに合っている。

Reth ソースで Arc<RwLock<Foo>> の隣に Arc<Mutex<Bar>> を見たら、それは著者が用途ごとに選択した結果である。いまならその判断を読み解ける。

読み物リスト

  1. Rust Book 章 16 (Fearless Concurrency)ArcMutex のセクションを読む。
  2. tokio::sync ドキュメントMutexRwLockbroadcastoneshot のページをスキム。各々が Reth のタスクコードの実パターンにマップする。
  3. Reth ソースで Arc<RwLockArc<Mutex を検索。いくつか選ぶ。どちらを選ぶかは常に意図的 — なぜか説明してみる。

このレッスンで持ち帰るもの

  • Arc<T> は多オーナー、不変、clone が安い (アトミック add 1 回)。
  • Mutex<T> は排他 (一度に reader-or-writer 1 つ)、.lock() がスレッドをブロック。
  • RwLock<T> は多 reader OR 単 writer — 読みが支配的な時に使う。
  • tokio::sync::Mutex は async-aware 版 — クリティカルセクションが .await を跨ぐ時に使う。
  • Arc<Mutex<T>> は正準の「共有可変状態」パターン — Reth/ExEx の至る所で見る。

中級レッスン 6 (ExEx) で Arc<...> フィールドが 3 つある struct を見ても、「なぜラッパーだらけか」を説明できるはずだ。各ラッパーは、そのコンポーネントを runtime タスク間で共有するための必須部品である。

まとめ(3行)

  • Arc<T> でスレッド間共有、Mutex<T> / RwLock<T> で排他制御、Arc<Mutex<T>> が最頻出パターン、Atomic はロックなしの軽量版。
  • 内部可変性 = Cell / RefCell(シングルスレッド)/ Mutex / RwLock(マルチスレッド)、&self から内側変更したいときに。
  • Tokio async 版(tokio::sync::*)を async タスクで使う、std::sync::Mutex.await 内で持つとデッドロック、次は unsafe Rust。