Day 038 — spawn・join!・select!。同じ「並行実行」でも独立性・パニック伝播・キャンセルが違う

2026-09-04 🟣 上級者 / Phase 3 実装 tokioタスク・並行実行パターン

📚 背景知識(読んでから問題へ)

Day 037ではFutureを手書きし、「pollされて初めて前進する」という怠惰な値としての性質を学びました。今日はそのFutureを複数同時に走らせる3つの方法——tokio::spawn・tokio::join!・tokio::select!——の違いを、実際に手を動かして体で覚えます。この3つはどれも「並行実行」を実現しますが、タスクの独立性・パニック時の挙動・キャンセルの仕方がまったく異なります。この違いを理解せずに使い分けを誤ると、実務では「エラー処理が漏れる」「意図せずタスクがキャンセルされてリソースリークする」といった事故につながります。

tokio::spawn — 独立したタスクを生成する。tokio::spawn(some_future)は渡したFutureを新しいタスクとしてランタイムに登録します。呼び出し元とは完全に独立して実行され、渡すFutureは'staticかつSendである必要があります。タスク内でパニックが起きても呼び出し元には伝播せず、JoinHandle::awaitの戻り値Result<T, JoinError>のErrとして捕捉できます。

📌
tokio::join! — 同じタスク内で複数のFutureを同時に進める。新しいタスクは生成せず、呼び出し元と同じタスクの中で複数のFutureを交互にpollします。渡すFutureは'staticである必要がなく、周囲のスコープの変数を普通に借用できます。いずれか1つがパニックすると、そのパニックは呼び出し元のタスク全体に伝播します(spawnのようにJoinErrorとして握りつぶされることはありません)。

tokio::select! — 最初に完了したものだけを採用し、残りを打ち切る。複数のFutureを同時にpollし、最初にReadyになった1つだけを採用します。選ばれなかった残りのFutureはその場でドロップされます(キャンセルされます)。これはタイムアウト処理など必須のパターンですが、ドロップされたFuture内で途中まで進んでいた処理は失われるため、各Futureが「途中でキャンセルされても状態が壊れない」——キャンセル安全(cancel-safe)——かどうかを設計者が意識する必要があります。

📝 問題

疑似的な3つの「APIエンドポイントへの取得処理」fetch_endpoint(id, delay_ms, should_panic)(tokio::time::sleepで遅延をシミュレートするasync fn)を題材に、以下3つの要件を実装してください。

要件1(spawn + JoinHandle): 3つのfetch_endpoint呼び出しをtokio::spawnでそれぞれ独立したタスクとして起動し、Vec<JoinHandle<String>>で管理してください。3つ目のタスクはshould_panic=trueにしてパニックさせ、全タスク完了後にパニックしたタスクだけErrとして捕捉でき、他の2つは正常にOkが得られることを確認してください。

要件2(join!): パニックしない2つのfetch_endpoint呼び出しをtokio::join!で同時に実行し、両方の結果をタプルで受け取ってください。要件1との違い(新規タスクを作らない・パニックが伝播する)をコメントで説明してください。

要件3(select! とキャンセル安全性の検証): delay_ms=300のfetch_endpointとdelay_ms=100のtokio::time::sleep(タイムアウト役)をtokio::select!で競わせてください。fetch_endpointの内部ではsleep前後でprintln!によるログを出し、タイムアウトが先に成立した場合、fetch_endpoint側の「完了ログ」が出力されないこと(=キャンセルされて最後まで実行されなかったこと)を実行結果で確認してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

3つとも「複数のFutureを同時に扱う」という点では同じに見えますが、問いは「もし1つが失敗したら?」「もし2つとも欲しいのか、それとも最初の1つでいいのか?」という運用上の意図の違いです。spawnは「独立させて後で結果を集める」、join!は「まとめて全部待つ」、select!は「最初の1つだけでよく、残りは打ち切ってよい」——この3つの意図をコードにどう落とし込むかを考えてください。

ヒント2 — アプローチ

要件1: Vec<tokio::task::JoinHandle<String>>を作り、for handle in handles { let r = handle.await; ... }のように順番に.awaitします。JoinErrorには.is_panic()メソッドがあり、パニックによる失敗かどうかを判定できます。
要件2: let (a, b) = tokio::join!(fetch_endpoint(1, 100, false), fetch_endpoint(2, 50, false));とするだけです。spawnと違いJoinHandleは登場しません。
要件3: tokio::select! { result = fetch_endpoint(1, 300, false) => { ... }, _ = tokio::time::sleep(Duration::from_millis(100)) => { println!("timeout"); } }という形にします。fetch_endpointの中でsleepの前と後の両方にprintln!を仕込み、「後」の方のログが出力されないことを目で確認してください。

