「このデータを別スレッドに渡していい?」をコンパイラに判定させる 2 つの目印
題材: Notifier トレイトと NotifyFutureプログラムは普通、上から順に 1 本の線で進みます。この 1 本の線を スレッド と呼びます。1 件の障害アラートを Slack にもメールにも PagerDuty にも送り届けるような処理では、1 本ずつ順番にやると遅いので、線を何本も同時に走らせます。
ここで事故が起きます。2 本の線が同じメモリを同時に触ると、値が壊れます。たとえば「カウンタを 1 増やす」という処理は、機械語のレベルでは 読む → 足す → 書く の 3 手順です。2 本のスレッドが同時にやると、こうなります。
C++ や Java では、この事故は実行してみるまで分かりません。しかも毎回起きるとは限らないので、再現できないバグとして残ります。Rust は この事故をコンパイル時に禁止する 道を選びました。その仕掛けが Send と Sync です。
Send と Sync は、メソッドを 1 つも持たない空っぽのトレイトです。実行時には何もしません。「この型は安全ですよ」とコンパイラに伝えるだけの目印なので マーカートレイト と呼ばれます。
T: Send は「T の値を、まるごと別のスレッドに引っ越しさせてよい」という意味です。引っ越しなので、元のスレッドにはもう残りません。渡した瞬間から、触れるのは新しいスレッドだけです。
ほとんどの型は Send です。i32、String、Vec<Alert>、どれも引っ越しできます。では 引っ越しできない型 とは何でしょう。代表は Rc<T> です。
Rc は参照カウントを普通の整数で持っています。2 スレッドから同時に増減させると、冒頭の図と同じ壊れ方をします。カウンタが早く 0 になると、まだ使っている側の足元でメモリが解放されます。
Arc は同じ機能を、CPU が保証する不可分な命令でカウントします。だから複数スレッドから触っても壊れません。だから Arc には Send が付いています。
T: Sync は「1 つの T を、複数のスレッドから同時に参照してよい」という意味です。引っ越しではなく、置いたまま &T という覗き窓を配るイメージです。
ここで公式めいた 1 行を覚えると、あとが全部つながります。
T: Sync とは &T: Send のこと。つまり「覗き窓を別スレッドに渡してよいなら、その型は Sync」。Rust の標準ライブラリでも、この 1 行がそのまま実装になっています。
Sync でない代表格は RefCell<T> です。RefCell は「今このデータを何人が借りているか」を普通の整数で数え、実行時にチェックします。複数スレッドから同時に借りると、そのカウンタが壊れ、チェックをすり抜けて可変参照が 2 本できてしまいます。だから RefCell は Sync ではありません。代わりに使うのが Mutex で、これは鍵をかけて 1 人ずつ通すので Sync です。
| 型 | Send | Sync | 理由 |
|---|---|---|---|
i32, String, Vec<T> | ○ | ○ | 中身が普通のデータだけ |
Rc<T> | × | × | 参照カウントがスレッド安全でない |
Arc<T> | ○ | ○ | カウントが不可分命令。ただし中身の T も Send + Sync が条件 |
RefCell<T> | ○ | × | 引っ越しは安全だが、同時に覗くと借用カウンタが壊れる |
Mutex<T> | ○ | ○ | 鍵で 1 人ずつに制限する |
MutexGuard<T> | × | ○ | 鍵を取ったスレッドと返すスレッドが違うと OS の前提が壊れる |
RefCell と MutexGuard が正反対なのが面白いところです。Send と Sync は片方が成り立てばもう片方も成り立つ、という関係ではありません。独立した 2 つの質問 です。
ここまで impl Send for MyType のようなコードを 1 行も書いていません。それでも Vec<Alert> は Send です。理由は、この 2 つが 自動トレイト だからです。
自動トレイトの規則はたった 1 つ: 構造体やトレイトのフィールドが すべて Send なら、その型も自動的に Send。1 つでも Send でないフィールドが混ざれば、その型は Send を失う。Sync も同じ。
ただし dyn Trait は例外です。Box<dyn Future> と書いた時点で、中身の具体的な型はコンパイラから見えなくなります。見えないものは自動判定できないので、Rust は安全側に倒して「Send ではない」とみなします。だから dyn Trait のときだけは、自分で + Send と書く必要がある のです。これが今回のコードの主役です。
tokio のような非同期ランタイムでは、タスクは .await のたびに中断します。そして再開するとき、最初と違うスレッドで再開することがあります。これをワークスティーリングと呼び、暇なスレッドが忙しいスレッドから仕事を横取りする仕組みです。
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 を失う
}
ここまでの道具で、実際のコードが読めるようになります。題材は 複数のサービスに通知 です。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 つ増えて、4 回で目的の型ができあがります。
Box に入れる実装ごとに Future の大きさがバラバラなのが問題でした。ヒープに置いてしまえば、戻り値は ポインタ 1 個ぶんに揃います。async fn をやめて、自分で Box<dyn Future<Output = Result<()>>> を返す形に書き換えます。
Pin で留めるFuture の中身は自分自身への参照を持つことがあります(let s = ...; f(&s).await; のような形)。ヒープ上の別番地へ動かすと、その参照が迷子になります。poll の引数がそもそも Pin<&mut Self> なので、Pin<Box<...>> が事実上の定型になります。
+ Send を書く第 4 章の例外がここで効きます。dyn にした時点で中身が見えなくなり、Send の自動判定が働きません。3 か所へ同時に送るには tokio::spawn に渡す必要があるので、自分で約束を書き足します。
+ '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 を動かして、参照が迷子になる |
+ Send | tokio::spawn に渡せず、並列に送れない |
+ 'a | 'static 扱いになり、&self や &Alert を持ち込めない |
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 + Syncpub trait Notifier: Send + Sync は「このトレイトを実装する型は、必ず Send かつ Sync でなければならない」という条件です。実装側が Rc や RefCell をフィールドに持った瞬間、そのファイルでコンパイルエラーになります。並列実行する場所ではなく、原因を作った場所でエラーが出るのが利点です。
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 ¬ifiers {
let n = Arc::clone(n); // Sync なので &self を配れる
let alert = Arc::clone(&alert);
handles.push(tokio::spawn(async move {
n.notify(&alert).await // Send なので別スレッドで再開できる
}));
}
+ 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 の 恐れなき並行性 と呼ばれる部分です。
Send は「値を別スレッドへ引っ越してよい」、Sync は「参照を複数スレッドへ配ってよい」。T: Sync ⟺ &T: Send の 1 行に集約される。dyn Trait だけは自動判定が効かないので、自分で + Send と書く。.await のたびにスレッドを乗り換える可能性があるので、Future 自身に Send が要る。Notifier: Send + Sync は「Arc で共有して並列に送信する」という設計意図を、型で宣言したもの。実装がその約束を破ったら即コンパイルエラーになる。