Day 034 — Rc/ArcやCell/Mutexの違いは、Send/Syncという型のプロパティに現れる

2026-08-31 🟣 上級者 / Phase 3 概念理解 Send/Syncトレイトの理解

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

Day 031〜033ではArc<Mutex<T>>やmpsc::Sender<T>を「型がスレッド間で安全に行き来できる道具」として使ってきました。今日はその裏側、つまりなぜコンパイラはそれが安全だと判断できるのかを扱います。

RustにはSendとSyncという2つのマーカートレイトがあります。マーカートレイトとは、メソッドを1つも持たず、「その型がある性質を満たすこと」だけを表す印です。

pub unsafe auto trait Send {}
pub unsafe auto trait Sync {}
  • Send: その型の値を別のスレッドに所有権ごと移動しても安全であることを示す
  • Sync: その型への共有参照(&T)を複数スレッドから同時に触っても安全であることを示す(T: Sync ⇔ &T: Sendという関係になっている)

重要なのはauto traitという部分です。多くのトレイトはimpl Trait for Typeと明示的に書かないと実装されませんが、Send/Syncはコンパイラが型の構造を見て自動的に判定します。構造体・enumのフィールドが全てSendならその型も自動的にSendになり、1つでも!Sendなフィールドがあれば全体も!Sendになります。

📌
Go/Javaとの決定的な違い: Goでは「このデータをgoroutine間で共有していいか」はプログラマの規律に委ねられており、go vetや-race検出器は実行時(または静的解析ツール)で気づくしかありません。Javaも同様に、スレッド安全性はJavadocのコメントか実行時のConcurrentModificationExceptionで発覚します。Rustではこの判断がコンパイル時の型チェックに埋め込まれているため、スレッド安全でないデータをスレッドをまたいで使おうとした瞬間にコンパイルエラーになります。これが「恐れなき並行性(fearless concurrency)」と呼ばれる所以です。

unsafe auto traitとある通り、型によっては手動でこの自動判定を上書きすることもできます。Rc<T>は中身が全てSendな型(例えばRc<i32>)であっても、あえてSend/Syncを実装しないよう設計されています。理由は、Rcの参照カウントの増減(+= 1 / -= 1)が非アトミック演算だからです。2つのスレッドが同時にRc::cloneすると、カウンタの更新がデータ競合を起こし、カウントがずれて二重解放(use-after-free)につながる可能性があります。一方Arc(Atomically Reference Counted)は、その名の通りカウンタの増減にアトミック命令を使うため、複数スレッドから同時にcloneしても安全であり、Send/Syncになれます。

📝 問題

以下の6つの型について、Sendか・Syncか(あるいは両方か、どちらでもないか)を判定し、それぞれ理由を説明してください。

  1. Arc<i32>
  2. Arc<Mutex<i32>>
  3. Cell<i32>(およびRefCell<i32>)
  4. Mutex<Cell<i32>>
  5. Mutex<Rc<i32>>
  6. Rc<i32>

要件1(判定): 上記6つそれぞれについて Send/Sync の有無を判定し、理由を一言で説明してください。特に3と4の対比(Cell<i32>自体は!Syncなのに、Mutex<Cell<i32>>はSyncになれるのはなぜか)と、4と5の対比(同じMutex<T>でもTがCell<i32>かRc<i32>かで結果が変わるのはなぜか)に注目してください。

要件2(実装): fn assert_send<T: Send>() {} と fn assert_sync<T: Sync>() {} という2つの関数を定義し、要件1で「Send/Syncである」と判定した型についてはこれらの関数を実際に呼び出してコンパイルが通ること自体を証明するコードを書いてください(「Send/Syncでない」と判定した型は呼び出しをコメントアウトし、理由をコメントで残してください)。

要件3(実行時デモ): Arc<Mutex<Cell<i32>>>を4つのスレッドで共有し、それぞれのスレッドがロックを取得して内部のCell<i32>の値をインクリメントするプログラムを書き、最終的な合計値が期待通りになることをassert_eq!で検証してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