ヒント3 — コード骨格
use std::time::Duration;
use tokio::time::sleep;

async fn fetch_endpoint(id: u32, delay_ms: u64, should_panic: bool) -> String {
    println!("[fetch {id}] start (delay={delay_ms}ms)");
    sleep(Duration::from_millis(delay_ms)).await;
    if should_panic {
        panic!("endpoint {id} returned malformed response");
    }
    println!("[fetch {id}] completed"); // select!でキャンセルされるとこの行は出力されない
    format!("endpoint {id} response")
}

async fn fetch_all_spawn() -> Vec<Result<String, tokio::task::JoinError>> {
    let handles = vec![
        tokio::spawn(fetch_endpoint(1, 100, false)),
        tokio::spawn(fetch_endpoint(2, 50, false)),
        tokio::spawn(fetch_endpoint(3, 80, true)),
    ];
    // ここで各handleを順にawaitし、Vec<Result<..>>に集める
    todo!()
}

✅ 模範解答

// Cargo.toml に以下を追加して検証すること:
// [dependencies]
// tokio = { version = "1", features = ["full"] }

use std::time::Duration;
use tokio::time::sleep;

/// 疑似APIエンドポイント。delay_ms分だけ待ってから結果を返す。
/// should_panic=trueの場合、待機後にpanic!する(要件1の異常系検証用)。
async fn fetch_endpoint(id: u32, delay_ms: u64, should_panic: bool) -> String {
    println!("[fetch {id}] start (delay={delay_ms}ms)");
    sleep(Duration::from_millis(delay_ms)).await;
    if should_panic {
        panic!("endpoint {id} returned malformed response");
    }
    // select!でこのFutureがキャンセルされた場合、以降の行は実行されない。
    println!("[fetch {id}] completed");
    format!("endpoint {id} response ({delay_ms}ms)")
}

/// 要件1: tokio::spawnで3つを独立タスクとして起動し、
/// 全タスクの結果(Result<String, JoinError>)をVecで集約する。
/// spawnされたタスクはこの関数(呼び出し元のタスク)とは完全に独立して走るため、
/// 3つ目がpanicしても、この関数自体はpanicしない。
/// JoinError::is_panic()でパニックによる失敗かどうかを判定できる。
async fn fetch_all_spawn() -> Vec<Result<String, tokio::task::JoinError>> {
    let handles: Vec<tokio::task::JoinHandle<String>> = vec![
        tokio::spawn(fetch_endpoint(1, 100, false)),
        tokio::spawn(fetch_endpoint(2, 50, false)),
        tokio::spawn(fetch_endpoint(3, 80, true)), // 意図的にpanicさせる
    ];

    let mut results = Vec::with_capacity(handles.len());
    for handle in handles {
        results.push(handle.await);
    }
    results
}

/// 要件2: tokio::join!で2つを同じタスク内で同時に進める。
/// spawnと違い新しいタスクは生成されない(OSスレッドを追加消費しない)。
/// もしどちらかがpanicした場合、そのpanicはこの関数の呼び出し元まで
/// そのまま伝播する(JoinErrorのようなラップは存在しない)。
async fn fetch_all_join() -> (String, String) {
    let (a, b) = tokio::join!(
        fetch_endpoint(1, 100, false),
        fetch_endpoint(2, 50, false),
    );
    (a, b)
}

/// 要件3: tokio::select!でfetch_endpoint(300ms)とtimeout(100ms)を競わせる。
/// 100ms経過時点でtimeoutブランチが先にReadyになるため、
/// fetch_endpoint側のFutureはpollの途中(sleep(300ms)の待機中)でドロップされる。
/// その結果「[fetch 1] completed」のログは出力されない。
async fn fetch_with_timeout() {
    tokio::select! {
        result = fetch_endpoint(1, 300, false) => {
            println!("  -> fetch側が先に完了: {result}");
        }
        _ = sleep(Duration::from_millis(100)) => {
            println!("  -> タイムアウトが先に成立。fetch(1)はキャンセルされた");
        }
    }
}

