Day 030 — std::threadによる並行処理基礎(Rc/RefCellがなぜSendでないか)

2026-08-27 🟣 上級者 / Phase 3 デバッグ・コードレビュー std::threadによる並行処理基礎

📚 背景知識(読んでから問題へ)

昨日(Day 029)はthread::spawnが要求するF: Send + 'staticのうち、'static(クロージャが借用したデータより長生きしないか)に焦点を当てました。今日はもう半分の主役、Sendに焦点を当てます。

Sendはマーカートレイト(メソッドを持たず、コンパイラに「この型の所有権を別スレッドへ安全に移動できる」ことだけを伝える印)です。ほとんどの型は自動的にSendを実装しますが、いくつかの型は意図的にSendを実装しません。その代表例がRc<T>(参照カウント式スマートポインタ、Day 46で詳しく学びます)です。

Rc<T>は内部の参照カウンタをアトミック演算なし(ロックなしの単純な整数インクリメント/デクリメント)で更新することで高速化しています。もし複数スレッドが同時に同じRc<T>をclone()すると、カウンタの更新が競合し、カウントがずれて「解放済みメモリへの参照が残る」「二重解放」といったメモリ安全性違反が起こり得ます。Rustはこれを実行時に検出するのではなく、Rc<T>にSendを実装しないことで、複数スレッドをまたぐコード自体をコンパイルさせないという設計を選んでいます。

📌
これは他言語出身のエンジニアが最初につまずきやすいポイントです。「同じ参照カウント式ポインタなら、複数スレッドから触っても動くはず」という直感(例えばPythonのthreadingはGILがあるため素朴な共有がそれなりに動いてしまう)が、Rustではコンパイルエラーとして即座に否定されます。

📝 問題

以下のコードは「4つのスレッドで共有カウンタを1ずつインクリメントし、最終的に4にする」という意図で書かれていますが、コンパイルが通りません。

// このコードはコンパイルエラーになる
use std::cell::RefCell;
use std::rc::Rc;
use std::thread;