Send/Syncは「その型に何のメソッドがあるか」ではなく「その型の内部実装がどういう同期プリミティブを使っているか」で決まります。Rc<T>とArc<T>はAPIの見た目はほぼ同じですが、内部の参照カウンタの更新方法(非アトミック vs アトミック)が違うことを思い出してください。同様にCell<T>/RefCell<T>とMutex<T>もどちらも「内部可変性」を提供する点は同じですが、複数スレッドからの同時アクセスに対する防御手段の有無が違います。

ヒント2 — アプローチ

Mutex<T>がTに対してどんな制約を課すかを、標準ライブラリのソースにある実装で確認してみましょう。実際の定義はおおよそ次の形をしています。

unsafe impl<T: ?Sized + Send> Send for Mutex<T> {}
unsafe impl<T: ?Sized + Send> Sync for Mutex<T> {}

どちらも要求しているのはT: Sendだけで、T: Syncは要求していません。つまり「中身がスレッド間を移動できるなら、ロックというアクセス制御を被せることでスレッド間の共有も安全にできる」という発想です。この非対称性(Syncになるための条件がT: SyncではなくT: Send)が、Cell<i32>(Sendだが!Sync)とMutex<Cell<i32>>(SendかつSync)の関係を作り出しています。

ヒント3 — コード骨格
use std::cell::Cell;
use std::sync::{Arc, Mutex};

fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}

fn main() {
    assert_send::<Arc<i32>>();
    assert_sync::<Arc<i32>>();

    assert_send::<Mutex<Cell<i32>>>();
    assert_sync::<Mutex<Cell<i32>>>();

    // assert_sync::<Cell<i32>>(); // ← ここでコンパイルエラーが出るはず。
                                   //    実際に一度コメントを外してエラーメッセージを読んでみましょう
}

assert_sync::<Cell<i32>>()のコメントを外してcargo buildすると、コンパイラがどんなエラーメッセージを出すか——特に「どのトレイトがどの型に足りないと言っているか」を実際に読むのが今日の理解の核心です。

✅ 模範解答

// cargo new day034_send_sync の後、src/main.rs をこの内容で置き換えて
// `cargo run` で検証できます(外部クレート依存なし、標準ライブラリのみ)。

use std::cell::{Cell, RefCell};
use std::sync::{Arc, Mutex};
use std::thread;

// コンパイル時に「この型がSendか/Syncか」を検査するための小道具。
// 呼び出せた時点で、その型がSend/Syncであることがコンパイラによって証明される。
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}

fn main() {
    // ① Arc<i32>: Send + Sync(i32自体がSend+Syncなので、Arcもそれを引き継ぐ)
    assert_send::<Arc<i32>>();
    assert_sync::<Arc<i32>>();

    // ② Arc<Mutex<i32>>: Send + Sync(Mutexが排他制御を提供するのでSyncになれる)
    assert_send::<Arc<Mutex<i32>>>();
    assert_sync::<Arc<Mutex<i32>>>();

    // ③ Cell<i32> / RefCell<i32>: Sendだが!Sync
    //    (UnsafeCell<T>を内部に持つため、ロックなしの内部可変性はスレッド間で共有できない)
    assert_send::<Cell<i32>>();
    assert_send::<RefCell<i32>>();
    // assert_sync::<Cell<i32>>();    // ← これはコンパイルエラーになる(後述)
    // assert_sync::<RefCell<i32>>(); // ← 同上

    // ④ Mutex<Cell<i32>>: Send + Sync になる
    //    MutexはT: Sendでさえあれば、TがSyncでなくてもSyncを付与する
    assert_send::<Mutex<Cell<i32>>>();
    assert_sync::<Mutex<Cell<i32>>>();

    // ⑤ Mutex<Rc<i32>>: SendでもSyncでもない
    //    RcはSendでないため、Mutexで包んでも「T: Send」の前提が満たせない
    // assert_send::<Mutex<Rc<i32>>>(); // ← コンパイルエラー(後述)
    // assert_sync::<Mutex<Rc<i32>>>(); // ← コンパイルエラー(後述)

    // ⑥ Rc<i32>: SendでもSyncでもない
    // assert_send::<Rc<i32>>(); // ← コンパイルエラー
    // assert_sync::<Rc<i32>>(); // ← コンパイルエラー

    println!("assert_send / assert_sync: 全てのコンパイル時アサーションを通過しました");

    // ── 実行時デモ: Arc<Mutex<Cell<i32>>> はスレッド間で安全に共有できる ──
    let shared = Arc::new(Mutex::new(Cell::new(0)));
    let mut handles = vec![];
    for i in 0..4 {
        let shared = Arc::clone(&shared);
        handles.push(thread::spawn(move || {
            let guard = shared.lock().unwrap();
            let prev = guard.get();
            guard.set(prev + i);
        }));
    }
    for h in handles {
        h.join().unwrap();
    }
    let total = shared.lock().unwrap().get();
    println!("4スレッドでCellを更新した合計: {total}(0+1+2+3=6のはず)");
    assert_eq!(total, 6);
    println!("OK: Mutexの排他制御により、!SyncなCell<i32>でも安全にスレッド間共有できた");
}
▶ 実行結果を見る(rustc / cargo run・実機で3回検証済み、警告0件)
$ rustc -O main.rs -o day034 && ./day034   # (1回目)
assert_send / assert_sync: 全てのコンパイル時アサーションを通過しました
4スレッドでCellを更新した合計: 6(0+1+2+3=6のはず)
OK: Mutexの排他制御により、!SyncなCell<i32>でも安全にスレッド間共有できた

