レッスン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 のコードは Arc と Mutex で埋め尽くされる。本レッスン前だとラッパーは雑音に見えやすいが、通過後は、非同期タスク間で状態共有を支える必須部品として読める。
問題 — なぜ所有権だけでは不十分か
インデクサを想像してみてください:
- チェーンから新ブロックを受け取る (1 タスク)
- RPC サーバが現在の状態をクエリする (多タスク)
- 定期的に状態をディスクへスナップショットとして書き出す (1 タスク)
3 つすべてが同じインメモリ状態にアクセスする必要がある。素の Rust 所有権は「ただ一人のオーナー」を要求する — でもここでは 本当に 共有アクセスが必要。
答える質問が 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 しない。やるのは:
- アトミックにカウンタをインクリメント
- 同じヒープ確保への新しいポインタを返す
コスト: アトミック add 1 回。内部の T は触らない。だから Reth で Arc::clone(&shared_state) がどこにでもある — 実質タダ。
Arc は読み取り専用共有を提供
Arc<T> 経由では T の &self メソッドしか呼べない。変更不可。設計上そう — 同じ Arc<T> を持つ複数スレッドは同時に読み書きできない、同期なしでは。Rust が型レベルで強制する。
変更が必要なら、最初に T を Mutex か RwLock で包む。
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>> を見たら、それは著者が用途ごとに選択した結果である。いまならその判断を読み解ける。
読み物リスト
- Rust Book 章 16 (Fearless Concurrency) —
ArcとMutexのセクションを読む。 tokio::syncドキュメント —Mutex・RwLock・broadcast・oneshotのページをスキム。各々が Reth のタスクコードの実パターンにマップする。- Reth ソースで
Arc<RwLockとArc<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。