はじめに
プログラミングにおいて「待つ」処理は避けられません。サーバからのレスポンスを待つ、データベースの処理が終わるのを待つ、ファイルの読み込みが完了するのを待つ——この「待ち時間」の使い方が、プログラムの性能を大きく左右します。
非同期処理とは、ある処理の完了を待たずに次の処理を開始する技術です。待っている間に他のタスクを処理することで、限られたリソースを効率的に活用できます。
本記事では、以下の内容を解説します。
- Bashでの基本的な非同期処理
- Rust Edition 2024における非同期プログラミングの基礎
- 実践的なコード例とパターン
コード例を通じて、非同期処理の基礎から実践的なパターンまでを学んでいきましょう。各例は独立した断片で、依存関係や関数定義を省略したもの、説明上の限界があるものも含みます。実行する際は、本文に記した条件も確認してください。
Bashでの非同期処理
シェルスクリプトでも基本的な非同期処理が可能です。まずは簡単な例から見ていきましょう。
#!/bin/bash # バックグラウンドで実行 echo "タスク1を開始..." sleep 3 & pid1=$! echo "タスク2を開始..." sleep 2 & pid2=$! echo "タスク3を開始..." sleep 4 & pid3=$! # すべてのタスクの完了を待つ echo "すべてのタスクが完了するのを待っています..." wait $pid1 echo "タスク1が完了しました" wait $pid2 echo "タスク2が完了しました" wait $pid3 echo "タスク3が完了しました" echo "すべて完了!"
このスクリプトでは、& をコマンドの最後につけることで、そのコマンドをバックグラウンドで実行しています。wait コマンドで特定のプロセスの終了を待ちます。
実用的な例:複数サーバの監視
より実用的な例として、複数のサーバの死活監視を同時に行うスクリプトを見てみましょう。
#!/bin/bash
check_server() {
local server=$1
local start_time=$(date +%s)
if ping -c 1 -W 2 "$server" > /dev/null 2>&1; then
local end_time=$(date +%s)
local duration=$((end_time - start_time))
echo "$server: OK (${duration}秒)"
else
echo "$server: 到達不可"
fi
}
# 複数のサーバを同時にチェック
servers=("google.com" "github.com" "stackoverflow.com" "rust-lang.org")
for server in "${servers[@]}"; do
check_server "$server" &
done
# すべての完了を待つ
wait
echo "すべてのチェックが完了しました"
各チェックが約2秒かかると仮定すれば、順次実行の合計は約8秒、並行実行では約2秒になります。実際の所要時間は応答や名前解決の状況で変わり、このスクリプトの実行時間が必ず2秒になるわけではありません。pingのオプションの意味も、実行環境で確認してください。
Bashの非同期処理の限界
ただし、Bashの非同期処理には以下のような限界があります。
- プロセス単位での並行処理のため、オーバーヘッドが大きい
- エラーハンドリングが煩雑
- 状態の共有が難しい
- 細かい制御ができない
より洗練された非同期処理には、プログラミング言語レベルでのサポートが必要となります。
なぜ非同期処理が重要なのか
ハードウェアの性能向上の鈍化
ハードウェアの性能向上は、2010年代以降、鈍化しています。NVIDIAのCEO Jensen Huangが2017年5月のCOMPUTEX TAIPEIで「ムーアの法則は死んだ」と述べました。単純にクロック速度を上げることで性能を向上させる時代は終わったのです。
つまり、これ以上は単に「速いコンピュータを買えばいい」という解決策が使えなくなってきています。
ソフトウェア要求の増大
一方で、ソフトウェアに対する要求は増大し続けています。
- マイクロサービスアーキテクチャでは、システム内でのI/O呼び出しの回数が激増
- Webアプリケーションは、複数のAPIを並行して呼び出す必要がある
- モバイルアプリは、限られたリソースで複数のタスクを処理しなければならない
ここで重要になるのが非同期プログラミングです。
並行処理・並列処理・非同期処理の違いと使い分け
これらの用語は混同されやすいですが、それぞれ異なる概念を指します。
並行処理(Concurrency)
複数のタスクが論理的に同時進行しているように見せる技術です。シングルコアCPUでも実現可能で、タスクを高速に切り替えながら実行します。
例えば、レストランで一人のシェフが玉ねぎを炒めている間にトマトを切る動作が並行処理に相当します。
並列処理(Parallelism)
複数のタスクが物理的に同時実行される技術です。マルチコアCPUを活用し、実際に複数の処理が同じ瞬間に実行されます。複数のシェフがそれぞれ別の料理を作る状態が並列処理に相当します。
目的は処理速度の向上です。
非同期処理(Asynchronous)
ある処理の完了を待たずに次の処理を開始する技術です。I/O操作のような「待ち時間」が多い処理に特に有効で、ネットワークリクエストのレスポンスを待っている間に他の処理を進められます。
目的は待ち時間の有効活用です。
まとめ
これらは異なる次元の概念であり、組み合わせて使用できます。非同期処理を並行的に実行したり、並列に実行したりできます。
CPUのコア数を増やさなくても、非同期プログラミングを用いれば性能を向上させることができます。サーバからのレスポンスを待っている時間があるなら、その間に他のタスクを処理すればよいのです。
参考:qiita.com
Rust Edition 2024における非同期処理
2025年2月20日のRust 1.85.0で、Edition 2024が安定化されました。このリリースには、非同期コードを書く際に役立つ変更も含まれています。以下では、Edition固有の変更と、同じ時期に使えるようになった機能を見ていきます。
Edition 2024の哲学
Rust Editionは、Rustの後方互換性を保ちながら破壊的変更を導入するための仕組みです。Rust 1.0がリリースされた際、チームは「1.xのどのバージョンでコンパイルできたコードは、将来の1.yバージョンでも問題なくコンパイルできる」という約束をしました。
しかし、言語の進化には破壊的変更が必要な場合もあります。Editionはこの問題を解決します。
Edition 2024は、多くの小さな改善の集合体です。大きな単一機能ではなく、言語全体の洗練を目指しています。言語は停滞せず、かつ急激な変化も避けるという健全な進化を示しています。
参考:bertptrs.nl
Edition 2024と、その前後に追加された非同期機能
1. Async Closuresの安定化
Rust 1.85.0のリリース告知では、async || {}という非同期クロージャと、AsyncFn、AsyncFnMut、AsyncFnOnceの安定化が説明されています。async Fn()という構文と、安定化されたAsyncFn()というトレイト名は区別してください。以下の例は、従来のFn() -> FutとFut: Futureを組み合わせた境界を使っています。
use std::time::Duration; // ついに可能になった! let async_closure = async || { tokio::time::sleep(Duration::from_secs(1)).await; "完了".to_string() }; // AsyncFnトレイトを使った高階関数 // 注:async Fn()構文はまだunstableのため、以下のように記述します async fn process_with_async_closure<F, Fut>(f: F) -> String where F: Fn() -> Fut, Fut: std::future::Future<Output = String>, { f().await } #[tokio::main] async fn main() { let result = async_closure().await; println!("結果: {}", result); let result = process_with_async_closure(async || { "非同期クロージャ".to_string() }).await; println!("結果: {}", result); }
この断片のlet async_closure = ...は、実行時にはmainなどの関数内へ置く必要があります。また、コメントには「AsyncFnトレイト」とありますが、関数の境界はFn() -> Futです。
非同期クロージャは、通常のクロージャが非同期ブロックを返す|| async {}では表せなかった借用も扱えます。どちらを使うかは、キャプチャした値をどう参照するかや、関数に求める境界によって変わります。
参考:medium.com
2. トレイト内のasync fn
トレイト内のasync fnは、Rust 1.75.0で安定化された機能です。Edition 2024でも利用できます。
use std::time::Duration; // これがついに標準機能に! trait AsyncService { async fn process(&self, data: String) -> Result<String, Box<dyn std::error::Error>>; async fn validate(&self, input: &str) -> bool; } struct MyService; impl AsyncService for MyService { async fn process(&self, data: String) -> Result<String, Box<dyn std::error::Error>> { tokio::time::sleep(Duration::from_secs(1)).await; Ok(format!("処理完了: {}", data)) } async fn validate(&self, input: &str) -> bool { !input.is_empty() } } // ジェネリックな非同期関数でも使える async fn use_service<T: AsyncService>(service: &T) { match service.process("データ".to_string()).await { Ok(result) => println!("{}", result), Err(e) => eprintln!("エラー: {}", e), } }
この機能により、トレイトベースの抽象化が非同期コードでも自然に使えるようになった。Niko Matsakisは2024年の初めに「async fn in traitsはAsync Rustのハードモードを終わらせる基盤」と述べている。
3. PreludeへのFutureとIntoFutureの追加
// もうuseステートメントが不要! // use std::future::Future; // ← 不要になった async fn my_future() -> i32 { 42 } // FutureもIntoFutureもpreludeに含まれているので // そのまま使える fn process_future<F: Future<Output = i32>>(future: F) { // ... }
これは小さな変更に見えるが、非同期コードを書く際の摩擦を大幅に減らす。
4. RPIT(Return Position impl Trait)のライフタイム捕捉ルールの改善
// Edition 2021での問題 fn old_way(x: &str) -> impl Future<Output = String> { async move { // xのライフタイムが正しく捕捉されない場合があった x.to_string() } } // Edition 2024での改善 fn new_way(x: &str) -> impl Future<Output = String> { async move { // ライフタイムが適切に捕捉される x.to_string() } } // use<..> で明示的な制御も可能 fn explicit_capture<'a>(x: &'a str) -> impl Future<Output = String> + use<'a> { async move { x.to_string() } }
この改善により、非同期関数から返されるimpl Future型のライフタイム推論がより直感的になった。
参考:www.heise.de
5. 一時変数のスコープ改善
// if let での一時変数のドロップタイミングが改善 async fn process() { if let Some(data) = fetch_data().await { // Edition 2024では、dataはこのブロック内でのみ有効 println!("{}", data); } // ← dataはここでドロップされる } // tail expressionでの改善 async fn compute() -> i32 { let result = calculate().await; result * 2 // この一時変数のスコープも改善された }
この変更が扱うのは、変数dataの有効範囲そのものではなく、式の評価中に作られる一時値の破棄タイミングです。if letでは条件式の一時値をelseへ入る前に破棄するようになり、末尾式の一時値も破棄の順序や範囲が変わります。上のdataや整数のresult * 2だけでは、Edition間の違いを示せません。変更によってコンパイルできるようになる例も、逆に修正が必要になる例もあります。
6. unsafeの厳格化
// Edition 2024では、extern blockはunsafeマーク必須 unsafe extern "C" { fn external_function(); } // 環境変数の操作もunsafeに unsafe { std::env::set_var("KEY", "value"); }
unsafeの範囲がより明確になり、安全でない操作がコード中で目立つようになった。これにより、コードレビュー時に安全性を検証しやすくなる。
Edition 2024への移行
以下は移行後の設定例です。既存プロジェクトでは、先にEditionを変えるのではなく、従来のEditionのままcargo fix --editionを実行し、その後にedition = "2024"へ変更してビルド・テストを確認します。公式の移行手順に沿って、手動対応が必要な警告も確認してください。
[package] name = "my-async-app" version = "0.1.0" edition = "2024" # ← ここを変更 [dependencies] tokio = { version = "1", features = ["full"] }
移行前のEditionで実行するコマンド:
cargo fix --edition
実践例:Edition 2024の機能を使ったコード
use std::time::Duration; // Async closureを使った例 async fn process_items<F, Fut>(items: Vec<String>, processor: F) -> Vec<String> where F: Fn(String) -> Fut, Fut: std::future::Future<Output = String>, { let mut results = Vec::new(); for item in items { results.push(processor(item).await); } results } // Async trait methodを使った例 trait DataProcessor { async fn process(&self, data: &str) -> String; } struct UppercaseProcessor; impl DataProcessor for UppercaseProcessor { async fn process(&self, data: &str) -> String { tokio::time::sleep(Duration::from_millis(100)).await; data.to_uppercase() } } #[tokio::main] async fn main() { // Async closureの使用 let items = vec!["hello".to_string(), "world".to_string()]; let results = process_items(items, |item| async move { format!("処理済み: {}", item) }) .await; println!("結果: {:?}", results); // Async traitの使用 let processor = UppercaseProcessor; let result = processor.process("rust 2024").await; println!("変換結果: {}", result); }
非同期Rustのベストプラクティス(2024-2025)
1. ブロッキング操作を避ける
// ❌ 悪い例 #[tokio::main] async fn main() { tokio::spawn(async { // std::thread::sleepはスレッドをブロックする std::thread::sleep(Duration::from_secs(5)); }); } // ✅ 良い例 #[tokio::main] async fn main() { tokio::spawn(async { // tokio::time::sleepは非同期 tokio::time::sleep(Duration::from_secs(5)).await; }); }
Tokioのタスク内でスレッドをブロックすると、他のタスクの実行を妨げます。非同期版の関数を使うか、次に示すようにブロッキング処理を別のスレッドへ移します。また、上の例はタスクの終了を待たずにmainが終わるため、処理完了を確かめるには、tokio::spawnが返すハンドルをawaitする必要があります。Tokioのタスクの説明も参照してください。
参考:https://blog.poespas.me/posts/2024/06/01/rust-async-io-best-practices/blog.poespas.me
2. CPU集約的な処理は別スレッドで
use tokio::task; fn cpu_intensive_work(n: u64) -> u64 { // フィボナッチ数の計算など (0..n).sum() } #[tokio::main] async fn main() { // CPU集約的な処理はspawn_blockingで let result = task::spawn_blocking(|| { cpu_intensive_work(1_000_000) }).await.unwrap(); println!("結果: {}", result); }
CPU集約的な処理は、spawn_blockingで非同期タスクとは別のスレッドへ移せます。ただし、このプールのスレッド数の上限は大きく設定されているため、大量の計算を投入する場合は同時実行数を制限するか、計算向けのスレッドプールを検討します。
参考:medium.com
3. 適切なランタイム設定
// シングルスレッドランタイム(軽量) #[tokio::main(flavor = "current_thread")] async fn main() { // 単純なI/O処理に適している } // マルチスレッドランタイム(デフォルト) #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { // 多数の並行タスクがある場合 }
アプリケーションの特性に応じてランタイムを選択します。
4. エラーの適切な伝搬
use anyhow::Result; async fn step1() -> Result<String> { Ok("ステップ1完了".to_string()) } async fn step2() -> Result<String> { Ok("ステップ2完了".to_string()) } async fn process() -> Result<()> { let result1 = step1().await?; let result2 = step2().await?; println!("{}", result1); println!("{}", result2); Ok(()) } #[tokio::main] async fn main() { if let Err(e) = process().await { eprintln!("エラー: {}", e); } }
?演算子を活用し、エラーを適切に伝搬させる。anyhowクレートは便利なエラーハンドリングを提供します。
5. Send境界の理解
use std::sync::Mutex; use tokio::sync::Mutex as TokioMutex; // ❌ 悪い例:std::sync::MutexGuardはSendではない async fn bad_example() { let data = Mutex::new(0); let guard = data.lock().unwrap(); // これはコンパイルエラー // some_async_function().await; } // ✅ 良い例:tokio::sync::Mutexを使用 async fn good_example() { let data = TokioMutex::new(0); let guard = data.lock().await; // これは問題ない some_async_function().await; }
tokio::spawnへ渡すFutureにはSendが必要で、awaitをまたいで保持する値もこの条件に関わります。上のbad_exampleはawaitがコメントアウトされているので、そのままではこのエラーを再現しません。単に非同期関数を定義しただけで、すべてにSendが要求されるわけでもありません。ロックをawait前に解放できるならそのようにし、保持が必要な場合に非同期Mutexを検討します。
実践:非同期HTTPリクエストで性能を比較
実際のコードで、非同期の威力を確認してみよう。
依存関係の設定
[dependencies] tokio = { version = "1", features = ["full"] } reqwest = { version = "0.11", features = ["json"] }
非同期リクエストを順次実行するアプローチ
use std::time::Instant; use reqwest::Error; #[tokio::main] async fn main() -> Result<(), Error> { let url = "https://3-shake.com/"; let start_time = Instant::now(); // 順番に4回リクエスト for i in 1..=4 { let response = reqwest::get(url).await?; println!("リクエスト {} 完了: ステータス {}", i, response.status()); } let elapsed = start_time.elapsed(); println!("合計時間: {} ms", elapsed.as_millis()); // 出力例: 合計時間は環境により変動(概ね数百ms程度) Ok(()) }
非同期リクエストを並行実行するアプローチ
use std::time::Instant; use reqwest::Error; #[tokio::main] async fn main() -> Result<(), Error> { let url = "https://3-shake.com/"; let start_time = Instant::now(); // 4つのリクエストを同時に実行 let (r1, r2, r3, r4) = tokio::join!( reqwest::get(url), reqwest::get(url), reqwest::get(url), reqwest::get(url), ); // 結果を確認 println!("リクエスト1: {:?}", r1.map(|r| r.status())); println!("リクエスト2: {:?}", r2.map(|r| r.status())); println!("リクエスト3: {:?}", r3.map(|r| r.status())); println!("リクエスト4: {:?}", r4.map(|r| r.status())); let elapsed = start_time.elapsed(); println!("合計時間: {} ms", elapsed.as_millis()); // 出力例: 合計時間は環境により変動 Ok(()) }
並行実行による性能改善
ネットワークのレスポンスを待っている間、CPUは他のリクエストを処理できます。実際の性能改善はネットワーク状況、サーバーの同時接続制限、HTTP/2の利用状況などに依存しますが、理想的な条件下では、並行実行により順次実行と比べて大幅な時間短縮が可能です。
より実践的な例:並行データ処理
複数のAPIからデータを取得して統合する、よくあるシナリオを見てみましょう。
use reqwest::Error; use serde::Deserialize; use std::time::Instant; #[derive(Deserialize, Debug)] struct User { id: i32, name: String, email: String, } #[derive(Deserialize, Debug)] struct Post { id: i32, title: String, body: String, } #[derive(Debug)] struct UserProfile { user: User, posts: Vec<Post>, comments_count: usize, } async fn fetch_user(user_id: i32) -> Result<User, Error> { let url = format!( "https://jsonplaceholder.typicode.com/users/{}", user_id ); reqwest::get(&url) .await? .json::<User>() .await } async fn fetch_user_posts(user_id: i32) -> Result<Vec<Post>, Error> { let url = format!( "https://jsonplaceholder.typicode.com/posts?userId={}", user_id ); reqwest::get(&url) .await? .json::<Vec<Post>>() .await } async fn fetch_comments_count(user_id: i32) -> Result<usize, Error> { let url = format!( "https://jsonplaceholder.typicode.com/comments?postId={}", user_id ); let comments = reqwest::get(&url) .await? .json::<Vec<serde_json::Value>>() .await?; Ok(comments.len()) } async fn get_user_profile(user_id: i32) -> Result<UserProfile, Error> { // 3つのAPIを並行して呼び出す let (user_result, posts_result, comments_result) = tokio::join!( fetch_user(user_id), fetch_user_posts(user_id), fetch_comments_count(user_id), ); Ok(UserProfile { user: user_result?, posts: posts_result?, comments_count: comments_result?, }) } #[tokio::main] async fn main() -> Result<(), Error> { let start = Instant::now(); let profile = get_user_profile(1).await?; let duration = start.elapsed(); println!("ユーザー: {}", profile.user.name); println!("投稿数: {}", profile.posts.len()); println!("コメント数: {}", profile.comments_count); println!("\n処理時間: {} ms", duration.as_millis()); Ok(()) }
3つのAPI呼び出しの所要時間が同程度で、同時実行による遅延がなければ、待ち時間は順次実行の約3分の1になります。常にこの比率になるわけではありません。
この断片には追加の準備と修正が必要です。serdeのderive機能とserde_jsonを依存関係に追加してください。また、fetch_comments_count(user_id)は、その値を投稿のpostIdとして使っています。JSONPlaceholderのリソースではユーザーと投稿は別なので、これではユーザーの全投稿に付いたコメント数にはなりません。取得した各投稿のIDを使って数えるなど、求める集計に合わせた実装が必要です。
Future とタスクの理解
Rustの非同期処理の核心は Future トレイトです。
pub trait Future { type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } pub enum Poll<T> { Ready(T), Pending, }
async関数は、Futureを返す関数に変換されます。
// これを書くと... async fn example() -> i32 { 42 } // コンパイラがこのように変換する fn example() -> impl Future<Output = i32> { // 状態機械の実装 }
async関数が返すFutureは、作っただけでは本体の処理が進みません。pollによって処理が進む仕組みで、awaitはそのための通常の書き方です。tokio::spawnへ渡したFutureはランタイムにポーリングされるので、呼び出し元がハンドルをawaitする前にも処理が進みます。
参考:https://cosmicmeta.io/2024/10/01/asynchronous-programming-in-rust-a-guide-to-async-and-await/cosmicmeta.io
カスタムFutureの実装
理解を深めるため、カウンターFutureを自作する例を示します。ただし、下の実装はpoll内のstd::thread::sleepでスレッドをブロックします。ポーリング回数を観察するための例であり、非同期の待機処理の実装としては使わないでください。pollは素早く返り、ブロックしないことが求められます。
use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::Duration; struct CounterFuture { count: u32, max: u32, } impl CounterFuture { fn new(max: u32) -> Self { Self { count: 0, max } } } impl Future for CounterFuture { type Output = u32; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> { self.count += 1; println!("ポーリング #{}: カウント = {}", self.count, self.count); // 実際の待機をシミュレート std::thread::sleep(Duration::from_millis(100)); if self.count < self.max { // まだ完了していない cx.waker().wake_by_ref(); Poll::Pending } else { // 完了 Poll::Ready(self.count) } } } #[tokio::main] async fn main() { let future1 = CounterFuture::new(3); let future2 = CounterFuture::new(3); let (result1, result2) = tokio::join!(future1, future2); println!("\n結果1: {}", result1); println!("結果2: {}", result2); }
出力:
ポーリング #1: カウント = 1 ポーリング #1: カウント = 1 ポーリング #2: カウント = 2 ポーリング #2: カウント = 2 ポーリング #3: カウント = 3 ポーリング #3: カウント = 3 結果1: 3 結果2: 3
2つのFutureが交互にポーリングされている様子がわかる。
エラーハンドリングとキャンセル安全性
エラーハンドリングのベストプラクティス
use std::time::Duration; use tokio::time::timeout; async fn risky_operation() -> Result<String, &'static str> { tokio::time::sleep(Duration::from_secs(2)).await; Ok("成功".to_string()) } async fn slow_operation() -> Result<String, &'static str> { tokio::time::sleep(Duration::from_secs(10)).await; Ok("遅い操作完了".to_string()) } #[tokio::main] async fn main() { // タイムアウト付き実行 let result = timeout( Duration::from_secs(3), slow_operation() ).await; match result { Ok(Ok(value)) => println!("成功: {}", value), Ok(Err(e)) => println!("操作エラー: {}", e), Err(_) => println!("タイムアウト"), } // 複数の操作を並行実行し、エラーを適切に処理 let results = tokio::join!( risky_operation(), risky_operation(), risky_operation(), ); match results { (Ok(r1), Ok(r2), Ok(r3)) => { println!("すべて成功: {}, {}, {}", r1, r2, r3); } _ => { println!("一部が失敗しました"); } } }
キャンセル安全性
キャンセルでFutureが破棄されると、その中のガードも破棄され、TokioのMutexのロックは解放されます。したがって、以下のコメントにある「キャンセルされると、ロックが保持されたまま」は正確ではありません。2つの例の違いは、待機中にもロックを保持するかどうかです。どちらもキャンセル前の加算を自動的に取り消すわけではなく、処理全体の整合性は別に検討する必要があります。この断片ではstd::time::Durationのインポートも必要です。
Tyler Mandryは「Making Async Rust Reliable」で、キャンセル安全性の重要性を強調している。
use tokio::sync::Mutex; use std::sync::Arc; // キャンセル安全でない例 async fn unsafe_increment(counter: Arc<Mutex<i32>>) { let mut guard = counter.lock().await; *guard += 1; // ここでキャンセルされると、ロックが保持されたまま tokio::time::sleep(Duration::from_secs(1)).await; } // キャンセル安全な例 async fn safe_increment(counter: Arc<Mutex<i32>>) { let mut guard = counter.lock().await; *guard += 1; drop(guard); // 明示的にロックを解放 tokio::time::sleep(Duration::from_secs(1)).await; }
実践的なパターン集
パターン1: 並行実行で最初に完了したものを使う
async fn fetch_from_server_a() -> Result<String, Box<dyn std::error::Error>> { tokio::time::sleep(Duration::from_secs(2)).await; Ok("サーバーA".to_string()) } async fn fetch_from_server_b() -> Result<String, Box<dyn std::error::Error>> { tokio::time::sleep(Duration::from_secs(1)).await; Ok("サーバーB".to_string()) } #[tokio::main] async fn main() { use tokio::select; // 最初に完了したほうを使う select! { result_a = fetch_from_server_a() => { println!("サーバーAから: {:?}", result_a); } result_b = fetch_from_server_b() => { println!("サーバーBから: {:?}", result_b); } } }
パターン2: 複数のタスクをスポーンして管理
use tokio::task::JoinHandle; async fn worker(id: i32, duration: u64) -> String { tokio::time::sleep(Duration::from_secs(duration)).await; format!("ワーカー {} 完了", id) } #[tokio::main] async fn main() { let mut handles: Vec<JoinHandle<String>> = Vec::new(); // 複数のワーカーをスポーン for i in 0..5 { let handle = tokio::spawn(worker(i, i as u64 + 1)); handles.push(handle); } // すべての完了を待つ for handle in handles { match handle.await { Ok(result) => println!("{}", result), Err(e) => eprintln!("エラー: {}", e), } } }
パターン3: 共有状態の安全な管理
use tokio::sync::RwLock; use std::sync::Arc; #[derive(Clone)] struct Counter { value: Arc<RwLock<i32>>, } impl Counter { fn new() -> Self { Self { value: Arc::new(RwLock::new(0)), } } async fn increment(&self) { let mut value = self.value.write().await; *value += 1; } async fn get(&self) -> i32 { let value = self.value.read().await; *value } } #[tokio::main] async fn main() { let counter = Counter::new(); let mut handles = vec![]; // 10個のタスクで並行してインクリメント for _ in 0..10 { let counter_clone = counter.clone(); let handle = tokio::spawn(async move { for _ in 0..100 { counter_clone.increment().await; } }); handles.push(handle); } // すべて完了を待つ for handle in handles { handle.await.unwrap(); } println!("最終カウント: {}", counter.get().await); // 出力: 最終カウント: 1000 }
まとめ
非同期処理における重要な概念を再確認しましょう。
- 並行性(Concurrency): 複数のタスクを論理的に同時進行させる技術
- 効率性(Efficiency): 待ち時間を無駄にせず他のタスクを処理すること
- スケーラビリティ(Scalability): リソースを効果的に使い多数のタスクを処理する能力
- 応答性(Responsiveness): ユーザーを待たせず素早く反応すること
Rustの非同期処理は、型システムによる安全性保証と高い実行性能を両立しています。async/await構文はコードを簡潔に保ち、Futureトレイトは強力な抽象化を提供します。Tokioなどのランタイムは成熟しており、本番環境での使用実績も豊富です。
async closuresはRust 1.85.0で使えるようになりました。一方、Send境界の扱いや非同期ジェネレータなど、引き続き改善が検討されている領域もあります。
非同期処理の本質
非同期処理の本質は、待ち時間を効率的に活用することです。サーバからのレスポンスを待ちながら他のリクエストを処理し、ファイルの読み込みを待ちながら計算を行います。一つのタスクの完了を待たずに次のタスクを開始することで、限られたリソースを最大限に活用できます。
学習のポイント
初学者にとって、SendとSyncのトレイト境界やライフタイムの扱いは困難に感じるかもしれません。しかし、これらの制約は、並行処理における安全性を保証するために必要なものです。Rustコンパイラのエラーメッセージは、タスクの依存関係やリソースの所有権について正しく考えるための指針となります。
Edition 2024における改善は、単なる文法の追加ではありません。「複数の時間軸を同時に扱う」という非同期処理の考え方が、言語の中により深く統合されたことを意味したのかなぁって思います。
おわり
参考リンク
公式ドキュメント
- Rust公式非同期ブック: rust-lang.github.io
- Rust Edition 2024公式ガイド: doc.rust-lang.org
- Rust 1.85.0リリースノート(Edition 2024安定化): blog.rust-lang.org
- Tokio公式ドキュメント: tokio.rs
開発ロードマップ
- Async Rust 2024 Roadmap: smallcultfollowing.com
- Rust Lang Team Roadmap 2024: https://lang-team.rust-lang.org/roadmaps/roadmap-2024.htmllang-team.rust-lang.org
- Rust Project Goals 2024: rust-lang.github.io
コミュニティリソース
- Making Async Rust Reliable - Tyler Mandry: tmandry.gitlab.io
- Async Rust in a Nutshell - Shuttle: www.shuttle.dev
- Rust Edition 2024 Annotated: bertptrs.nl