# 2回目・3回目も出力は完全に一致(合計値6は決定的)
🧪
このコードはrustc 1.96.0で警告ゼロでコンパイル・実行し、3回連続で同一の出力(合計値6)を確認済みです。

🪜 Step-by-Step 解説

1
「Send/Syncか」は型のAPIではなく内部実装で決まる
Rc<T>とArc<T>は公開しているメソッド(clone, strong_countなど)がほぼ同じですが、Rcは参照カウンタをCell<usize>(非アトミック)で持ち、ArcはAtomicUsize(アトミック命令)で持っています。この内部実装の違いだけで、Arc<i32>はSend+SyncになれるのにRc<i32>はどちらにもなれません。「型が何をできるか」ではなく「型が内部でどう同期を取っているか」を見る、という視点の転換が今日の核心です。
2
Cell<i32>が!Syncである理由
Cell<T>は内部にUnsafeCell<T>を持ち、get/setでロックなしに値を読み書きできます。もしCell<i32>がSyncだったら、&Cell<i32>を2つのスレッドが同時に持ち、setを同時に呼び出せてしまいます。Cellのsetは排他制御なしの単純なメモリ書き込みなので、これはデータ競合そのものです。だからこそUnsafeCell<T>は根本的に!Syncと定義されており、Cell/RefCellはそれを(意図的に)継承しています。
3
Mutex<Cell<i32>>がSyncになれる理由
Mutex<T>のSync実装が要求しているのはT: Sendのみで、T: Syncではありません。Cell<i32>はSend(別スレッドに所有権ごと持っていくのは安全)なので、この条件を満たします。Mutexはlock()が返すMutexGuardを通じてのみ中身へのアクセスを許可し、同時に1スレッドしかMutexGuardを持てないことを保証します。つまりMutex自身が「複数スレッドからの同時アクセスを防ぐ」というSyncに必要な保証を肩代わりしてくれるため、中身のCell<i32>が本来持っていないSync性を、外側のMutexが獲得できるのです。
4
Mutex<Rc<i32>>がSend/Syncどちらにもなれない理由
Mutex<T>: SendもMutex<T>: Syncも、どちらもT: Sendを要求します。Rc<i32>は非アトミックな参照カウンタを持つため、そもそもSendではありません。Mutexでロックをかけても、Rcをあるスレッドから別スレッドへ移動すること自体(Sendの対象範囲)は何も保護してくれません。ロック機構は「複数スレッドからの同時アクセス」を防ぐものであって、「参照カウンタの非アトミック性」という問題そのものを解決するものではないため、Mutex<Rc<i32>>は!Sendかつ!Syncのままです。

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