fn count_up_wrong() -> i32 {
    let counter = Rc::new(RefCell::new(0));
    let mut handles = Vec::new();

    for _ in 0..4 {
        let counter_clone = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            *counter_clone.borrow_mut() += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    *counter.borrow()
}

fn main() {
    println!("{}", count_up_wrong());
}
  1. 実際に出るコンパイルエラーを読み、Rc<RefCell<i32>>の何がthread::spawnの要求と衝突しているのかを説明してください。
  2. スレッド間で安全に共有できる型に差し替えて、正しくコンパイル・実行できるように修正してください(最終出力が4になること)。

🔍 ヒント(段階的開示)

ヒント1 — 方向性
  • thread::spawnが要求する境界はF: Send + 'staticでした(Day 029参照)
  • クロージャ自身がSendであるためには、クロージャがキャプチャする値(ここではcounter_clone: Rc<RefCell<i32>>)もSendでなければなりません。Rc<T>はそもそもSendを実装しているか、標準ライブラリのドキュメントで確認する視点を持ってください
ヒント2 — アプローチ
  • 「複数スレッドから安全に共有できる参照カウント」と「複数スレッドから安全に中身を変更できるセル」という2つの役割を、それぞれスレッドセーフ版に置き換える必要があります
  • std::syncモジュールにはstd::rc::Rcに対応するstd::sync::Arc(atomically reference counted)があります。std::cell::RefCellに対応するものは何か、std::syncモジュールを探してみてください
ヒント3 — コード骨格
use std::sync::{Arc, Mutex};
use std::thread;

fn count_up_fixed() -> i32 {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = Vec::new();

    for _ in 0..4 {
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut guard = /* ??? Mutexをロックして中身への可変アクセスを得る */;
            *guard += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    *counter.lock().unwrap()
}

(Arc<Mutex<T>>の詳しい使い方・ロックの仕組みは明日 Day 031「Arc<Mutex<T>>による共有状態管理」で本格的に扱います。今日は「なぜこの置き換えが必要か」をSendの観点から理解できれば十分です。)

✅ 模範解答

// cargo new day030_send_debug の後、src/main.rs をこの内容で置き換えて
// `cargo run` で検証できます。

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

// 修正版: Rc<RefCell<i32>> ではなく Arc<Mutex<i32>> を使う。
// Arc はスレッドセーフな(アトミック演算を使う)参照カウント、
// Mutex はロックを介した排他的な内部可変性を提供する。
fn count_up_fixed() -> i32 {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = Vec::new();

    for _ in 0..4 {
        // Arc::clone はカウンタのアトミックなインクリメントのみを行う軽量操作。
        // 複製されるのは「同じヒープ領域を指すハンドル」であって、中身のコピーではない。
        let counter_clone = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            // lock() はそのスレッドが排他ロックを獲得するまでブロックし、
            // MutexGuard<i32> を返す(スコープを抜けると自動でロック解放)。
            let mut guard = counter_clone.lock().unwrap();
            *guard += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    *counter.lock().unwrap()
}

fn main() {
    let result = count_up_fixed();
    assert_eq!(result, 4);
    println!("count_up_fixed(): {}", result);
    println!("OK");
}
▶ 実行結果を見る(rustc / cargo run・実機で検証済み)
$ cargo run
count_up_fixed(): 4
OK
🧪
このコードはrustcで単体コンパイル・実行し、上記の出力とOKが得られることを確認済みです。
💬
原因説明: Rc<T>は参照カウンタの更新に通常の(アトミックでない)整数演算を使っています。もし2つのスレッドが同時にRc::clone/ドロップを行うと、カウンタの読み取り・加算・書き込みが競合し、実際のカウント数と食い違う可能性があります(最悪の場合、カウントが早く0になり参照先メモリが解放された後にアクセスするuse-after-freeが起こり得ます)。Rustの標準ライブラリはこの危険性を「Rc<T>はSendを実装しない」という形で型システムに刻んでおり、Rc<T>を(直接であれRefCell越しであれ)複数スレッドをまたいで使おうとするコードは、危険な操作をランタイムで検出するのではなく、そもそもコンパイルさせないことで防いでいます。

🪜 Step-by-Step 解説

1
エラーの発生源を特定する
問題のコードをコンパイルすると、thread::spawnの呼び出し箇所で「クロージャがSendを実装していない」というエラーが出ます。クロージャがSendを実装できない理由は、キャプチャしているcounter_clone: Rc<RefCell<i32>>自体がSendを実装していないためです(コンパイラは「クロージャがキャプチャする全フィールドがSendである場合のみ、クロージャ自身も自動的にSendになる」という自動導出ルールを使っています)。
2
Rc → Arcへの置き換え
std::sync::Arc<T>(Atomically Reference Counted)はRc<T>と同じAPI(clone()で参照カウントを増やす、スコープを抜けると自動でデクリメント)を持ちながら、内部でCPUのアトミック命令を使ってカウンタを更新します。これにより複数スレッドから同時にclone/ドロップされてもカウントが正確に保たれ、Send(およびSync、Day 032で扱います)を実装しています。
3
RefCell → Mutexへの置き換え
RefCell<T>の借用チェック(borrow()/borrow_mut())は単一スレッド内でのみ有効な、実行時借用チェックの仕組みです。複数スレッドから同時にborrow_mut()された場合の排他制御機構を持たないため、RefCell<T>もSyncを実装していません。std::sync::Mutex<T>はOSレベルのロック機構を使い、.lock()を呼んだスレッドだけが中身への&mutアクセス(MutexGuard<T>)を得られることを保証します。他のスレッドは先にロックを持つスレッドが解放するまでブロックされます。
4
lock().unwrap()の意味
Mutex::lock()はLockResult<MutexGuard<T>>(実体はResult<MutexGuard<T>, PoisonError<MutexGuard<T>>>)を返します。Err(Poison状態)になるのは、ロックを保持していた別スレッドがそのロックを解放する前にpanicした場合です。今回は各スレッドがpanicしない前提なのでunwrap()で問題ありません。

💡 設計思想・なぜこう書くのか

Rc<T>にSendを実装しない、RefCell<T>にSyncを実装しない、という判断は「危険な可能性がある操作を、デフォルトで塞いでおく」というRustの一貫した設計哲学の表れです。C++のstd::shared_ptrは(参照カウント自体はアトミックですが)中身への同時アクセスを型システムでは一切防いでくれず、「複数スレッドから安全に使えるかどうか」はドキュメントとプログラマの記憶に委ねられます。

Rustでは「型がSend/Syncを実装しているかどうか」というコンパイル時に検証可能な事実に、その判断を落とし込んでいます。今回のエラーは煩わしく感じるかもしれませんが、実際には「あなたが書こうとしたコードは、実行タイミング次第でメモリ破壊を起こす可能性がある」というランタイムでは気づきにくいバグを、コードを書いた瞬間に教えてくれています。

🛑 コンパイルエラーが出た場合

問題文のコードをそのままコンパイルすると、次のようなエラーになります(Rustのバージョンにより文言は多少変わります)。

error[E0277]: `Rc<RefCell<i32>>` cannot be sent between threads safely --> src/main.rs:10:23 | 10 | let handle = thread::spawn(move || { | ^^^^^^^^^^^^^ `Rc<RefCell<i32>>` cannot be sent between threads safely | = help: within `[closure@src/main.rs:10:37: 10:44]`, the trait `Send` is not implemented for `Rc<RefCell<i32>>` note: required because it's used within this closure --> src/main.rs:10:37 | 10 | let handle = thread::spawn(move || { | ^^^^^^^ note: required by a bound in `thread::spawn` --> .../std/src/thread/mod.rs | | pub fn spawn<F, T>(f: F) -> JoinHandle<T> | ----- required by a bound in this function | where | F: FnOnce() -> T, | F: Send + 'static, | ^^^^ required by this bound in `spawn`

読み方: E0277は「トレイト境界が満たされていない」ことを示す汎用エラーコードです。1行目の「Rc<RefCell<i32>> cannot be sent between threads safely」が結論、note:の連鎖が「なぜその境界が必要になったか」をthread::spawnの定義まで遡って説明してくれています。注意点として、このエラーメッセージはmoveの追加を提案してきません——Day 029の'staticエラーとは異なり、moveを付けてもRc自体がSendでない事実は変わらないため、コンパイラも「型そのものを変えるしかない」と判断しているからです。

🌐 他言語との比較

言語同種の機構Rustとの違い
C++std::shared_ptr(参照カウント自体はアトミック)とstd::unique_ptrshared_ptrは複数スレッドから安全にcloneできるが、指している中身への同時アクセスは何も守ってくれない。今回のような「カウンタは安全だが中身は危険」という区別を型システムでは表現できない
Javajava.util.concurrent以下のスレッドセーフコレクション vs 通常のHashMap等「このクラスはスレッドセーフか」はJavadocのコメントやクラス名の慣習(Concurrent接頭辞など)に頼るしかなく、コンパイラは強制しない。誤って非スレッドセーフなコレクションを共有しても普通にコンパイルが通る
Goチャンネルによるメッセージパッシングを推奨("Don't communicate by sharing memory; share memory by communicating")変数の共有自体は言語として自由に許されており、go vetや-raceのような実行時/静的解析ツールでデータ競合を検出する方針。RustはSend/Syncという型でそもそも共有不可能な状態をコンパイルさせない、という一段階手前で防ぐ
PythonGIL(Global Interpreter Lock)によりバイトコードレベルの排他が暗黙に効くため、素朴な共有カウンタもそれなりに動いてしまう「動いてしまう」ことが逆に危険で、multiprocessingやGIL解除を伴うC拡張と組み合わせた途端にデータ競合が顕在化する。Rustはこの「たまたま動く」状態を型エラーとして最初から拒否する

🏆 実務での使いどころ

  • 複数ワーカースレッドで共有カウンタ・共有キャッシュ・共有設定値にアクセスするサーバーサイドプログラムでは、Arc<Mutex<T>>(または読み取り優先ならArc<RwLock<T>>)がほぼ定番の組み合わせになる
  • ライブラリのAPI設計時、ある型にSend/Syncを実装するかどうかの判断(あるいは意図的に実装させないPhantomDataを使ったオプトアウト)は、そのままAPI利用者に対する並行処理安全性の契約になる
  • コンパイルエラーE0277 ... cannot be sent between threads safelyは実務のRust開発でも頻出するため、「Rc/RefCellが出てきたらArc/Mutexに置き換える」という反射的な対応をここで身につけておくと後々の学習が楽になる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
RcをArcに変えるだけで直ると思い込み、RefCellをそのまま残してしまうエラーメッセージの主語がRc<RefCell<i32>>全体なので、どちらが原因か切り分けにくいArc<RefCell<i32>>も依然としてコンパイルエラーになる。Sendの問題はArcで解決するが、RefCellが持つ「複数スレッド同時アクセス時の排他制御なし」というSyncの問題は別途Mutex(またはRwLock)への置き換えが必要
Arc<Mutex<T>>にすれば常に安全で高速だと思い込むエラーが消えてコンパイルが通ると「解決した」と感じてしまうMutexはロック獲得のたびに実行時コストが発生し、ロック範囲が広いと並列度が下がる(他スレッドが待たされる)。今回のように短時間のインクリメントなら実務ではstd::sync::atomic::AtomicI32のようなロックフリーな型の方が適切な場合も多い(atomic型はDay 118で扱う)
Pythonなど他言語での「参照カウント式オブジェクトは大体スレッド間で共有しても大丈夫」という感覚をそのまま持ち込むGILのような言語ランタイム側の暗黙の保護に慣れているRustにはそうしたランタイム側の保護がない代わりに、型システムがコンパイル時に安全性を保証する。「動くかどうか試してみる」のではなく「型がSend/Syncを実装しているか」を確認する習慣に切り替える必要がある

🚀 次のステップ

  • 発展: count_up_fixedをstd::sync::atomic::AtomicI32とfetch_addを使ってMutexなしで書き直すとどうなるか調べてみましょう(ロックを使わない「ロックフリー」なカウンタです。詳細はPhase 3後半のlock-freeテーマで扱います)
  • 次回予告: 次回はいよいよArc<Mutex<T>>による共有状態管理を本格的に扱います。今日「なぜ必要か」をSend/Syncの観点から理解したので、明日はMutexのロック機構・デッドロックの避け方・RwLockとの使い分けを深掘りします

🎯 自己評価

自分の回答

気づき・メモ