Day 045 — JoinHandleをdropしてもタスクは止まらない。JoinSetのDropだけがタスク群を能動的にabortする

2026-09-11 🟣 上級者 / Phase 3 概念理解 tokioタスク・並行実行パターン

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

Day 041〜044 で JoinSet を土台に、動的な数のタスクの束ね方(041)、Semaphore による並行度制御(042)、mpsc による進捗通知(043)、watch による外部シャットダウン(044)と組み立ててきました。ここまで一貫して「JoinSet に集めて、abort_all() で打ち切る」という書き方をしてきましたが、実は一度も確認していない前提があります。それは——JoinHandle や JoinSet を「持っているだけの値」を drop したとき、その中のタスクはどうなるのか、という問いです。

Rustの所有権システムに慣れていると、「値を持つ変数が drop された = その値が管理していたリソースは解放される」という直感を持ちやすくなります。File は drop されればクローズされ、MutexGuard は drop されればロックが解放されます。この直感を tokio::spawn が返す JoinHandle<T> にそのまま適用すると、「JoinHandle を drop すればタスクも止まるはずだ」と考えてしまいますが、これは誤りです。tokio::spawn されたタスクは、呼び出し元とは独立してランタイムのスケジューラに直接ぶら下がる「デタッチされた」実行単位であり、JoinHandle はそのタスクの完了を観測するための窓口に過ぎません。窓口を閉じても、タスク本体(ランタイム上のスケジュール済みFuture)は生き続けます。

一方、Day 041〜044 で使ってきた JoinSet<T> は、Drop を実装する際に内部で保持している各タスクの AbortHandle を使って全タスクを能動的に abort するという設計になっています。つまり JoinHandle 単体と JoinSet は、見た目こそ「タスクをまとめて持つ入れ物」という点で似ていますが、drop 時の挙動が正反対です。この非対称性を正確に理解していないと、「グレースフルシャットダウンなんて JoinSet を drop すればいいのでは?」という誤った設計判断(Day 044 で明示的な watch + select! を実装した意味を見失う判断)につながります。

📌
今日は実装ではなく、この「drop とタスクのライフタイムの関係」を3つのパターンを比較する実験コードで確認し、正確なメンタルモデルを作ることがゴールです。

📝 問題

以下の3つのケースについて、「メインタスクを一定時間 sleep させて観測したとき、AtomicUsize カウンタはいくつになっているか」を先に予想してから、実際に cargo test で検証するコードを書いてください。

async fn record_completion(counter: Arc, delay_ms: u64) {
    tokio::time::sleep(Duration::from_millis(delay_ms)).await;
    counter.fetch_add(1, Ordering::SeqCst);
}
  • ケースA: tokio::spawn(record_completion(...)) の戻り値(JoinHandle)を、await せずに即座に drop() する。1個のタスクをspawnした場合、カウンタは最終的にいくつになるか?
  • ケースB: 5個のタスクを tokio::spawn して Vec<JoinHandle<()>> に集める。1つもawaitせずに Vec ごと drop() する。カウンタは最終的にいくつになるか?
  • ケースC: 5個のタスクを JoinSet<()>::spawn で JoinSet に集める。1つもawaitせずに JoinSet ごと drop() する。カウンタは最終的にいくつになるか?

要件:

  • 上記3ケースそれぞれについて、#[tokio::test] で「drop後に十分な時間 sleep してからカウンタの値を assert する」テストを書いてください(各タスクの delay_ms は30ms程度、観測用の待機は100ms程度を目安にしてください)
  • テストを書く前に、3ケースそれぞれの予想値をコメントとして残してください
  • なぜ JoinHandle(またはそれを集めたVec)と JoinSet とで挙動が異なるのか、「所有権を持つ」ことと「実行を制御する権限を持つ」ことの違いという観点で、1〜2文で説明を加えてください

🔍 ヒント(段階的開示)

ヒント1 — 方向性

Rustの Drop はあくまで「そのメモリ上の値」の後始末をするだけです。tokio::spawn が返す JoinHandle<T> という値そのものの中身(内部的には共有された状態へのポインタ程度のもの)と、実際に実行されている非同期タスク本体は、別の場所で管理されている別物だと捉え直してください。JoinHandle を drop することと、タスクの実行を止めることは、本当に同じ意味を持つ操作でしょうか?

ヒント2 — アプローチ

tokio::task::JoinHandle には明示的に .abort() というメソッドがあります。「明示的なメソッドを呼ばないと止まらない」という設計は、JoinHandle が単なる drop では何もキャンセルしないことを示唆しています。一方 JoinSet のドキュメントには「dropped 時に内部の全タスクを abort する」という趣旨の記述があります。JoinSet は AbortHandle の集合を内部に保持しており、その Drop 実装の中で明示的に abort ループを回している、という実装上の違いに注目してください。

