📚 背景知識(読んでから問題へ)
Day 038〜040で tokio::spawn・tokio::join!・tokio::select! の違い(タスクの独立性・パニック伝播・キャンセル安全性)を学びました。今日はこれらを実務でそのまま使うと壁にぶつかるケース——タスクの数が実行時まで分からない場合を扱います。
tokio::join! はマクロなので、待ち合わせるFutureの数はコンパイル時に固定されていなければなりません。「URLのリストを受け取って、その数だけ並行にリクエストを飛ばす」ようなコードでは join! は使えず、Vec<JoinHandle<T>> を自分で管理するのが典型的な最初のアプローチです。しかしこの方法には2つの落とし穴があります。
for handle in handles { handle.await? }のように順番に.awaitすると、先頭のタスクが遅い場合、後続がとっくに終わっていても結果を受け取れず、「並行実行はしているのに完了検知は逐次」という中途半端な実装になりがちです。- 個々のタスクが
panic!すると、対応するJoinHandleの.awaitはErr(JoinError)を返しますが、?でそのまま早期returnすると、まだ実行中の他のタスクがJoinHandleごとdropされ、実行され続けたまま誰も結果を待たなくなる(デタッチされる)ため、後片付けの設計を誤りやすいポイントです。
tokio::task::JoinSet<T> はこの「動的な数のタスクを束ね、完了した順に結果を受け取る」というパターンのために用意された型です。spawn() でタスクを追加し、join_next().await を呼ぶと、その時点で最初に完了したタスクの結果を Option<Result<T, JoinError>> として返します。順番待ちがなく、パニックしたタスクがあってもそのタスク分だけ JoinError として受け取れるため、残りのタスクを止めずに継続できます。
Go であれば errgroup.Group が近い役割を担いますが、errgroup はデフォルトで「最初のエラーで ctx をキャンセルし、他のgoroutineへの協調的な停止を促す」設計です。Rustの JoinSet はそこまで自動化されておらず、「エラー時にどうするか」を呼び出し側が明示的に書く必要があります。この違い自体が、今日の実装のテーマになります。
📝 問題
以下の要件を満たす関数 run_batch を実装してください。
- シグネチャ:
async fn run_batch(inputs: Vec<u32>, per_task_timeout: Duration, total_timeout: Duration) -> BatchReport inputsの各要素に対して非同期タスクfetch(input: u32) -> Result<u32, String>を1つずつtokio::task::JoinSetに spawn する。fetchは「時間のかかる処理」を模倣する関数として以下を使ってよい(そのまま模範解答にも使用してください)。
async fn fetch(input: u32) -> Result {
tokio::time::sleep(Duration::from_millis((input % 5) * 40)).await;
if input % 7 == 0 {
return Err(format!("input {input} is not allowed (divisible by 7)"));
}
if input % 11 == 0 {
panic!("input {input} triggered an unexpected panic");
}
Ok(input * input)
}
- 各タスクの実行には
per_task_timeoutをtokio::time::timeoutで適用し、超過した場合は「タイムアウト」として扱う。 - バッチ全体には
total_timeoutを適用する。全体の制限時間を超えた場合、まだ完了していないタスクはabort()して打ち切り、そこまでに得られた結果だけを集計する。 - 結果は以下の集計構造体にまとめて返す。
#[derive(Debug, Default, PartialEq, Eq)]
struct BatchReport {
succeeded: Vec, // fetchが返した値(fetchの戻り値、input*input)
application_errors: usize, // fetchがErrを返した件数
panicked: usize, // タスクがpanicした件数
timed_out: usize, // per_task_timeoutを超過した件数
aborted_by_total_timeout: usize, // total_timeout超過でabortされた件数
}
succeeded の順序は完了順で構いません(JoinSet の性質上、順序は保証されません)。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
JoinSet<T> は spawn() した順序に関係なく、join_next().await を呼ぶたびにその時点で完了しているタスクのうち1つの結果を返します。join_next() の戻り値は Option<Result<T, JoinError>> — None は「もう管理下のタスクが1つも残っていない」ことを意味します。ループで while let Some(res) = set.join_next().await と回すのが基本形です。
ヒント2 — アプローチ
3段階のResult/Optionのネストになることを意識してください。
total_timeout全体 →tokio::time::timeout(total_timeout, ...).awaitがErr(Elapsed)を返すか- 個々のタスク →
JoinSet::join_next()がSome(Err(JoinError))を返すか(JoinError::is_panic()で判定可能) - タスク内部 →
fetch自身のtimeoutがErr(Elapsed)を返すか、fetchがErr(String)を返すか
total_timeout のかけ方は、join_next() を1回ずつ呼ぶループ全体を tokio::time::timeout で包むのではなく、ループの中で残り時間を再計算しながら1回ずつの join_next() にタイムアウトをかけるか、あるいは「ループ全体に対して1回だけ timeout をかけ、Elapsed になったら set.abort_all() してから残りを join_next() で回収する」かのどちらかです。後者の方がシンプルです。
ヒント3 — コード骨格
async fn run_batch(inputs: Vec, per_task_timeout: Duration, total_timeout: Duration) -> BatchReport {
let mut set = JoinSet::new();
for input in inputs {
set.spawn(async move {
tokio::time::timeout(per_task_timeout, fetch(input)).await
});
}
let mut report = BatchReport::default();
let collect = async {
while let Some(join_result) = set.join_next().await {
match join_result {
Ok(Ok(Ok(value))) => report.succeeded.push(value),
Ok(Ok(Err(_app_err))) => report.application_errors += 1,
Ok(Err(_elapsed)) => report.timed_out += 1,
Err(join_err) if join_err.is_panic() => report.panicked += 1,
Err(_cancelled) => {}
}
}
};
if tokio::time::timeout(total_timeout, collect).await.is_err() {
// ここで abort_all() し、残りの結果を回収する
}
report
}
abort_all() の後、join_next() を呼び続けるとabortされたタスクは JoinError(is_cancelled() == true)として返ってくるので、それを aborted_by_total_timeout に数えます。
✅ 模範解答
// Cargo.toml に以下を追加して検証すること:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
use std::time::Duration;
use tokio::task::JoinSet;
async fn fetch(input: u32) -> Result {
tokio::time::sleep(Duration::from_millis((input % 5) * 40)).await;
if input % 7 == 0 {
return Err(format!("input {input} is not allowed (divisible by 7)"));
}
if input % 11 == 0 {
panic!("input {input} triggered an unexpected panic");
}
Ok(input * input)
}
#[derive(Debug, Default, PartialEq, Eq)]
struct BatchReport {
succeeded: Vec,
application_errors: usize,
panicked: usize,
timed_out: usize,
aborted_by_total_timeout: usize,
}
async fn run_batch(
inputs: Vec,
per_task_timeout: Duration,
total_timeout: Duration,
) -> BatchReport {
let mut set: JoinSet, tokio::time::error::Elapsed>> =
JoinSet::new();
for input in inputs {
set.spawn(async move { tokio::time::timeout(per_task_timeout, fetch(input)).await });
}
let mut report = BatchReport::default();
// ループ全体に対して1回だけ total_timeout をかける。
// Elapsed になった時点で collect 用の Future ごと drop されるが、
// JoinSet 自体は main の変数として生き続けるので後段で abort_all() できる……
// はずだが、この版では set を async move の中にムーブしてしまっているため
// 実際には手が届かない。次の run_batch_correct で修正する。
let collect_result = tokio::time::timeout(total_timeout, {
let report = &mut report;
async move {
while let Some(join_result) = set.join_next().await {
match join_result {
Ok(Ok(Ok(value))) => report.succeeded.push(value),
Ok(Ok(Err(_app_err))) => report.application_errors += 1,
Ok(Err(_elapsed)) => report.timed_out += 1,
Err(join_err) if join_err.is_panic() => report.panicked += 1,
Err(_cancelled) => {}
}
}
set
}
})
.await;
match collect_result {
Ok(_finished_set) => {
// 全タスクが total_timeout 内に完了済み。追加処理は不要。
}
Err(_elapsed) => {
// total_timeout超過。setはムーブ済みで既に手が届かない設計ミス。
}
}
report
}
// --- 上の run_batch は "total_timeout超過後にJoinSetへ手が届かない" という
// 設計ミスをあえて含めた説明用のドラフトです。正しい実装は JoinSet を
// async move の外に置き、参照で貸し出すことで解決します。 ---
async fn run_batch_correct(
inputs: Vec,
per_task_timeout: Duration,
total_timeout: Duration,
) -> BatchReport {
let mut set: JoinSet, tokio::time::error::Elapsed>> =
JoinSet::new();
for input in inputs {
set.spawn(async move { tokio::time::timeout(per_task_timeout, fetch(input)).await });
}
let mut report = BatchReport::default();
// JoinSetは参照で貸し出す。timeoutされてもsetは外側の変数として残るので、
// 超過後にabort_all()し、残りをjoin_next()で回収できる。
let timed_out_overall = tokio::time::timeout(total_timeout, async {
while let Some(join_result) = set.join_next().await {
match join_result {
Ok(Ok(Ok(value))) => report.succeeded.push(value),
Ok(Ok(Err(_app_err))) => report.application_errors += 1,
Ok(Err(_elapsed)) => report.timed_out += 1,
Err(join_err) if join_err.is_panic() => report.panicked += 1,
Err(_cancelled) => {}
}
}
})
.await
.is_err();
if timed_out_overall {
set.abort_all();
while let Some(join_result) = set.join_next().await {
match join_result {
// abort_all()後に自然完了していたタスクも成功として扱う
Ok(Ok(Ok(value))) => report.succeeded.push(value),
Ok(Ok(Err(_app_err))) => report.application_errors += 1,
Ok(Err(_elapsed)) => report.timed_out += 1,
Err(join_err) if join_err.is_panic() => report.panicked += 1,
Err(_cancelled) => report.aborted_by_total_timeout += 1,
}
}
}
report
}
#[tokio::main]
async fn main() {
let report = run_batch_correct(
(1..=20).collect(),
Duration::from_millis(500),
Duration::from_secs(5),
)
.await;
println!("{report:#?}");
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn success_and_application_errors_are_separated() {
// 7の倍数(7,14)はErr、11の倍数(11)はpanic、それ以外は成功
let report =
run_batch_correct(vec![1, 2, 7, 11, 14], Duration::from_secs(1), Duration::from_secs(5))
.await;
assert_eq!(report.succeeded.len(), 2); // 1, 2
assert!(report.succeeded.contains(&1));
assert!(report.succeeded.contains(&4));
assert_eq!(report.application_errors, 2); // 7, 14
assert_eq!(report.panicked, 1); // 11
assert_eq!(report.timed_out, 0);
assert_eq!(report.aborted_by_total_timeout, 0);
}
#[tokio::test]
async fn per_task_timeout_is_recorded_separately_from_success() {
// input % 5 == 4 -> sleep 160ms。per_task_timeoutを50msにして必ず超過させる。
let report =
run_batch_correct(vec![4, 9], Duration::from_millis(50), Duration::from_secs(5)).await;
assert_eq!(report.timed_out, 2);
assert_eq!(report.succeeded.len(), 0);
}
#[tokio::test]
async fn total_timeout_aborts_remaining_tasks() {
// 20個のタスク全部に長めのsleepを入れ、total_timeoutを極端に短くする。
let inputs: Vec = (0..20).map(|i| i * 5 + 4).collect(); // すべて % 5 == 4 -> 160ms sleep
let report = run_batch_correct(
inputs,
Duration::from_secs(5), // per_taskは十分長い
Duration::from_millis(10), // totalをほぼ即座に超過させる
)
.await;
// 10ms以内には誰も完了できないので、全件がabortされる
assert_eq!(report.succeeded.len(), 0);
assert_eq!(report.aborted_by_total_timeout, 20);
}
}
cargo test でこの3件のテストがすべてokになることを実際に確認済みです(test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out)。success_and_application_errors_are_separated は成功2件・アプリケーションエラー2件・panic1件が正しく分類されること、total_timeout_aborts_remaining_tasks は10msという極端に短い全体タイムアウトで20件すべてがaborted_by_total_timeoutに計上されることを検証しています。🪜 Step-by-Step 解説
run_batchはドラフト止まりなのか模範解答にあえて途中の失敗版
run_batchを残しました。tokio::time::timeout(total_timeout, async move { ... set ... })のようにsetをasync moveブロックにムーブしてしまうと、Elapsedになった時点でそのFutureごとsetがdropされ、abort_all()を呼ぶ手段が失われます(JoinSetはdrop時に管理下の全タスクを自動abortしますが、「abortされた件数」を数える機会もなくなります)。これは「Rustの所有権規則が、設計ミスをコンパイルエラーではなく“機能できないコード”として教えてくれる」典型例です。
setを参照で貸し出すrun_batch_correctではasync { ... }(moveなし)を使い、クロージャ相当のFutureにsetとreportを参照として借用させています。こうすることでtimeoutがElapsedを返した後も、外側のスコープにsetの所有権が残ったままなので、set.abort_all()を呼べます。
abort_all()後もjoin_next()を回し切るabort_all()は「abortを要求する」だけで、即座にすべてのタスクが消えるわけではありません。実行中のタスクは次の.awaitポイントでCancelledとして終了します。そのためabort_all()の後もwhile let Some(...) = set.join_next().awaitを最後まで回し、キャンセルとして返ってきた分をaborted_by_total_timeoutに計上しています。
join_resultの型はResult<Result<Result<u32, String>, Elapsed>, JoinError>です。外側から順に「タスク自体が正常終了したか(JoinError)」「per_task_timeoutを超えなかったか(Elapsed)」「fetchがアプリケーションエラーを返さなかったか(String)」という3つの異なる失敗モードを表しています。matchのネストされたパターンで一段ずつ剥がすのが、Option/Resultを積み重ねる設計の標準的な読み方です。
💡 設計思想・なぜこう書くのか
RustのJoinSetが「エラー時に自動で全体をキャンセルする」機能を持たないのは、Go のerrgroupと対照的です。これはRustの並行処理設計に一貫する思想——「何が起きたら何をするか」を型システムとAPIの形で呼び出し側に明示させる——の表れです。JoinErrorがis_panic() / is_cancelled()という形でpanicとキャンセルを区別できるのも同じ理由で、「タスクが終わらなかった理由」を握りつぶさずに呼び出し側へ返す設計になっています。
Result<T, E>を返す関数を書くとき「エラーは呼び出し側が処理する」と考えるのと同様に、JoinSetも「タスクの失敗モードをどう扱うかはあなたが決めてください」というスタンスです。ゼロコスト抽象化の思想は「便利な自動化を提供しない」ことではなく、「自動化するかどうかも含めて選べる余地を残す」ことだと理解すると腑に落ちます。🛑 コンパイルエラーが出た場合
set.spawn(async move { ... })に渡すFutureの中で、外側の非'staticな参照(例えば&inputs)をそのままキャプチャしようとすると、以下のようなエラーになります。
--> src/main.rs:NN:9
|
NN | set.spawn(async move { fetch(&input).await });
| ^^^^^ `input` escapes the associated function body here
JoinSet::spawnはF: Future + Send + 'staticを要求します。spawnされたタスクはランタイム上で親の関数のスタックとは独立して生存する可能性があるため、参照ではなく値の所有権をムーブして渡す必要があります。今回の模範解答でfor input in inputs { set.spawn(async move { ... input ... }) }のようにu32(Copy型)をそのままムーブしているのはこのためです。もし参照を渡したい構造にしたい場合は、Arc<T>で包んでクローンして渡す設計に変える必要があります。🌐 他言語との比較
| 観点 | Go(errgroup.Group) | Java(ExecutorService + Future) | Rust(tokio::task::JoinSet) |
|---|---|---|---|
| 動的な数のタスク管理 | g.Go(func() error {...})を好きな回数呼べる | executor.submit(callable)の戻り値をList<Future<T>>で保持 | set.spawn(async {...})を好きな回数呼べる |
| 1件のエラー発生時の挙動 | デフォルトで最初のエラーのみ保持し、WithContext使用時はctxをキャンセルして他へ伝播 | 個別のFutureが例外を保持するだけで、他のタスクは自動停止しない | JoinSet自体は自動停止しない。abort_all()は呼び出し側が明示的に呼ぶ必要がある |
| 完了順の取得 | 標準では非対応(WaitGroupは全完了待ちのみ) | ExecutorCompletionServiceを別途使えば完了順取得が可能 | join_next()が標準で完了順に返す |
| panicの扱い | goroutineのpanicはプロセス全体をクラッシュさせうる(recoverが必要) | Future.get()がExecutionExceptionとして例外を包んで返す | JoinError::is_panic()でタスク単位のpanicを検知でき、プロセスは落ちない |
RustのJoinSetはJavaのExecutorCompletionServiceに近い完了順取得の思想を持ちつつ、Goのerrgroupにあるような「自動キャンセル伝播」は持たず、明示的なabort_all()呼び出しに委ねている点が最大の違いです。
🏆 実務での使いどころ
- バッチ処理のファンアウト/ファンイン: 数百件のレコードを外部APIやDBに並行問い合わせし、完了したものから順次後続処理(DB書き込み等)に流す、というストリーミング的なバッチ処理
- ヘルスチェックの並行実行: 複数の依存サービスに対して「全体で3秒以内に応答が返ったものだけ集計する」ようなタイムアウト付きヘルスチェックの実装
- 部分的な失敗を許容するAPI集約: 複数のマイクロサービスから情報を集約して1つのレスポンスを組み立てる際、一部のサービスが失敗・タイムアウトしても残りの結果で応答を返す「グレースフルデグラデーション」の実装
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
JoinSetにspawnした順にjoin_next()で結果が返ってくると思う | Vec<JoinHandle>を順番にiterateする感覚からの類推 | join_next()は完了順。どのタスクの結果か知りたい場合は、タスクの戻り値に入力値やIDを含めて自分で紐付ける必要がある |
setをasync moveブロックにムーブしても後で参照できると思う | 所有権のムーブが値のコピーのように感じられ、意識されにくい | ムーブした変数はムーブ先のスコープでしか使えない。timeout後に操作したいなら参照で貸し出す設計にする |
JoinErrorが返ってきたら常にpanicだと思う | エラー系の型は「異常系ひとまとめ」という直感 | JoinErrorはis_panic()(パニック)とis_cancelled()(abort等によるキャンセル)を区別できる。ログの出し分けに使うべき |
abort_all()を呼べば即座に全タスクが消えると思う | 名前から同期的な即時停止を連想する | abort_all()は次の.awaitポイントでのキャンセルを要求するのみ。join_next()を回し切るまでは完全には終わらない |
🚀 次のステップ
- 発展:
run_batch_correctに「アプリケーションエラーが一定件数を超えたら残りを即座にabortする(サーキットブレーカー的挙動)」を追加してみましょう - 次回予告: async trait(async-trait / RPITIT) — 非同期メソッドを持つトレイトの設計