Rust の Send と Sync のしくみ

「このデータを別スレッドに渡していい?」をコンパイラに判定させる 2 つの目印

題材: Notifier トレイトと NotifyFuture

1. 背景 — スレッドは「同時に動く分身」ここを押さえないと Send も Sync も暗号にしか見えない

プログラムは普通、上から順に 1 本の線で進みます。この 1 本の線を スレッド と呼びます。1 件の障害アラートを Slack にもメールにも PagerDuty にも送り届けるような処理では、1 本ずつ順番にやると遅いので、線を何本も同時に走らせます。

ここで事故が起きます。2 本の線が同じメモリを同時に触ると、値が壊れます。たとえば「カウンタを 1 増やす」という処理は、機械語のレベルでは 読む → 足す → 書く の 3 手順です。2 本のスレッドが同時にやると、こうなります。

スレッド A 1. 読む → 5 3. 書く → 6 スレッド B 2. 読む → 5 4. 書く → 6 カウンタ = 6 本当は 7 のはずが 6
2 増やしたのに 1 しか増えない。これがデータ競合

C++ や Java では、この事故は実行してみるまで分かりません。しかも毎回起きるとは限らないので、再現できないバグとして残ります。Rust は この事故をコンパイル時に禁止する 道を選びました。その仕掛けが SendSync です。

キモ: SendSync は、メソッドを 1 つも持たない空っぽのトレイトです。実行時には何もしません。「この型は安全ですよ」とコンパイラに伝えるだけの目印なので マーカートレイト と呼ばれます。

2. Send — 荷物を丸ごと引っ越しできる印所有権をスレッドをまたいで移動してよいか

T: Send は「T の値を、まるごと別のスレッドに引っ越しさせてよい」という意味です。引っ越しなので、元のスレッドにはもう残りません。渡した瞬間から、触れるのは新しいスレッドだけです。

スレッド A もう空っぽ スレッド B 値の所有権 move
Send は「引っ越し可」の印。同時に 2 か所には存在しない

ほとんどの型は Send です。i32StringVec<Alert>、どれも引っ越しできます。では 引っ越しできない型 とは何でしょう。代表は Rc<T> です。

Rc は Send ではない

Rc は参照カウントを普通の整数で持っています。2 スレッドから同時に増減させると、冒頭の図と同じ壊れ方をします。カウンタが早く 0 になると、まだ使っている側の足元でメモリが解放されます。

Arc は Send

Arc は同じ機能を、CPU が保証する不可分な命令でカウントします。だから複数スレッドから触っても壊れません。だから Arc には Send が付いています。

3. Sync — 同じ物を複数人で覗いていい印共有参照をスレッドをまたいで配ってよいか

T: Sync は「1 つの T を、複数のスレッドから同時に参照してよい」という意味です。引っ越しではなく、置いたまま &T という覗き窓を配るイメージです。

値は 1 つだけ 動かさずここに置く スレッド A スレッド B スレッド C スレッド D &T &T &T &T
Sync は「覗き窓を配ってよい」の印

ここで公式めいた 1 行を覚えると、あとが全部つながります。

定義そのもの: T: Sync とは &T: Send のこと。つまり「覗き窓を別スレッドに渡してよいなら、その型は Sync」。Rust の標準ライブラリでも、この 1 行がそのまま実装になっています。

Sync でない代表格は RefCell<T> です。RefCell は「今このデータを何人が借りているか」を普通の整数で数え、実行時にチェックします。複数スレッドから同時に借りると、そのカウンタが壊れ、チェックをすり抜けて可変参照が 2 本できてしまいます。だから RefCell は Sync ではありません。代わりに使うのが Mutex で、これは鍵をかけて 1 人ずつ通すので Sync です。

SendSync理由
i32, String, Vec<T>中身が普通のデータだけ
Rc<T>××参照カウントがスレッド安全でない
Arc<T>カウントが不可分命令。ただし中身の T も Send + Sync が条件
RefCell<T>×引っ越しは安全だが、同時に覗くと借用カウンタが壊れる
Mutex<T>鍵で 1 人ずつに制限する
MutexGuard<T>×鍵を取ったスレッドと返すスレッドが違うと OS の前提が壊れる

RefCellMutexGuard が正反対なのが面白いところです。Send と Sync は片方が成り立てばもう片方も成り立つ、という関係ではありません。独立した 2 つの質問 です。

4. Send と Sync の関係、そして自動で付く仕組み誰も書いていないのに、なぜ付いているのか