ヒント3 — コード骨格
#[tokio::test]
async fn case_a_bare_spawn() {
    let counter = Arc::new(AtomicUsize::new(0));
    let handle = tokio::spawn(record_completion(Arc::clone(&counter), 30));
    drop(handle);

    tokio::time::sleep(Duration::from_millis(100)).await;
    assert_eq!(counter.load(Ordering::SeqCst), /* 予想値を入れる */);
}

// ケースB, ケースCも同様の形で、Vec/JoinSetをdropする箇所を差し替えて書く

✅ 模範解答

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

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinSet;

/// sleep後にカウンタをインクリメントすることで、「このタスクが最後まで
/// 実行されたかどうか」を外部から観測できるようにするための道具。
async fn record_completion(counter: Arc, delay_ms: u64) {
    tokio::time::sleep(Duration::from_millis(delay_ms)).await;
    counter.fetch_add(1, Ordering::SeqCst);
}

#[tokio::main]
async fn main() {
    let counter_a = Arc::new(AtomicUsize::new(0));
    {
        let handle = tokio::spawn(record_completion(Arc::clone(&counter_a), 30));
        drop(handle); // JoinHandleをdropしてもタスクは走り続ける(デタッチ)
    }

    let counter_b = Arc::new(AtomicUsize::new(0));
    {
        let mut handles = Vec::new();
        for _ in 0..5 {
            handles.push(tokio::spawn(record_completion(Arc::clone(&counter_b), 30)));
        }
        drop(handles); // Vecをdropしても中身のタスクは止まらない
    }

    let counter_c = Arc::new(AtomicUsize::new(0));
    {
        let mut set: JoinSet<()> = JoinSet::new();
        for _ in 0..5 {
            set.spawn(record_completion(Arc::clone(&counter_c), 30));
        }
        drop(set); // JoinSetをdropすると、追跡している全タスクがabortされる
    }

    tokio::time::sleep(Duration::from_millis(150)).await;
    println!("A (bare spawn):        {}/1", counter_a.load(Ordering::SeqCst));
    println!("B (Vec):   {}/5", counter_b.load(Ordering::SeqCst));
    println!("C (JoinSet):           {}/5", counter_c.load(Ordering::SeqCst));
}

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

    #[tokio::test]
    async fn case_a_bare_spawn_keeps_running_after_handle_is_dropped() {
        // 予想: 1 (JoinHandleのdropはタスクをキャンセルしない)
        let counter = Arc::new(AtomicUsize::new(0));
        let handle = tokio::spawn(record_completion(Arc::clone(&counter), 30));
        drop(handle);

        tokio::time::sleep(Duration::from_millis(100)).await;
        assert_eq!(
            counter.load(Ordering::SeqCst),
            1,
            "bare spawnはhandleをdropしても最後まで実行されるはず"
        );
    }

    #[tokio::test]
    async fn case_b_vec_of_join_handles_keeps_tasks_running_after_vec_is_dropped() {
        // 予想: 5 (Vecはただの入れ物。中身の各JoinHandleが
        // dropされる際の挙動はケースAと同じなので、全タスクが完走する)
        let counter = Arc::new(AtomicUsize::new(0));
        let mut handles = Vec::new();
        for _ in 0..5 {
            handles.push(tokio::spawn(record_completion(Arc::clone(&counter), 30)));
        }
        drop(handles);

        tokio::time::sleep(Duration::from_millis(100)).await;
        assert_eq!(
            counter.load(Ordering::SeqCst),
            5,
            "Vecをdropしても中のタスクは1つも止まらないはず"
        );
    }

    #[tokio::test]
    async fn case_c_join_set_aborts_all_tasks_when_dropped() {
        // 予想: 0 (JoinSetのDrop実装は内部のAbortHandle全てにabortを呼ぶ)
        let counter = Arc::new(AtomicUsize::new(0));
        let mut set: JoinSet<()> = JoinSet::new();
        for _ in 0..5 {
            set.spawn(record_completion(Arc::clone(&counter), 30));
        }
        drop(set);

        tokio::time::sleep(Duration::from_millis(100)).await;
        assert_eq!(
            counter.load(Ordering::SeqCst),
            0,
            "JoinSetをdropすると追跡中の全タスクがabortされ、1つも完走しないはず"
        );
    }
}

// 説明: JoinHandle(や、それをただ集めただけのVec)は「タスクの完了を
// 観測する権利」しか持っていない——所有権に似た形をしているが、実行を
// 止める権限までは持っていない。一方JoinSetは、単なる入れ物ではなく
// 「自分がspawnしたタスク群の生存管理そのものに責任を持つ」型として
// 設計されており、そのDrop実装がタスクの実行権限(abort)まで行使する。
🧪
cargo test でこの3件が全て ok になることを確認済みです。ケースA・Bは assert_eq!(..., 1) / assert_eq!(..., 5) が成立し(タスクは止まらない)、ケースCは assert_eq!(..., 0) が成立します(JoinSet のdropで全タスクがabortされる)。

