📚 背景知識(読んでから問題へ)
Day 034ではSend/Syncを「型を見て判定する」問題を扱いました。今日はその判定基準を実際に自分の手でコードとして操作します。auto trait(自動導出トレイト)はコンパイラがフィールドを再帰的に見て自動的に付与しますが、生ポインタ*mut T/*const Tのように標準ライブラリが意図的に!Send/!Syncと定義している型を含む構造体は、自動的にスレッド安全でない扱いになります。
この既定を人間の手で上書きするのがunsafe impl Send for MyType {}です。
pub unsafe auto trait Send {}
unsafeが付いている理由は単純で、「コンパイラが自動判定できない領域に、プログラマが責任を持って安全性を保証する」という契約だからです。この契約を書く際は、なぜ安全と言えるかを// SAFETY: ...コメントとして明記するのがRustコミュニティの規約になっています。安全性コメントのないunsafeコードは、コードレビューで真っ先に指摘されるべきアンチパターンです。
Arc<Mutex<T>>を使った実務でよくあるパターン、後者は独自の生ポインタラッパーを書くライブラリコードでよくあるパターンです。📝 問題
以下の2つを実装してください。
Part A: SharedCounter<T>の実装
- 内部は
Arc<Mutex<Vec<T>>>で保持するジェネリックなコンテナSharedCounter<T>を実装する new() -> Self、push(&self, value: T)、len(&self) -> usize、clone_handle(&self) -> Selfを持たせる- ジェネリック型
Tにはスレッドをまたいで安全に扱うために必要なトレイト境界を付ける mainで8スレッドを立ち上げ、各スレッドがi32を10個ずつpushし、全join後にlen() == 80であることを確認する
Part B: 生ポインタを含む型とSendの関係
struct RawBuffer {
ptr: *mut u8,
len: usize,
}
- この
RawBufferをそのまま別スレッドにmoveしようとするとどんなコンパイルエラーになるか、実際に確認する RawBufferが確保した独立バッファを排他的に所有しており他からエイリアスされていないと仮定し、unsafe impl Send for RawBuffer {}を安全性コメント付きで書く
🔍 ヒント(段階的開示)
ヒント1 — 方向性
Part Aは「共有可能な状態=所有権を複数箇所に持たせたい」という要求です。Day 034で学んだ通り、非アトミックなRcではなくアトミック版を使う必要があります。「なぜRcではダメなのか」を思い出すと境界の必要性が見えてきます。Part Bは、標準ライブラリが生ポインタに対してどんな既定のauto trait判定を下しているかを、実際にエラーメッセージで確認するところから始めます。
ヒント2 — アプローチ
Arc<Mutex<Vec<T>>>のpushはself.inner.lock().unwrap().push(value)の形になります。lock()はLockResult<MutexGuard<T>>を返すので.unwrap()でポイズニングを一旦無視します。ジェネリック境界はT: Send + 'staticが必要です。thread::spawnのクロージャが'staticを要求するため、T自体もそれを満たす必要があります。
Part Bはthread::spawn(move || { ... buffer ... })と書いてコンパイルすると、エラーメッセージに`*mut u8` cannot be sent between threads safelyという趣旨の文言が出ます。
ヒント3 — コード骨格
use std::sync::{Arc, Mutex};
use std::thread;
#[derive(Clone)]
struct SharedCounter<T> {
inner: Arc<Mutex<Vec<T>>>,
}
impl<T: Send + 'static> SharedCounter<T> {
fn new() -> Self {
Self { inner: Arc::new(Mutex::new(Vec::new())) }
}
fn push(&self, value: T) {
self.inner.lock().unwrap().push(value);
}
fn len(&self) -> usize {
self.inner.lock().unwrap().len()
}
fn clone_handle(&self) -> Self {
self.clone()
}
}
✅ 模範解答
// cargo new day035_send_sync の後、src/main.rs をこの内容で置き換えて
// `cargo run` / `cargo test` で検証できます(外部クレート依存なし、標準ライブラリのみ)。
use std::sync::{Arc, Mutex};
use std::thread;
/// 複数スレッドから安全に値を追加できるコンテナ。
/// Arc>> をラップすることで:
/// - Arc により所有権(参照カウント)をスレッドをまたいで共有できる
/// - Mutex により内部の Vec への同時アクセスを排他制御する
#[derive(Clone)]
struct SharedCounter<T> {
inner: Arc<Mutex<Vec<T>>>,
}
impl<T: Send + 'static> SharedCounter<T> {
fn new() -> Self {
Self { inner: Arc::new(Mutex::new(Vec::new())) }
}
fn push(&self, value: T) {
// lock() が返す LockResult は「他スレッドがパニックしながらロックを
// 保持していた(=ポイズニング)」場合に Err になる。学習用コードでは
// unwrap で簡略化するが、本番では Err を伝播させるのが望ましい。
self.inner.lock().unwrap().push(value);
}
fn len(&self) -> usize {
self.inner.lock().unwrap().len()
}
fn clone_handle(&self) -> Self {
// Arc::clone なので中身のデータはコピーされず、参照カウントだけ増える
self.clone()
}
}
fn main() {
let counter: SharedCounter<i32> = SharedCounter::new();
let mut handles = Vec::new();
for worker_id in 0..8 {
let counter_handle = counter.clone_handle();
let handle = thread::spawn(move || {
for i in 0..10 {
counter_handle.push(worker_id * 10 + i);
}
});
handles.push(handle);
}
for handle in handles {
handle.join().expect("thread panicked");
}
assert_eq!(counter.len(), 80);
println!("total pushed = {}", counter.len());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pushes_from_multiple_threads_are_all_recorded() {
let counter: SharedCounter<i32> = SharedCounter::new();
let mut handles = Vec::new();
for worker_id in 0..8 {
let counter_handle = counter.clone_handle();
handles.push(thread::spawn(move || {
for i in 0..10 {
counter_handle.push(worker_id * 10 + i);
}
}));
}
for handle in handles {
handle.join().unwrap();
}
assert_eq!(counter.len(), 80);
}
}
// ---- Part B: 生ポインタと Send ----
struct RawBuffer {
ptr: *mut u8,
len: usize,
}
impl RawBuffer {
fn new(len: usize) -> Self {
let mut v = std::mem::ManuallyDrop::new(vec![0u8; len]);
Self { ptr: v.as_mut_ptr(), len }
}
}
// SAFETY: RawBuffer はヒープ上の連続領域を「排他的に」所有している。
// - この構造体の外部にポインタのコピーは存在しない(Clone を実装していない)
// - 生成後は Drop まで他のコード経路からアクセスされない
// そのため、所有権ごと別スレッドに move しても、元のスレッドと新しいスレッドが
// 同時にこのバッファへアクセスすることはあり得ず、データ競合は起こり得ない。
// よって Send を手動で実装しても安全である。
unsafe impl Send for RawBuffer {}
impl Drop for RawBuffer {
fn drop(&mut self) {
unsafe {
// ManuallyDrop で抜いた分の所有権をここで正しく回収して解放する
let _ = Vec::from_raw_parts(self.ptr, self.len, self.len);
}
}
}
cargo testでpushes_from_multiple_threads_are_all_recordedが通ることを確認済み。ワーカー数8×各10件=80件がスレッド間で1件も欠落・重複せずVecに積まれます(Mutexの排他制御によりpush同士が競合しないため)。🪜 Step-by-Step 解説
ArcであってRcではないのかRc<T>の参照カウントは非アトミック(通常の+1/-1)な命令で実装されている。2つのスレッドが同時にcloneとdropを行うとカウンタが競合し、二重解放(use-after-free)につながりうる。そのためRc<T>は意図的に!Sendとしてマークされており、thread::spawnに渡そうとした瞬間にコンパイルエラーになる。Arc<T>(Atomically Reference Counted)はカウンタの増減にアトミック命令を使うためSend/Syncになれる。
Mutexが必要なのかArc<T>は「複数箇所からTへの不変参照を共有する」ことしか許さない。可変操作をしたいので、内部可変性を提供するMutex<T>を挟む。Mutex<T>::lock()が返すMutexGuardはロックが保持されている間だけ有効な「排他的な可変アクセス権」を表現しており、スコープを抜けると自動的にアンロックされる(RAIIパターン)。
T: Send + 'static境界がなぜ必要かthread::spawnのクロージャはFnOnce() -> T + Send + 'staticを要求する。クロージャはcounter_handle(=SharedCounter<T>)をキャプチャしているので、SharedCounter<T>がSendである必要があり、それには中身のTがSendである必要がある。'staticはクロージャが親スレッドのスタック上の参照を借用していないことを保証する。
RawBufferが自動的に!Sendになる理由生ポインタ
*mut u8/*const u8は標準ライブラリで意図的に!Send/!Syncと定義されている。理由は「生ポインタが指す先の所有権・エイリアシング状況をコンパイラが一切追跡できない」ため。auto traitはフィールドを再帰的に見るので、*mut u8を1つでもフィールドに持つ構造体は自動的に!Sendになる。これを打ち消すにはunsafe impl Sendで明示的に安全性を宣言するしかない。
💡 設計思想・なぜこう書くのか
Rustの並行性の安全性は「実行時に頑張ってレースを検出する」のではなく、「そもそも危険な操作を型として表現できないようにする」というゼロコスト抽象化の思想に基づく。Send/Syncはランタイムに一切のオーバーヘッドを持たないマーカー(ゼロサイズの制約情報のみ)でありながら、コンパイル時にスレッド安全性の証明を強制する。これは「実行時コストを払わずに安全性を得る」というRustの核心的な設計哲学の並行処理版であり、所有権システムがメモリ安全性を静的に保証するのと全く同じ発想がスレッド安全性にも適用されている。
unsafe impl Sendという抜け道が用意されているのも重要な設計判断で、「コンパイラは保守的に判断するが、人間がより多くの文脈(この生ポインタは本当に排他所有されている、等)を知っている場合はオーバーライドできる」という、安全側に倒しつつ表現力を失わないバランスになっている。🛑 コンパイルエラーが出た場合
Part Bでunsafe impl Sendを付ける前に、以下のようなコードを書いてRawBufferをそのまま別スレッドにmoveしようとするとコンパイルエラーになります。
fn try_send_raw_buffer() {
let buf = RawBuffer::new(16);
thread::spawn(move || {
println!("len = {}", buf.len);
});
}
*mut u8がSendではないので、それをフィールドに持つRawBufferもSendではなく、thread::spawnのF: Send境界を満たせない」というエラー連鎖になっている。修正方針は2つ。①生ポインタをやめてVec<u8>など標準のSendな型で表現し直す(王道)。②排他所有が保証できると確信を持てるならunsafe impl Send for RawBuffer {}を安全性コメント付きで書く(今回の模範解答の方法)。🌐 他言語との比較
| 言語 | スレッド間データ共有の安全性チェック |
|---|---|
| Go | 型システムでは強制されない。go vetの一部検出や-raceフラグでの実行時検出に頼る。goroutine間でmapを無防備に共有してもコンパイルは通る |
| Java | synchronized/volatileの付け忘れは実行時までわからない。「この型はスレッドをまたいで良いか」という区別は型に現れない |
| C++ | std::shared_ptrは参照カウントの操作自体はスレッドセーフだが、指している先のオブジェクトへの同時書き込みは何も守ってくれない。Rc相当・Arc相当の区別も型では表現されない |
| TypeScript | シングルスレッド(Web Worker間はメッセージパッシングのみ)なので、この種のデータ競合自体が言語レベルではほぼ問題にならない |
| Rust | Send/Syncという2つのマーカートレイトにより「安全にスレッドをまたげるか」がコンパイル時に型として表現され、違反はコンパイルエラーになる |
Go・Java・C++経験者にとって最大の学びは、「スレッド安全性はドキュメントに書くものではなく、型シグネチャに刻み込まれるものである」という発想の転換です。
🏆 実務での使いどころ
- Webサーバー(axum/actix-web等)でリクエストハンドラ間に共有するアプリケーション状態(DB接続プール、キャッシュ等)は
Arc<Mutex<T>>やArc<RwLock<T>>で表現するのが定番パターン tokio::spawnされたタスク間でデータを受け渡す際も、渡す型がSend + 'staticであることが要求されるため、この境界エラーは非同期プログラミングで日常的に遭遇する- FFI(C言語ラッパー)や独自メモリ管理を書くライブラリ(独自アロケータ、ゼロコピーパーサー等)では、生ポインタを含む型に対して意図的に
unsafe impl Send/Syncを書く場面が頻出する。安全性コメントの書き方はコードレビューで真っ先にチェックされる部分 !Sendを意図的に維持したい場面(特定のスレッドでしか使ってはいけないハンドル等)では、PhantomData<*const ()>をフィールドに追加してauto traitを意図的に打ち消すテクニックもよく使われる
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
Arc<T>にすれば自動的にスレッド安全になると思い込む | Arcという名前から「並行処理用の型」という印象だけで判断してしまう | Arc<T>はあくまで所有権の共有(参照カウント)を安全にするだけ。中身Tへの可変アクセスの排他制御は別途Mutex/RwLock等が必要 |
Mutex<T>があればTが!Sendでも平気だと思う | Mutexが「安全化してくれる魔法の箱」に見える | Mutex<T>自体がSendになるにはT: Sendが必要。中身がSendでなければそもそもArc<Mutex<T>>を他スレッドに渡せない |
unsafe impl Sendを「コンパイラを黙らせる呪文」として使う | エラーを消すことだけが目的化する | unsafe implは「型システムの代わりに人間が安全性を数学的・論理的に証明する」宣言。安全性コメントなしで書くのはコードレビューで即座に指摘されるべきアンチパターン |
SendとSyncを同じものだと混同する | どちらも「スレッド安全」という漠然とした理解で片付けてしまう | Send=所有権の移動が安全、Sync=参照の共有が安全、で意味が異なる。Rc<T>は!Sendかつ!Syncだが、Cell<T>はSendだが!Sync |
🚀 次のステップ
- 発展:
PhantomData<*const ()>を使って、フィールドには生ポインタを持たないが意図的に!Sendにしたい型を作ってみましょう(例: 特定のスレッドに固定したいGUIハンドル) - 次回予告: async/await基礎 — 協調的マルチタスクとしての
Futureの考え方へ進みます(OSスレッドではなく軽量タスクによる並行性)