ここまで impl Send for MyType のようなコードを 1 行も書いていません。それでも Vec<Alert> は Send です。理由は、この 2 つが 自動トレイト だからです。

自動トレイトの規則はたった 1 つ: 構造体やトレイトのフィールドが すべて Send なら、その型も自動的に Send。1 つでも Send でないフィールドが混ざれば、その型は Send を失う。Sync も同じ。

struct Good name: String  Send ids: Vec<u32>  Send → Good も自動で Send 書くコードはゼロ struct Bad name: String  Send cache: Rc<Data>  not Send → Bad は Send にならない 1 つ混ざるだけで失格
汚染が伝播する仕組み。だから明示的に書く必要がない

ただし dyn Trait は例外です。Box<dyn Future> と書いた時点で、中身の具体的な型はコンパイラから見えなくなります。見えないものは自動判定できないので、Rust は安全側に倒して「Send ではない」とみなします。だから dyn Trait のときだけは、自分で + Send と書く必要がある のです。これが今回のコードの主役です。

5. なぜ Future に Send が要るのかasync のタスクはスレッドを乗り換える

tokio のような非同期ランタイムでは、タスクは .await のたびに中断します。そして再開するとき、最初と違うスレッドで再開することがあります。これをワークスティーリングと呼び、暇なスレッドが忙しいスレッドから仕事を横取りする仕組みです。

ワーカースレッド 1 notify 開始。HTTP リクエスト送信 .await で中断。状態を箱に保存 別の仕事へ移る この Future はもう手元にない ワーカースレッド 2 レスポンス到着。Future を受け取る poll して続きから再開 ステータスコードを確認 Ok(()) を返す Future が スレッドを移動
Future そのものが引っ越す。だから Future 自身に Send が必要

async fn をコンパイルすると、中断地点ごとの状態を持つ隠れた構造体になります。.await をまたいで生きているローカル変数は、すべてこの構造体のフィールドになります。そして第 4 章の規則が効きます。中に Rc を 1 つ持ち越しただけで、その Future は Send を失い、tokio::spawn に渡せなくなります。

// これは Send な Future
async fn ok() {
    let url = String::from("https://hooks.example.com/T0/B0");
    http_post(&url).await;       // String は Send なので問題なし
}

// これは Send でない Future
async fn ng() {
    let rc = Rc::new(vec![1, 2, 3]);
    http_post("...").await;      // .await をまたいで rc が生きている
    println!("{:?}", rc);        // → この Future は Send を失う
}

6. 実際のコードを読む複数のサービスに通知する処理を題材に

ここまでの道具で、実際のコードが読めるようになります。題材は 複数のサービスに通知 です。1 件の障害アラートを、Slack にも、メールにも、PagerDuty にも同時に送りたい。送り先ごとに喋るプロトコルはまったく違いますが、呼ぶ側からは「アラートを渡すと送ってくれる何か」に見えてほしい。トレイトの出番です。

まず素直に書いてみる

pub struct Alert { pub title: String, pub body: String }

pub trait Notifier {
    fn name(&self) -> &'static str;
    async fn notify(&self, alert: &Alert) -> Result<()>;   // Rust 1.75 以降はこう書ける
}

Rust 1.75 から async fn はトレイトの中に直接書けます。これで完成に見えます。実際、SlackNotifier を 1 つ持って直接呼ぶだけなら、このまま動きます。

破綻するのは、送り先をまとめて配列に持った瞬間です。

let notifiers: Vec<Arc<dyn Notifier>> = vec![ /* ... */ ];
//                     ^^^^^^^^^^^^ ここでコンパイルエラー
error[E0038]: the trait `Notifier` is not dyn compatible
   |
   |     let notifiers: Vec<Arc<dyn Notifier>> = vec![ /* ... */ ];
   |                                 ^^^^^^^^ `Notifier` is not dyn compatible
   |
note: for a trait to be dyn compatible it needs to allow building a vtable
   |
   |     async fn notify(&self, alert: &Alert) -> Result<()>;
   |              ^^^^^^ ...because method `notify` is `async`
   = help: consider moving `notify` to another trait
なぜ dyn にできないのか: async fn の戻り値は「コンパイラが自動で作った、名前のない構造体」です。Slack 版とメール版では、中で持つ変数も違えばサイズも違います。一方 dyn Trait は「関数のアドレスを並べた表」を引いて呼ぶ仕組みなので、戻り値のサイズが実装ごとに違うと表が作れません。だから async fn を持つトレイトは dyn にできない、と Rust は断ります。