🪜 Step-by-Step 解説

1
tokio::spawn はタスクをランタイムに「委譲」する
tokio::spawn(fut) を呼んだ瞬間、渡した Future の所有権はランタイムのタスクキューに移ります。戻り値の JoinHandle<T> はこのタスクへの「参照票」のようなもので、.await すればタスクの結果を受け取れますが、これを持っていること自体はタスクの実行に何の影響も与えません。
2
JoinHandle の Drop は「何もしない」に等しい
JoinHandle<T> の Drop 実装は、内部的な参照カウント(タスクの結果を保持する共有状態への参照)を減らすだけで、タスクのキャンセルは一切トリガーしません。これは Vec<JoinHandle<T>> を drop した場合も同じで、各要素の Drop が個別に呼ばれるだけです。だからケースA・Bはどちらもカウンタが最終値まで到達します。
3
JoinSet は「観測用の入れ物」ではなく「管理責任を持つ型」
JoinSet<T> は各タスクをspawnする際、内部で AbortHandle も一緒に保持しています。JoinSet の Drop 実装は、保持している全ての AbortHandle に対して abort() を呼び出すよう設計されています。だからケースCでは、drop(set) した瞬間に5つのタスク全てにキャンセル要求が飛び、record_completion の sleep は完了前に中断されます(Rustの async fn は .await ポイントで初めてキャンセルをチェックするため、正確には「次にポーリングされたときにタスクが終了する」動きですが、結果としてカウンタのインクリメントには到達しません)。
4
Day 044 の設計判断を振り返る
Day 044 で「JoinSet を drop するだけでシャットダウンにならないか」と考えた場合、実は drop するだけで全タスクは止まります。しかしそれでは「何件成功し、何件シャットダウンで打ち切られたか」を集計できません。abort_all() を明示的に呼んで join_next() でその後の結果を回収する、という Day 044 の実装は、単に「止める」だけでなく「止めた結果を観測する」ことまで含んだ設計だった、と今日の実験を通じて裏付けられます。

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

Rustの所有権システムは「値の生存期間とリソースの生存期間を一致させる」ことをRAII(Resource Acquisition Is Initialization)という形で徹底しますが、tokio::spawn されたタスクはこの原則から意図的に外れています。タスクを呼び出し元の生存期間から切り離す「デタッチされた並行性」こそが tokio::spawn の存在意義(呼び出し元の関数がreturnした後もタスクを走らせ続けられる)であり、これは所有権システムの型では表現しきれない設計上の選択です。一方 JoinSet は、この「デタッチされ放題」の世界に対して、ライブラリ側が独自に「構造化された並行性(structured concurrency)」——タスクの生存期間を、それを保持する値の生存期間に改めて縛り直す——という規律を型として提供したもの、と捉えることができます。

⚠️
「Rustは所有権システムがあるからリソースリークが起きない」という理解は、ヒープメモリやファイルハンドルには正しく当てはまりますが、tokio::spawnされたタスクという「実行中の計算」そのものには、明示的に構造化しない限り当てはまりません。この非対称性を意識して、JoinHandleを作りっぱなしで放置する「fire-and-forget」が許容される場面と、JoinSetのように生存期間を縛るべき場面を、設計時に区別する必要があります。

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

今日のテーマは概念理解が中心ですが、この実験コードを書く際によくあるミスとして、Arc::clone を忘れてループの2周目以降で同じ counter を再利用しようとするケースがあります。

// アンチパターン: cloneせずにループの中でcounterをそのまま使おうとする
let counter = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..5 {
    handles.push(tokio::spawn(record_completion(counter, 30))); // 2周目でエラー
}
error[E0382]: use of moved value: `counter`
  --> src/main.rs:NN:44
  |
N-2 |  let counter = Arc::new(AtomicUsize::new(0));
  |    --- move occurs because `counter` has type `Arc<AtomicUsize>`, which does not implement the `Copy` trait
