Rust での非同期実行には一定の複雑さがあり、プログラミング中にいくつかのミスを犯しやすいです。この記事では、Rust の非同期ランタイムでよくある落とし穴について紹介します。
意図しない同期ブロッキング
非同期コードの中で意図せず同期ブロッキング操作を行うことは、非同期プログラミングにおける重要な落とし穴の一つです。これは非同期プログラミングの利点を損ない、パフォーマンスのボトルネックとなります。以下はよくあるシナリオです:
- 非同期関数内でブロッキング I/O 操作を使う:例えば、
async fn
内で標準のstd::fs::File::open
やstd::net::TcpStream::connect
といったブロッキング関数を直接呼び出す。 - 非同期クロージャ内で計算量の多い処理を行う:非同期クロージャ内で大量の CPU 計算を実行すると、現在のスレッドがブロックされ、他の非同期タスクの実行に影響を与える。
- 非同期コード内でブロッキングなライブラリや関数を使う:一部のライブラリや関数は非同期インターフェースを提供しておらず、同期的にしか呼び出せません。これらを非同期コード内で使うと、ブロックが発生します。
以下のコードを見て、std::thread::sleep
とtokio::time::sleep
の違いを比較してください:
use tokio::task;
use tokio::time::Duration;
async fn handle_request() {
println!("開始リクエスト処理");
// tokio::time::sleep(Duration::from_secs(1)).await; // 正しい:tokioのsleepを使う
std::thread::sleep(Duration::from_secs(1)); // 間違い:同期sleepを使ってしまう
println!("リクエスト処理完了");
}
#[tokio::main(flavor = "current_thread")] // tokio::mainマクロ、単一スレッドモード
async fn main() {
let start = std::time::Instant::now();
// 複数の並行タスクを起動
let handles = (0..10).map(|_| {
task::spawn(handle_request())
}).collect::<Vec<_>>();
// 全てのタスクの完了を待つ(任意)
for handle in handles {
handle.await.unwrap();
}
println!("すべてのリクエスト処理が完了、所要時間:{:?}", start.elapsed());
}
どうすれば同期ブロッキングの落とし穴を避けられるか?
-
非同期対応のライブラリと関数を使うこと:可能な限り、非同期インターフェースを提供するライブラリや関数を使いましょう。たとえば、Tokio や async-std などのランタイムが提供する非同期 I/O、タイマー、ネットワーク機能などです。
-
計算負荷の高い処理は別スレッドプールで実行すること:非同期コード内で計算量の多い処理が必要な場合、
tokio::task::spawn_blocking
やasync-std::task::spawn_blocking
を使って、専用のスレッドプールで実行することで、メインスレッドのブロックを避けることができます。 -
依存ライブラリをよく確認すること:サードパーティ製のライブラリを使う際は、非同期インターフェースが提供されているかを確認し、ブロッキング操作を取り入れてしまわないようにしましょう。
-
ツールを使って分析すること:Tokio が提供する console などの性能分析ツールを使って、非同期コード内にブロッキング操作が存在するかを検出できます。
.await の忘却
非同期関数は Future
を返すため、.await
を付けて実行しなければ結果は得られません。.await を忘れると、Future は実行されずにスキップされてしまいます。
以下のコードを見てください:
async fn my_async_function() -> i32 { 42 }
#[tokio::main]
async fn main() {
// 間違い:.await を忘れているため、関数は実行されない
my_async_function();
// 正しい:.await を付けて非同期関数を実行
let result = my_async_function().await;
println!("正しい非同期操作の結果は:{}", result);
}
spawn の乱用
軽量なタスクを頻繁に spawn
すると、スケジューリングやコンテキストスイッチなどの追加オーバーヘッドが発生し、かえってパフォーマンスが低下する可能性があります。
以下の例では、各数値を 2 倍にして vec
に格納し、最終的に要素数を出力します。誤ったやり方と正しいやり方の両方を示します:
use async_std::task;
async fn process_item(item: i32) -> i32 {
// 非常に単純な操作
item * 2
}
async fn bad_use_of_spawn() {
let mut results = Vec::new();
for i in 0..10000 {
// 間違い:簡単な処理にも毎回 spawn を使う
let handle = task::spawn(process_item(i));
results.push(handle.await);
}
println!("{:?}", results.len());
}
async fn good_use_of_spawn() {
let mut results = Vec::new();
for i in 0..10000 {
results.push(process_item(i).await);
}
println!("{:?}", results.len());
}
fn main() {
task::block_on(async {
bad_use_of_spawn().await;
good_use_of_spawn().await;
});
}
上記の誤った例では、単純な掛け算処理ごとに新しいタスクを spawn しており、大量のスケジューリングオーバーヘッドが発生します。正しい例では、非同期関数を直接 .await
することで、余計なオーバーヘッドを回避しています。
本当に並行して処理すべきタスクにのみ spawn
を使うべきです。計算量が多い、あるいは I/O が重く時間のかかる処理に対しては spawn
が適しています。非常に軽量な処理であれば、直接 await
する方が通常は効率的です。複数のタスクを一括で管理するには、tokio::task::JoinSet
などのユーティリティも使用できます。
結論
非同期 Rust は強力ですが、使い方を誤りやすい一面もあります。ブロッキング呼び出しを避け、.await
を忘れず、本当に必要なときだけ spawn
を使いましょう。慎重にコードを書けば、非同期コードは高速かつ信頼性の高いものになります。
私たちはLeapcell、Rustプロジェクトのホスティングの最適解です。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:
複数言語サポート
- Node.js、Python、Go、Rustで開発できます。
無制限のプロジェクトデプロイ
- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。
比類のないコスト効率
- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。
洗練された開発者体験
- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。
Xでフォローする:@LeapcellHQ