断られた理由を 1 つずつ潰す

ここから先は、コンパイラの言い分に手作業で答えていく作業です。答えるたびに部品が 1 つ増えて、4 回で目的の型ができあがります。

1

サイズが決まらない → Box に入れる

実装ごとに Future の大きさがバラバラなのが問題でした。ヒープに置いてしまえば、戻り値は ポインタ 1 個ぶんに揃います。async fn をやめて、自分で Box<dyn Future<Output = Result<()>>> を返す形に書き換えます。

2

Future は動かされたくない → Pin で留める

Future の中身は自分自身への参照を持つことがあります(let s = ...; f(&s).await; のような形)。ヒープ上の別番地へ動かすと、その参照が迷子になります。poll の引数がそもそも Pin<&mut Self> なので、Pin<Box<...>> が事実上の定型になります。

3

並列に走らせたい → + Send を書く

第 4 章の例外がここで効きます。dyn にした時点で中身が見えなくなり、Send の自動判定が働きません。3 か所へ同時に送るには tokio::spawn に渡す必要があるので、自分で約束を書き足します

4

self とアラートを借りたまま走る → + 'a を書く

notify(&self, alert: &Alert) は、どちらも借りたまま非同期処理を始めます。dyn Trait は何も書かないと 'static(永久に生きる)扱いになるので、借り物を持ち込めません。「'a の間だけ有効」と幅を指定します。

積み上がった結果

pub type NotifyFuture<'a> =
    Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;

/// 1 件のアラートを 1 つの送り先へ送り届ける。
/// Slack は HTTP、メールは SMTP、PagerDuty は独自 API と中身は全然違うが、
/// 呼ぶ側からは同じ形に見える。
pub trait Notifier: Send + Sync {
    fn name(&self) -> &'static str;
    fn notify<'a>(&'a self, alert: &'a Alert) -> NotifyFuture<'a>;
}

この 1 行は、誰かが凝った書き方をしたのではなく、「実装ごとに違う非同期処理を、1 つの箱にまとめて並列に走らせたい」という要求から、逃げ場なく決まった形です。4 つの部品はそれぞれ別の理由で付いています。

部品なくすと困ること
Box実装ごとに Future のサイズが違い、戻り値の型が決まらない
Pin自己参照を持つ Future を動かして、参照が迷子になる
+ Sendtokio::spawn に渡せず、並列に送れない
+ 'a'static 扱いになり、&self&Alert を持ち込めない
Send と Pin は逆を言っているように見えて矛盾しない。Send は「Box ごとスレッド間で引っ越してよい」、Pin は「Box の中身をヒープ上の別番地へ動かすな」。移動を禁じているレイヤーが違います。箱ごと運ぶのは自由、箱から中身を出して別の箱に入れ替えるのが禁止、と考えると腑に落ちます。

寄り道 — Output = って何を言っているのか

Future<Output = Result<()>>Output = は、じつは Vec<T>T のような普通の型引数とは別ものです。これは 関連型 (associated type) という仕組みで、Future トレイトの定義を見ると正体が分かります。