#[tokio::main]
async fn main() {
    println!("=== 要件1: tokio::spawnによる独立タスク実行 ===");
    let results = fetch_all_spawn().await;
    for (i, r) in results.iter().enumerate() {
        match r {
            Ok(s) => println!("  task[{i}] -> Ok: {s}"),
            Err(e) => println!("  task[{i}] -> Err(JoinError): is_panic={}", e.is_panic()),
        }
    }
    assert!(results[0].is_ok());
    assert!(results[1].is_ok());
    assert!(results[2].is_err());
    assert!(results[2].as_ref().unwrap_err().is_panic());

    println!("\n=== 要件2: tokio::join!による構造化並行実行 ===");
    let (a, b) = fetch_all_join().await;
    println!("  join! results: {a} / {b}");

    println!("\n=== 要件3: tokio::select!によるタイムアウト競争とキャンセル ===");
    fetch_with_timeout().await;
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Instant;

    #[tokio::test]
    async fn spawn_isolates_panic_as_join_error() {
        let results = fetch_all_spawn().await;
        assert!(results[0].is_ok());
        assert!(results[1].is_ok());
        assert!(results[2].is_err());
        assert!(results[2].as_ref().unwrap_err().is_panic());
    }

    #[tokio::test]
    async fn join_runs_both_concurrently_not_sequentially() {
        let start = Instant::now();
        let (a, b) = fetch_all_join().await;
        // 逐次実行なら150ms、並行実行なら最大値の100msに近い時間で終わるはず
        assert!(start.elapsed() < Duration::from_millis(140));
        assert!(a.contains("endpoint 1"));
        assert!(b.contains("endpoint 2"));
    }

    #[tokio::test]
    async fn select_cancels_the_losing_branch_before_300ms() {
        let start = Instant::now();
        fetch_with_timeout().await;
        // 300ms(fetch側)を待たず、100ms(timeout側)で返ってくるはず
        assert!(start.elapsed() < Duration::from_millis(200));
    }
}
🧪
実行すると、要件1ではtask[0] -> Ok: ...、task[1] -> Ok: ...、task[2] -> Err(JoinError): is_panic=trueという3行が得られ、1つのパニックが他の2タスクに影響しないことが数値で裏付けられます。要件3では[fetch 1] startのログは出るものの[fetch 1] completedは出力されず、代わりに「タイムアウトが先に成立。fetch(1)はキャンセルされた」というログで終わります。

🪜 Step-by-Step 解説

1
spawnは「タスク境界」を作る
tokio::spawn(fetch_endpoint(3, 80, true))は、渡したFutureをランタイムのスケジューラに直接登録します。この時点で呼び出し元とは別の「実行単位」になるため、内部でpanic!が起きても、そのpanicはタスク境界でランタイムに捕捉され、呼び出し元には伝播しません。代わりにJoinHandle::awaitがErr(JoinError)を返し、is_panic()でパニック起因かどうかを判定できます。OSプロセスにおける「子プロセスの異常終了を親プロセスがwait()で検知する」構造に近い設計です。
2
join!は「タスク境界」を作らない
tokio::join!は新しいタスクを生成しないため、渡したFutureは呼び出し元のタスクの一部としてそのままpollされます。したがって内部でpanic!が起きれば、それは通常の関数呼び出しでのpanic!と同じように、join!を呼んでいる関数自体をパニックさせます。「タスクを分離するかどうか」が、パニックの影響範囲を決めています。
3
select!は「敗者をdropする」
select!マクロは渡された各Futureを同時にpollし、最初にReadyを返した枝を除いて残りのFutureをそのままスコープ外に出す(=Dropする)実装になっています。今回は100ms版のsleepが先に完了するため、fetch_endpoint(1, 300, false)はsleep(300ms)の待機中(=Futureの内部状態としては「まだ途中」)でドロップされます。ドロップされた時点でそのFutureのスタックは破棄されるため、println!("[fetch 1] completed")に到達する前に消えてなくなります。

💡 設計思想・なぜこう書くのか

spawn・join!・select!という3つの異なるAPIが用意されているのは、「並行実行」という一つの言葉が実際にはタスクの独立性の強さという1本の軸の上にある異なる要求を指しているためです。spawnは最も独立性が強く(他タスクの障害から隔離したい)、join!は独立性を持たせず全結果を確実に得たい、select!はそもそも一部の結果を「要らない」と早期に判断したい——という3つの異なるニーズに、Rust/tokioはそれぞれ専用の型・マクロで応えています。

📌
「1つの汎用APIに全部詰め込む」のではなく、意図が違えば型・APIも違うというのがRustのエコシステム全体に通底する設計哲学です。特にselect!が「キャンセル安全性」という新しい責務をプログラマに要求する点は、Go言語のselectにはない、Rustの所有権システムならではの考慮事項です。

🛑 コンパイルエラーが出た場合

fetch_endpointの引数に、呼び出し元のスタック上にある値への参照(例: &String)を渡してtokio::spawnしようとすると、次のようなエラーになります。

error[E0521]: borrowed data escapes outside of function
  --> src/main.rs:NN:5
  |
  | tokio::spawn(fetch_endpoint_by_ref(&name, 100, false))
  | ^^^^^^^^^^^^ `name` escapes the function body here
  |
  = note: `tokio::spawn` requires the future to be `'static`