N-1 |  for _ in 0..5 {
N   |    handles.push(tokio::spawn(record_completion(counter, 30)));
  |                         ^^^^^^^ value moved here, in previous iteration of loop
⚠️
読み方: Arc<AtomicUsize>はCopyではないため、ループ内でcounterをそのまま使うと1周目で所有権がtokio::spawnに移動(ムーブ)し、2周目では既に値がない変数を使おうとしてコンパイルエラーになります。これは「複数の非同期タスクが同じデータを共有する」場面での定番の書き方——ループの各反復の中でArc::clone(&counter)し、参照カウントを1つ増やしてからタスクへムーブする——を思い出させてくれるエラーです。Arcのcloneはヒープ上のデータ自体はコピーせず、参照カウントを増やすだけの軽量な操作である点も、Day 013前後のRc/Arcの学習内容とつながっています。

🌐 他言語との比較

観点Go(goroutine)Java(ExecutorService)Rust(JoinHandle vs JoinSet)
個々のタスクの既定の生存期間go func(){...}()は常にデタッチ。呼び出し元との関連付けをそもそも持たないsubmit()が返すFutureを捨てても、タスク自体はスレッドプールで実行され続けるtokio::spawnもGo同様にデタッチ。JoinHandleを捨ててもタスクは続く
「まとめて持つ入れ物」をdropした場合該当する標準の入れ物自体が存在しない(sync.WaitGroupは完了を待つだけでキャンセル機構ではない)ExecutorService自体は明示的にshutdown()/shutdownNow()を呼ぶまで残り続ける。GCで回収されても実行中タスクは止まらないVec<JoinHandle>はただの入れ物なので何も起きないが、JoinSetはdrop時に全タスクをabortする専用の設計
構造化された並行性のサポート標準では薄い。errgroup.Group等の準標準ライブラリで補う薄い。Java 21の構造化並行性APIはまだプレビュー機能JoinSetやtokio_util::task::TaskTrackerがエコシステムレベルで提供
RAII的なタスク終了の実現例標準にはない標準にはないC++のstd::jthreadがむしろ近い: デストラクタでrequest_stop()+join()を自動的に行う

面白いのは、Go・Javaの「デタッチが既定」という設計はRustのtokio::spawn単体の挙動と揃っている一方、C++20で導入されたstd::jthreadは「スコープを抜けたら自動的に停止要求を出してjoinする」というRAII的な設計を持ち、これはむしろJoinSetの挙動に近いということです。つまりRustは、言語の標準的な非同期タスク生成(tokio::spawn)自体は他言語同様「デタッチが既定」としつつ、JoinSetというライブラリレベルの型で構造化された代替手段を別途提供する、という2階建ての設計を取っています。

🏆 実務での使いどころ

  • fire-and-forgetが正しい選択の場面: HTTPリクエストへのレスポンスを返した後に、アクセスログを非同期で外部の集計サービスに送信するようなタスク。リクエストハンドラの生存期間にログ送信タスクを縛る必要はなく、tokio::spawnで投げっぱなしにするのが自然
  • 構造化が必須の場面: リクエストハンドラが内部で複数のタスクをspawnして並行処理し、その結果を集約してレスポンスに含める場合。ハンドラがタイムアウトやクライアント切断で早期returnする際、spawnした子タスクが野放しで走り続けるのは望ましくない(リソースリークやコネクションプールの枯渇に直結する)ため、JoinSetやtokio_util::task::TaskTracker + CancellationTokenでスコープを縛る
  • グレースフルシャットダウンの設計判断: Day 044のように「打ち切った件数を集計する」要件があるなら、単純なdrop任せではなくabort_all() + join_next()による明示的な回収ループが必要になる、という判断根拠を今日の実験が裏付ける

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
JoinHandleをdropすればタスクはキャンセルされると思い込むFileやMutexGuardなど、drop=リソース解放という他の型の直感を持ち込んでしまうJoinHandleのdropは単なる観測権の放棄。タスクを止めたいなら.abort()を明示的に呼ぶ必要がある
Vec<JoinHandle>もグループ管理してくれると思うJoinSetと見た目(複数のタスクを1つの値にまとめる)が似ているVecはただのコレクション型で、要素のdrop挙動をそのまま踏襲するだけ。グループとしての生存期間管理はしない
JoinSetをdropすれば結果も取得できると思う「まとめて管理してくれる」という理解から連想して結果回収も自動だと誤解するJoinSetのdropは全タスクをabortするだけで、結果は破棄される。結果が必要ならjoin_next()で明示的に回収してからdropする必要がある
プロセス(main関数)が終了すれば、spawnした全タスクも安全に終わると思うOSプロセス終了時にスレッドも全て消えるという直感の延長#[tokio::main]はスコープを抜ける際にRuntime全体をdropし、これによって実行中の全タスクが強制終了される。これは「個々のJoinHandle/JoinSetのdrop」とは別の「ランタイム全体のshutdown」という、また別の現象

🚀 次のステップ

  • 発展: tokio_util::task::TaskTracker と CancellationToken を使い、JoinSetよりもさらに構造化された(親子関係を持つキャンセルの伝播ができる)タスク管理を実装し、Day 044のwatchベースのシャットダウンと比較する
  • 次回予告: async trait(async-trait / RPITIT) — 非同期メソッドを持つトレイトの設計

🎯 自己評価

自分の回答

気づき・メモ