trait Future {
    type Output;                 // ← これが関連型。名前だけ決めて、中身は実装側が埋める
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

type Output; は「この Future が完成したときに出てくる値の型を、実装するときに 1 つ決めてください」という穴です。そして .await の結果の型が、まさにこの Output になります。

let sent: Result<()> = slack.notify(&alert).await;
//        ^^^^^^^^^^ これが Output に入れた型そのもの
自販機でたとえる: Future は「ボタンを押してしばらく待つと、何かが 1 個出てくる機械」という約束ごと。Output は「その出てくる物の種類」を書く欄です。ジュースの自販機なら Output = ジュース、切符の券売機なら Output = 切符notify() が返す機械は Output = Result<()>、つまり「送れた、または失敗した」という 結果だけ が出てきます。() は中身が空っぽの型(ユニット型)で、「返す値は特にない」の意味です。もし送信 ID を返す設計なら Output = Result<DeliveryId> になります。

型引数 <T> との違い

トレイトに型を持たせる方法は 2 通りあって、書き方も意味も違います。

型引数 Trait<T> 関連型 Trait<Assoc = T>
誰が型を決めるか 使う側。1 つの型に何通りも実装できる 実装側。1 つの型につき 1 通りに固定される
From<u8>From<u16> を同じ型に両方実装できる ある Future の Output は 1 つだけ
書き方 impl From<u8> for X type Output = u8;
指定するとき From<u8> Future<Output = u8>

Output が関連型なのは、「この非同期処理が終わったら何が出てくるか」が処理ごとに 1 つに決まるからです。だから = は「今ここで型を代入している」というより、「実装側がすでに決めている Output が、Result<()> であるものに限る」という絞り込みだと読むのが正確です。Iterator<Item = u32>(u32 が流れてくるイテレータ)もまったく同じ形です。

なぜ dyn のときは省略できないのか

普通に async fn の戻り値を使う分には、具体的な型がコンパイラに分かっているので Output を書く必要はありません。ところが dyn を付けた瞬間、具体的な型は消えてしまいます。すると .await したときに何が出てくるのか、コンパイラに手がかりがなくなります。

Box<dyn Future>                       // ❌ Output が分からない
Box<dyn Future<Output = Result<()>>>   // ✅

これは + Send を自分で書かないといけない理由(第 4 章)とまったく同じ事情です。dyn は情報を捨てる代わりに、捨ててはいけない情報を <...> の中に明記させます。結局 Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> は、日本語にすると .await すると送信の成否が返ってきて、別スレッドに渡せて、'a の間だけ有効な、中身は問わない非同期処理」 という 1 文になります。

トレイト側の : Send + Sync

pub trait Notifier: Send + Sync は「このトレイトを実装する型は、必ず Send かつ Sync でなければならない」という条件です。実装側が RcRefCell をフィールドに持った瞬間、そのファイルでコンパイルエラーになります。並列実行する場所ではなく、原因を作った場所でエラーが出るのが利点です。

Vec<Arc<dyn Notifier>> 送り先ごとの実装をまとめて保持 SlackNotifier HTTP でフックを叩く EmailNotifier SMTP で配送する PagerDutyNotifier 独自 API を叩く 1 件のアラートを 3 か所へ同時に送る
Sync があるから Arc で共有でき、Send があるから並列に走らせられる

なぜ Send だけでなく Sync も要るのか

notify(&self, ...) は共有参照を取ります。つまり Arc<dyn Notifier> を複数のタスクに配って、それぞれから notify を呼ぶ使い方が前提です。ここで &self が別スレッドへ渡るので、&T: Send すなわち T: Sync が必要になります。第 3 章の公式がそのまま効いています。

そして Arc<T> 自体が Send であるための条件も T: Send + Sync です。トレイト境界に両方書いてあるのは、この 2 つの要求を同時に満たすためです。

// この書き方ができるのは Send + Sync のおかげ
let notifiers: Vec<Arc<dyn Notifier>> = vec![
    Arc::new(SlackNotifier::new(webhook_url)),
    Arc::new(EmailNotifier::new(smtp_config)),
    Arc::new(PagerDutyNotifier::new(routing_key)),
];
let alert = Arc::new(Alert::new("DB への接続が切れました"));

let mut handles = Vec::new();
for n in &notifiers {
    let n = Arc::clone(n);           // Sync なので &self を配れる
    let alert = Arc::clone(&alert);
    handles.push(tokio::spawn(async move {
        n.notify(&alert).await       // Send なので別スレッドで再開できる
    }));
}

7. 印を外すと何が起きるか実際に見るエラーメッセージ

+ Send を消した場合

error: future cannot be sent
  between threads safely
  = help: the trait `Send` is not
    implemented for `dyn Future
    <Output = ...>`
note: required by a bound in
  `tokio::spawn`

3 か所へ並列に送ろうとした行で止まります。

: Send + Sync を消した場合

error: `dyn Notifier`
  cannot be shared between threads
  = note: required for
    `Arc<dyn Notifier>`
    to implement `Send`

Arc で共有した行で止まります。

どちらも実行前に、コンパイル時に止まります。テストが偶然通ってしまうことも、本番で月に 1 回だけ壊れることもありません。ここが Rust の 恐れなき並行性 と呼ばれる部分です。

8. まとめ

  • Send は「値を別スレッドへ引っ越してよい」、Sync は「参照を複数スレッドへ配ってよい」。
  • 両者の関係は T: Sync&T: Send の 1 行に集約される。
  • 普通の型にはフィールドから自動で付く。dyn Trait だけは自動判定が効かないので、自分で + Send と書く。
  • async のタスクは .await のたびにスレッドを乗り換える可能性があるので、Future 自身に Send が要る。
  • Notifier: Send + Sync は「Arc で共有して並列に送信する」という設計意図を、型で宣言したもの。実装がその約束を破ったら即コンパイルエラーになる。