Rust非同期ハンドラーにおけるSendとSyncの理解
Daniel Hayes
Full-Stack Engineer · Leapcell

はじめに
おそらく経験したことがあるでしょう。Rustで非同期ハンドラーを書いていて、生産性を感じているのに、SendまたはSyncに関するコンパイラーエラーに遭遇したことが。これは、Rustの並行処理モデルに慣れていない開発者、特 async/await の世界に足を踏み入れたばかりの開発者にとって、共通のつまずきどころです。これは単なる不可解なエラーメッセージではありません。Rustの厳格な型システムが、並行コードの安全性と正確性を保証しているのです。これらのトレイトを無視したり、誤解したりすると、見つけにくい、デバッグが困難なデータ競合やデッドロックにつながる可能性があります。この記事では、SendとSyncの層を剥がし、非同期ハンドラーにとってそれらがなぜ重要なのかを説明し、堅牢でスレッドセーフな非同期Rustコードを書くための実践的なソリューションを提供します。
Rust並行処理の基盤
非同期ハンドラーの具体に踏み込む前に、Rustにおけるスレッドセーフティの基盤となるコアコンセプト、すなわちSendとSyncを明確に理解しましょう。
SendとSyncとは何か?
Rustでは、SendとSyncはマーカートレイトです。メソッドを持たず、その目的は純粋に意味論的であり、コンパイラーに型のスレッドセーフティ特性を伝えます。
-
Send: 型Tの値の所有権をあるスレッドから別のスレッドに移動しても安全である場合、型TはSendであると言えます。ほとんどのプリミティブ型(i32、boolなど)、Box<T>のようなスマートポインター(TがSendの場合)、および多くのコレクション型(内容がSendであればVec<T>、HashMap<K, V>など)はSendです。逆に、生のポインター(*const T、*mut T)は、適切な同期なしに転送すると逆参照時にデータ競合を引き起こす可能性があるため、Sendではありません。チャンネルやミューテックスは、安全なクロススレッド通信を可能にする内部状態を管理するため、通常はSendです。 -
Sync: 型Tの値への共有参照(&T)を複数のスレッドが安全に持つことができる場合、型TはSyncであると言えます。これは、Tが並行して安全にアクセスできることを意味します。TがSyncであるためには、&TがSendである必要があります。これは、Tへの共有参照を別のスレッドに送信してそこでアクセスできることを意味します。不変データ(i32や&strなど)はSyncです。Mutex<T>やRwLock<T>(TがSendの場合)のような内部可変性を提供する型も、内部メカニズムが安全な並行アクセスを保証するため、Syncです。RefCell<T>や生のポインター(*const T、*mut T)は、スレッドセーフな内部可変性を提供しないため、Syncではありません。
Rustのほとんどの型はデフォルトでSendおよびSyncです(impl Trait for T {})。型は、SendまたはSyncでないフィールドを含む場合、または明示的に![derive(Send)]またはを使用してオプトアウトしない限り、非Sendまたは非Syncにはなりません。
SendとSyncは非同期にとってなぜ重要か?
FutureとExecutorによって駆動される非同期Rustは、一見シングルスレッド実行のように見えても、これらのトレイトに大きく依存しています。async fnまたはasync {}ブロックは、Futureトレイトを実装する状態マシンにデシュートします。このFutureはExecutorによって安全にポーリングされる必要があります。
スレッドのプールを管理するExecutor(Tokioやasync-stdなど)を考えてみましょう。Futureをspawnしたり、別のFutureを.awaitしたり、クロージャーをasyncブロックに移動したりすると、Executorは作業をバランスするためにFutureの状態をスレッド間で移動したり、後続の.await呼び出しで別のスレッドからポーリングしたりする可能性があります。
FutureはSendでなければならない:FutureがSendでない状態を含んでいる場合、ExecutorはFutureの状態を安全にスレッド間で移動できません。これは、タスクを再スケジュールするマルチスレッドExecutorにとって重要です。Future自体がSendであるタスクを表す場合、その内部状態は.awaitポイント間で移動できます。- 環境変数のキャプチャ:
asyncブロックが周囲の環境から変数をキャプチャすると、これらの変数はFutureの状態の一部になります。これらのキャプチャされた変数がSendでない場合、Future自体もSendにはなれません。
これがコンパイラエラーが頻繁に発生する原因です。asyncブロックを作成しており、それがSendでない(または時々Syncでない)何かを暗黙的にキャプチャしていて、コンパイラがスレッドセーフティを保証できないのです。
「Not Send」エラーにつながる一般的なシナリオ
いくつかの一般的な落とし穴とその解決策を見てみましょう。
シナリオ1:Rc<T>またはRefCell<T>のキャプチャ
Rc<T>(参照カウント)とRefCell<T>(内部可変性のための参照セル)は、シングルスレッドシナリオ向けに設計されています。スレッドセーフなアクセス制御を提供しません。
問題のあるコード:
use std::rc::Rc; use std::cell::RefCell; use tokio::task; #[tokio::main] async fn main() { let counter = Rc::new(RefCell::new(0)); // エラー: `Rc<RefCell<i32>>` はスレッド間で安全に送信できません // `RefCell<i32>` はスレッド間で安全に送信できません // `Rc<i32>` はスレッド間で安全に送信できません let handle = task::spawn(async move { // このクロージャーは`counter`を所有権を移動してキャプチャします。 // 非同期ブロックであるため、生成されたFutureはSendである必要があります。 for _ in 0..100 { *counter.borrow_mut() += 1; } println!("Counter in task: {}", *counter.borrow()); }); handle.await.unwrap(); println!("Final counter: {}", *counter.borrow()); }
失敗する理由: Rcは単一スレッド内での複数の所有者を許可します。RefCellは、所有者のmutを必要とせずに、単一スレッド内での可変借を許可します。どちらもマルチスレッドアクセスための同期メカニズムを提供しないため、SendまたはSyncではありません。task::spawn関数は、マルチスレッドExecutor向けに設計されており、そのFuture引数にSendを要求します。
解決策:Arc<T>とMutex<T> / RwLock<T>を使用する
マルチスレッド共有所有権と内部可変性のために、スレッドセーフな対応物であるArc<T>(アトミック参照カウント)とMutex<T>(相互排他ロック)またはRwLock<T>(読み書きロック)を使用してください。
use std::sync::{Arc, Mutex}; use tokio::task; #[tokio::main] async fn main() { let counter = Arc::new(Mutex::new(0)); // Arcは共有所有権のため、Mutexは内部可変性のために let counter_clone = Arc::clone(&counter); // タスクのためにArcをクローン let handle = task::spawn(async move { for _ in 0..100 { // ミューテックスをロックして、内部データへの排他アクセスを取得します let mut num = counter_clone.lock().unwrap(); *num += 1; } println!("Counter in task: {}", *counter_clone.lock().unwrap()); }); handle.await.unwrap(); println!("Final counter: {}", *counter.lock().unwrap()); }
ここでは、Arc<Mutex<i32>>はSendです。これは、Arcがスレッド間で参照カウントを安全に処理し、Mutexが複数のスレッドがi32データを変更しようとしても排他的アクセスを保証するためです。
シナリオ2:.awaitポイントをまたいで非Send型を保持する
時には、Rcのような型が原因ではなく、.awaitポイントをまたいで暗黙的にキャプチャされる一時的な非Send値であることもあります。これは、典型的なOSレベルの非Sendハンドル型はRustのイディオムではまれですが、この概念は当てはまります。
問題のあるコード(概念的):
// この例は概念的なものです。std::process::Child stdin/stdoutハンドルはSyncですが、 // リソースが非Sendであった場合のアイデアを例示しています。 #[tokio::main] async fn main() { let config = String::from("some_config_data"); // `my_non_send_struct`がそれを借用していた場合など、`config`をキャプチャする可能性があります。 let _handle = tokio::spawn(async move { // `MyNonSendType`はSendではない型であると仮定します。 // `MyNonSendType`のインスタンスがここで作成された場合 // またはSendではない環境から何かを借用した場合、 // そしてawaitポイントに到達した場合... // let some_data = MyNonSendType::new(); // 仮説上の非Send型 println!("Before await"); tokio::time::sleep(std::time::Duration::from_millis(10)).await; println!("After await. `Future`はスレッドを移動した可能性があります。"); // `some_data`がawaitをまたいでキャプチャされ、Sendでない場合、エラー! // some_data.do_something(); }); }
(概念的に)失敗する理由: asyncブロック(Futureにデシュートします)が非Send変数をキャプチャし、それを.awaitポイントをまたいで保持している場合、コンパイラは文句を言います。これは、ExecutorがFutureをあるスレッドで中断し、.awaitが完了した後、別スレッドで再開する可能性があるためです。非Send変数がFutureの状態の一部であった場合、それは安全でない方法でスレッド間で移動されます。
解決策:非Send変数をローカルスコープに移動するか、適切に同期されていることを確認する。
- ローカルスコープに移動する: 非
Send変数が.awaitポイントの前または後でのみ必要な場合、そのライフタイムが.awaitをまたがないようにします。 - 同期する: 非
Send変数が.awaitポイントをまたいで、潜在には異なるスレッドでアクセスされる必要がある場合、Arc<Mutex<T>>またはArc<RwLock<T>>のようなスレッドーフなプリミティブでラップする必要があります。
シナリオ3:クロージャーとFn vs FnOnce vs FnMut
非同期タスクをスポーンする際には、クロージャーを渡すことがよくあります。moveキーワードは重要です。
#[tokio::main] async fn main() { let mut my_data = 10; // エラー: `my_data`は暗黙的に参照でキャプチャされ、 // `my_data`はSyncではない(可変です)。 // スポーンされたFutureはSend+Syncであるために`&mut i32`を必要としますが、そうではありません。 // let handle = tokio::spawn(async { // println!("Data from inner task: {}", my_data); // my_data += 1; // これは`my_data`を`&mut`としてキャプチャされる原因になります。 // }); // 正しい: `move`を使用して所有権を転送します。 let handle = tokio::spawn(async move { println!("Data from inner task: {}", my_data); my_data += 1; // これで`my_data`はクロージャーに所有されます。 }); handle.await.unwrap(); // 所有権はスポーンされたタスク移動されたため、ここでmy_dataにアクセスできません。 // println!("Data in main: {}", my_data); }
なぜ重要か:
moveなしでは、my_dataは参照(&mut my_data)でキャプチャされます。可変参照&mut Tは、作成されたスレッドでのみ有効であり、スレッド間での転送を非Sendにします。asyncブロックをspawnすると、外側のタスクとスポーンされたタスクが異なるスレッドで動作する可能性があります。
解決策:moveキーワードを使用する
async move { ... }でmoveを使用することにより、my_dataの所有権はFutureに転送されます。i32はSendであるため、my_dataを含むFutureもSendです。元のスコープでアクセスを保持しながらデータを共有する必要がある場合は、Arc<Mutex<T>>を参照してください。
for<'a> Future<&'a Context<'a>>の問題
これは、ジェネリック非同期コードやライフタイムを含むトレイトの実装でよく見られる、より高度なケースです。非同期ハンドラーが特定のライフタイム 'a を持つデータを借する必要があり、そのデータがSyncでない場合、FutureはSendにはなれません。これは、ExecutorがFutureとその借データをスレッドで移動する必要があるが、借データが暗黙的に安全に共有できない場合に発生します。
結論
SendおよびSyncトレイトは、Rustのスレッドセーフティ保証の基本的な柱であり、非同期プログラミングもその影響深く及んでいます。非同期ハンドラーがSendまたはSyncに関するエラーを発生させる場合、それは妨げではなく、コンパイラーからの有益な警告であり、潜在的なデータ競合や未定義の動作を防ぎます。非同期ブロックがExecutorによってスレッド間で移動される可能性のあるFutureにデシュートすることを理解することで、RcやRefCellのようなシングルスレッド対応物ではなく、ArcやMutexのようなスレッドセーフなプリミティブをいつ使用すべきかを正しく特定し、moveキーワード効果的に活用できます。これらのコアコンセプトを受け入れることは、Rustで堅牢で高性能真にスレッドセーフな非同期アプリケーションを書くための鍵となります。コンパイラーは、より安全な並行処理導いてくれる友人なのです。