⚠️
読み方: tokio::spawnが受け取るFutureは、呼び出し元の関数がとっくに終わった後もランタイム上で生き続ける可能性があります。したがって、そのFutureが「関数のローカル変数への参照」のような短命な借用を内部に持っていると、参照先がすでに破棄された後にアクセスする危険(ダングリング参照)が生まれます。コンパイラはこれを防ぐため、spawnに渡すFutureに'static境界を要求します。修正方法は、参照ではなく所有権を持つ値(Stringのクローンなど)をmoveでFutureにキャプチャさせることです。join!にはこの制約がないため、同じ処理でも「参照をそのまま渡せる/渡せない」という違いが実務でも頻繁に選択の決め手になります。

🌐 他言語との比較

観点Go(goroutine + channel/select)JavaScript(Promise.all / Promise.race)Rust(tokio: spawn / join! / select!)
独立タスク生成go func() { ... }() — 起動した瞬間から独立、panicはプロセス全体を落とす(recoverしない限り)該当なし(Promise生成自体は独立実行だが、失敗は.catchで個別に捕捉可能)tokio::spawn — JoinErrorとして安全に捕捉、他タスクに影響しない
全部待つsync.WaitGroup + goroutineの組み合わせ(型システムによる保証なし)Promise.all([...]) — 1つでもrejectすると全体がrejecttokio::join! — 同一タスク内、1つでもpanicすれば伝播
最速の1つを採用select { case <-ch1: ... case <-ch2: ... }Promise.race([...])tokio::select! — 敗者はDropされキャンセルされる
キャンセル時の後始末チャンネルクローズやcontextキャンセルを手動で伝える必要がある敗れたPromiseはそのまま実行され続ける(キャンセルされない)敗れたFutureはDropによって即座に停止・リソース解放される(デストラクタが効く)

特に注目すべきはキャンセル時の挙動の違いです。JavaScriptのPromise.raceでは「負けた」Promiseもバックグラウンドで実行され続けます(キャンセルという概念自体がない)。一方Rustのselect!は、負けたFutureを実際にDropするため、確保していたリソース(ファイルハンドル・ロックなど)はDrop実装に従って確実に解放されます。これは「値の生存期間が終わったら何が起きるか」を型システムとDropトレイトで保証するRustならではの利点であり、同時に「Dropされる瞬間に中途半端な状態を残さないこと(キャンセル安全性)」をプログラマが自分で設計する責任にもなります。

🏆 実務での使いどころ

  • spawn: Webサーバーで各リクエストを個別タスクとして処理する(1リクエストのpanicが他のリクエスト処理を巻き込まないようにする)、バックグラウンドでのログ送信・メトリクス収集など「失敗しても本処理に影響させたくない」処理
  • join!: 1つのHTTPレスポンスを作るために、複数の下流サービス(ユーザー情報API・在庫API・価格API等)へ同時に問い合わせ、全部の結果が揃ってからレスポンスを組み立てる場合
  • select!: HTTPクライアントでのタイムアウト処理(実際の通信 vs sleepのレース)、複数のデータソース(キャッシュ vs DB)のうち速い方を採用する処理、シャットダウンシグナル(ctrl_c())とメインループの競合待ち

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
join!もタスクを新規生成すると思い込むspawnと見た目の役割(複数を同時に扱う)が似ているjoin!は同一タスク内でのポーリングの多重化に過ぎず、OSスレッドや新規tokioタスクは一切生成しない
spawnしたJoinHandleを.awaitしなければタスクが動かないと思う他言語の遅延評価的なFutureのイメージを引きずるspawnした時点でタスクはランタイムに登録され独立して走り始める。.awaitは「結果を待って受け取る」だけの操作であり、.awaitしなくてもタスクは実行される
select!で負けたFutureが裏で処理を続けると思うJavaScriptのPromise.raceの挙動に引きずられるRustのselect!は負けたFutureをその場でDropし、以降ポーリングされることはない。途中結果を保持したい場合は明示的にspawnして切り離す必要がある
select!のcancel-safetyを意識せず、途中状態を持つFutureをそのまま使う「キャンセルされる」という概念に馴染みがない言語出身の場合に見落としがち一部を書き込み済みの状態でDropされると不整合が生じる処理は、select!の枝に直接置かず、spawnで独立させるかCancellationToken等で明示的にキャンセルポイントを制御する設計にする

🚀 次のステップ

  • 発展: tokio::select!に3つ目の枝としてtokio::signal::ctrl_c()を追加し、Ctrl+Cで安全にシャットダウンする実装に挑戦してみましょう
  • 次回予告: async trait(async-trait / RPITIT) — トレイトに非同期メソッドを持たせる設計と、async-traitクレートとRPITIT(Return Position Impl Trait In Trait)の使い分けに進みます

🎯 自己評価

自分の回答

気づき・メモ