Send/Syncが体現しているのは、Rustの「安全性をランタイムの規律ではなく型システムに埋め込む」という一貫した設計思想です。C++やJavaでは「このオブジェクトはスレッドセーフか」はドキュメントに書かれた約束にすぎず、破っても即座にはコンパイラも実行環境も教えてくれません。Rustではこの約束がSend/Syncという型のプロパティになっているため、約束を破るコード(!Sendな値をthread::spawnに渡す等)はそもそもコンパイルが通りません。

さらに興味深いのは、Send/Syncが「構造体を作った瞬間に自動で決まる」という設計です。開発者は普段、自分の作った型がSendかどうかを意識する必要すらありません(フィールドが全てSendならその構造体も自動的にSendになります)。意識すべきタイミングは、Rc/RefCellのような「意図的にSend/Syncを実装しない」型を使うときだけです。これは「デフォルトで安全、危険なことをするときだけ明示的にオプトインする」というRust全体を貫く設計哲学(unsafeブロックの考え方とも共通)の並行処理版と言えます。

📌
Mutex<T>: Sync where T: Send(T: Syncではない)という非対称な制約は、最初は違和感があるかもしれません。しかし「排他ロックというアクセス制御自体がSyncの保証を作り出す」と捉えると自然です。RwLock<T>も同様の考え方で、こちらは読み取りロックが複数同時に取れるためSyncになるためにT: Send + Syncの両方を要求します(読み取り時は複数スレッドが同時に&Tを見るため)。

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

要件2で「Send/Syncでない」と判定した組み合わせを実際にコメントアウトせずに呼び出すと、次のような実際のコンパイルエラーが出ます(rustc 1.96.0で実機検証済み)。

Cell<i32>をassert_syncに渡した場合:

error[E0277]: `Cell<i32>` cannot be shared between threads safely --> fail2.rs:6:19 6 | assert_sync::<Cell<i32>>(); | ^^^^^^^^^ `Cell<i32>` cannot be shared between threads safely | = help: the trait `Sync` is not implemented for `Cell<i32>` = note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` or `std::sync::atomic::AtomicI32` instead note: required by a bound in `assert_sync`

注目すべきはnoteの1行です。コンパイラが「代わりにRwLockかAtomicI32を使え」と具体的な代替案まで提示してくれています。これはエラーメッセージが単なる「ダメです」ではなく、次に何をすべきかまで教えてくれるRustのエラーメッセージ設計の良さが表れている例です。

Rc<i32>をthread::spawnのクロージャにmoveで渡した場合:

error[E0277]: `Rc<i32>` cannot be sent between threads safely --> fail1.rs:6:19 6 | thread::spawn(move || { | ------------- ^------ | | | | _____|_____________within this closure | | | | | required by a bound introduced by this call 7 | | println!("{}", data); 8 | | }); | |_____^ `Rc<i32>` cannot be sent between threads safely | = help: within this closure, the trait `Send` is not implemented for `Rc<i32>` note: required because it's used within this closure note: required by a bound in `spawn`
⚠️
読み方: エラーの主語は常に「どの型が」「どのトレイトを実装していないか」です。「Rc<i32> cannot be sent between threads safely」=「Rc<i32>はスレッド間を安全に移動できない」。この一文だけで原因(Rcが非アトミック参照カウンタを使っている)まではエラーメッセージからは分かりませんが、「RcではなくArcを使う」という定石の修正方針にはこの一文だけで十分たどり着けます。

Mutex<Rc<i32>>をassert_sendに渡した場合、エラーメッセージはMutex<Rc<i32>>ではなく最終的にRc<i32>を名指しします(= note: required for std::sync::Mutex<Rc<i32>> to implement Sendという橋渡しの行が挟まる形)。ラップしている外側の型(Mutex)ではなく、根本原因の内側の型(Rc)までコンパイラが遡って教えてくれる点は、複雑にネストした型のデバッグで非常に助かります。

🌐 他言語との比較

言語スレッド安全性の保証方法Rustとの違い
Go型システムによる強制はなく、go vetや-race検出器(実行時のランタイムチェック)に頼る。goroutine間のデータ共有はプログラマの規律次第RustはSend/Syncという型のプロパティとしてコンパイル時に強制するため、-raceをつけ忘れて本番で初めてデータ競合が発覚する、という事故が原理的に起きない
JavasynchronizedキーワードやJavadocの「このクラスはスレッドセーフです」というコメント上の約束に依存。破ってもConcurrentModificationException等が実行時にしか出ないRustでは「スレッドセーフかどうか」がコメントではなく型そのものの一部になっており、コンパイラが機械的に検査する
C++型自体にスレッド安全性の情報はなく、ドキュメントとレビューで担保する。Clang Thread Safety Analysisのような静的解析ツールは属性を手動で付与する必要があるRustのSend/Syncはauto traitとしてコンパイラが自動的に導出するため、開発者が明示的にアノテーションを書かなくても大半の型で安全性が自動判定される
TypeScript/JavaScriptシングルスレッドのイベントループが前提のため、そもそも「複数スレッドでオブジェクトを共有する」という概念自体が薄い(Web WorkerやWorker Threadsではメッセージのコピー/転送が基本)Rustは複数OSスレッドでメモリを直接共有する設計を前提にしつつ、それでも安全性を型で保証する点が大きく異なる

🏆 実務での使いどころ

  • ライブラリ設計時の意図的な制約: 自作の型を「あえてスレッドをまたがせたくない」場合(例: FFIで扱うOSリソースのハンドル、シングルスレッド前提のキャッシュ)は、PhantomData<*const ()>のような!Send/!Syncなマーカーフィールドを持たせることで、コンパイラに「この型はスレッドをまたげない」と強制させる設計がよく使われる
  • Arc<Mutex<T>> vs Rc<RefCell<T>>の選択: シングルスレッドのGUIアプリやシングルスレッドのasyncランタイム(tokio::task::LocalSet等)ではRc<RefCell<T>>の方がアトミック操作やロック取得のオーバーヘッドがなく高速。マルチスレッドで共有する必要があるかどうかを最初に見極めることが、無駄なロックコストを避ける第一歩になる
  • コンパイルエラーがそのまま設計レビューになる: 「この型をArcで包んでスレッド間共有しようとしたらコンパイルが通らなかった」という経験は、実は「この型の設計にスレッド安全でない要素が紛れ込んでいた」というレビュー結果そのもの。Rustではこのレビューがコンパイラによって強制的に行われる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
SendとSyncを同じ意味だと思い込むどちらも「スレッドセーフ」という漠然とした概念でひとくくりにしてしまうSendは「所有権ごと別スレッドに移動できるか」、Syncは「&Tを複数スレッドから同時に参照できるか」という別々の性質。Cell<i32>のように「移動はOKだが共有はNG」という非対称な型が存在する
Mutex<T>で包めばTが何であっても必ずスレッド間共有できると思い込むMutexが「排他ロック」という強力な保証を提供することから、あらゆる問題を解決してくれると錯覚するMutex<T>のSend/Sync実装はT: Sendを要求する。T自体がSendですらない場合(Rc<i32>等)はMutexで包んでも解決しない。参照カウンタの非アトミック性のような「移動」レベルの問題は、ロックという「共有アクセス制御」の仕組みでは解決できない
unsafe impl Send for MyType {}を安易に書いて回避しようとするコンパイルエラーを消したいという目先の欲求から、unsafeで強制的にトレイトを実装してしまうunsafe impl Sendは「私はこの型がスレッド間を安全に移動できることを保証します」というプログラマ側の宣言。この宣言に誤りがあれば、コンパイラは何も検出できず未定義動作(データ競合)が実行時に発生する。安易に書いてよいものではなく、内部実装が本当にスレッド安全かを精査した上でのみ使う

🚀 次のステップ

  • 発展: PhantomData<Rc<()>>を構造体に含めることで、その構造体を意図的に!Send/!Syncにするテクニックを試してみましょう。「本来はSendにできるはずの型を、設計上あえて!Sendにする」判断がなぜ必要になるか(例: OSのスレッドローカルなリソースをラップする型)を考えてみてください
  • 次回予告: 次回はasync/await基礎を扱います。これまでのstd::threadベースの並行処理から一歩進み、「協調的マルチタスク」としてのFutureの考え方と、なぜasync fnが舞台裏で状態機械(ステートマシン)にコンパイルされるのかを見ていきます

🎯 自己評価

自分の回答

気づき・メモ