Day 043 — mpsc Senderをforループでcloneし忘れると2周目で移動エラー。cloneしても余分なクローンを持ち続けると受信側は永遠に終わらない

2026-09-09 🟣 上級者 / Phase 3 デバッグ・コードレビュー tokioタスク・並行実行パターン

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

Day 042 で run_batch_bounded を実装し、「タスクの数は動的」「同時実行数には上限がある」「全体にも個別にもタイムアウトがある」の3つを満たす設計を組みました。今日はこれに実務でほぼ必ず追加要求として出てくる機能——バッチの進捗をリアルタイムに外へ通知する——を足そうとして、チームメンバーが書いたコードをレビューします。

要求は単純です。「各タスクが完了するたびに、進捗イベントを tokio::sync::mpsc チャンネル経由で外部(CLIの進捗バー・WebSocket配信・ログ集約など、何でもよい)に送りたい」。バッチ処理関数の外にチャンネルの受信側 Receiver を渡し、送信側 Sender をバッチ処理関数に渡す設計です。

チームメンバーは以下のようなコードを書き、プルリクエストを出してきました。

async fn run_batch_with_progress(
    inputs: Vec,
    max_concurrency: usize,
    per_task_timeout: Duration,
    total_timeout: Duration,
    progress_tx: mpsc::UnboundedSender,
) -> BatchReport {
    let semaphore = Arc::new(Semaphore::new(max_concurrency));
    let mut set: JoinSet, tokio::time::error::Elapsed>> =
        JoinSet::new();

    for input in inputs {
        let semaphore = Arc::clone(&semaphore);
        set.spawn(async move {
            let _permit = semaphore.acquire_owned().await.expect("semaphore is never closed");
            let result = tokio::time::timeout(per_task_timeout, fetch(input)).await;
            if let Ok(Ok(output)) = &result {
                let _ = progress_tx.send(ProgressEvent::Completed { input, output: *output });
            }
            result
        });
    }

    // ... 以降の集計ループはDay 042のrun_batch_boundedと同じ
    todo!()
}

cargo build を叩いた瞬間にコンパイルが通りません。Arc<Semaphore> は Arc::clone して各タスクに渡しているのに、progress_tx はそのまま for ループの中で使い回そうとしています。これは Day 042 で見た「借用がスコープを逃げようとする」エラーとは別の種類のエラーです。

さらに、このコンパイルエラーを素直に直した後——つまり progress_tx を Arc<Semaphore> と同じように「ループの中でクローンしてから move」しても、もう1つ別の落とし穴が本番相当のコードに潜んでいます。こちらはコンパイルエラーにはならず、統合テストが(あるいは本番のリクエストが)いつまで待っても完了しないという形で発覚します。

今日はこの2段階の不具合——①コンパイルエラー(Sender のムーブ)と②実行時のハング(Sender のクローンの閉じ忘れ)——を順番に発見・修正します。tokio::sync::mpsc の「チャンネルが閉じる条件」を正確に理解しているかが問われる問題です。

📌
tokio::sync::mpsc にはGoのclose(ch)やJavaでの「毒薬メッセージ(poison pill)」に相当する明示的なクローズ操作がありません。代わりに「送信側のSender(とそのクローン)が全てdropされた時点で、受信側のrecv()が自動的にNoneを返す」という設計です。これは所有権・Dropで管理する Rust らしい設計である一方、「どこかに残っている1つのクローン」に気づかないと、受信側が永遠に完了しないという新しい種類のバグを生みます。

📝 問題

以下の2つのバグを、この順番で発見・修正してください。fetch 関数・BatchReport 構造体は Day 042 と同一のものを使用します。

バグ①(コンパイルエラー)

上記のコード(progress_tx を for ループ内で move せずに使い回そうとしている版)をコンパイルすると何が起きるか、実際に出るエラーの種類を説明し、修正してください。

バグ②(実行時のハング)

バグ①を修正して run_batch_with_progress が正しくコンパイル・動作するようになったとします。これを次のように「バッチ完了時に追加でサマリーイベントを送る」ために progress_tx をもう1つクローンして保持するコードから呼び出すと、統合テストが完了しません。なぜハングするのかをSenderのクローン数とrecv()の関係から説明し、ハングしないコードに修正してください。

async fn integrate_and_wait_broken(inputs: Vec) -> (BatchReport, Vec) {
    let (tx, mut rx) = mpsc::unbounded_channel();
    let done_notifier = tx.clone(); // バッチ完了後に「完了サマリー」を送るために保持
    let batch_fut =
        run_batch_with_progress(inputs, 4, Duration::from_secs(2), Duration::from_secs(5), tx);
    let collect_fut = async {
        let mut events = Vec::new();
        while let Some(event) = rx.recv().await {
            events.push(event);
        }
        events
    };
    let (report, events) = tokio::join!(batch_fut, collect_fut);
    let _ = done_notifier.send(ProgressEvent::Completed { input: 0, output: 0 });
    (report, events)
}
  • ProgressEvent は以下を使用してください。
#[derive(Debug, Clone, PartialEq, Eq)]
enum ProgressEvent {
    Completed { input: u32, output: u32 },
}
  • 両方のバグについて、修正前の状態をテストで再現し(バグ②はハングそのものではなく tokio::time::timeout で「完了しないこと」を検知する形でよい)、修正後にテストが通ることを確認してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

バグ①は Arc<Semaphore> と progress_tx(mpsc::UnboundedSender)を同じ for ループの中でどちらも spawn のクロージャに move しようとしていますが、片方だけ Arc::clone されていて、もう片方はされていません。Sender は Clone を実装している型です。1周目のループで何が起きるか、2周目のループで何が起きるかを分けて考えてください。

ヒント2 — アプローチ

バグ②は「コンパイルは通るが完了しない」ため、ヒント1のような借用チェッカーのエラーメッセージは出ません。tokio::sync::mpsc::Receiver::recv() が None を返す条件を思い出してください——それは「対応する Sender とその全てのクローンがdropされたとき」です。integrate_and_wait_broken の中で Sender のクローンは何個作られていて、collect_fut(rx.recv() を回すループ)が完了しようとする時点で、そのうち何個がまだ生きているかを数えてみてください。

ヒント3 — コード骨格

バグ①の修正は、Arc<Semaphore> に対して既にやっているのと同じパターンを progress_tx にも適用するだけです。

for input in inputs {
    let semaphore = Arc::clone(&semaphore);
    let progress_tx = progress_tx.clone(); // 追加: ループのたびに複製する
    set.spawn(async move {
        // ...
    });
}
drop(progress_tx); // 追加: 関数自身が持っている元のハンドルも手放す

バグ②の修正は、「join! が完了するまで生き続けてしまう不要な Sender クローン」を作らないことです。完了サマリーをどうしても送りたいなら、collect_fut が rx.recv() で待ち始める前に送り切ってからdropするか、そもそも run_batch_with_progress の内部(全タスク終了後、集計ループの最後)で送るように設計を変え、外側で追加のクローンを保持しないようにします。

✅ 模範解答

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

use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, Semaphore};
use tokio::task::JoinSet;

async fn fetch(input: u32) -> Result {
    tokio::time::sleep(Duration::from_millis(((input % 5) * 40) as u64)).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,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum ProgressEvent {
    Completed { input: u32, output: u32 },
}

/// バグ①(moveの二重使用)とバグ②(Senderクローンの閉じ忘れ)を両方修正した版。
///
/// 修正①: progress_tx は Arc と同様、ループの各周でclone()して
///        spawnに持ち込む。所有権を持つのは各タスクの中だけになる。
/// 修正②: 関数自身が最初に受け取った progress_tx(=もう使わない元のハンドル)を
///        ループの後で明示的にdropする。こうしないと、この関数の呼び出し元が
///        「Senderが全部dropされるのを待つ」設計(後述のrun_batch_with_progressの
///        呼び出し側)を組んだ場合に、この関数自身が最後の1個を握ったままになる。
async fn run_batch_with_progress(
    inputs: Vec,
    max_concurrency: usize,
    per_task_timeout: Duration,
    total_timeout: Duration,
    progress_tx: mpsc::UnboundedSender,
) -> BatchReport {
    let semaphore = Arc::new(Semaphore::new(max_concurrency));
    let mut set: JoinSet, tokio::time::error::Elapsed>> =
        JoinSet::new();

    for input in inputs {
        let semaphore = Arc::clone(&semaphore);
        let progress_tx = progress_tx.clone(); // 修正①: 周ごとに複製する
        set.spawn(async move {
            let _permit = semaphore
                .acquire_owned()
                .await
                .expect("semaphore is never closed");
            let result = tokio::time::timeout(per_task_timeout, fetch(input)).await;
            if let Ok(Ok(output)) = &result {
                let _ = progress_tx.send(ProgressEvent::Completed { input, output: *output });
            }
            result
            // progress_tx(このタスク用のクローン)はここでdropされる
        });
    }
    drop(progress_tx); // 修正②: 関数自身が持つ元のハンドルも手放す

    let mut report = BatchReport::default();

    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 {
                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
}

/// バグ②を再現する版: 「完了サマリーを送るため」に追加でcloneした
/// `done_notifier` を join! の完了後まで保持してしまっている。
/// この`done_notifier`が生きている間、rx.recv()は絶対にNoneを返さない。
async fn integrate_and_wait_broken(inputs: Vec) -> (BatchReport, Vec) {
    let (tx, mut rx) = mpsc::unbounded_channel();
    let done_notifier = tx.clone(); // <- このクローンがjoin!の間ずっと生き続ける
    let batch_fut =
        run_batch_with_progress(inputs, 4, Duration::from_secs(2), Duration::from_secs(5), tx);
    let collect_fut = async {
        let mut events = Vec::new();
        while let Some(event) = rx.recv().await {
            events.push(event);
        }
        events
    };
    let (report, events) = tokio::join!(batch_fut, collect_fut); // <- ここで永遠に止まる
    let _ = done_notifier.send(ProgressEvent::Completed { input: 0, output: 0 });
    (report, events)
}

/// 修正版: 「完了サマリー」を追加で送りたい要求自体は取り下げず、
/// done_notifierをjoin!より前に使い切ってdropしてから待ちに入る。
/// (今回はサマリー送信機能自体をシンプルに削除し、余計なcloneを作らない設計にした)
async fn integrate_and_wait_fixed(inputs: Vec) -> (BatchReport, Vec) {
    let (tx, mut rx) = mpsc::unbounded_channel();
    let batch_fut =
        run_batch_with_progress(inputs, 4, Duration::from_secs(2), Duration::from_secs(5), tx);
    let collect_fut = async {
        let mut events = Vec::new();
        while let Some(event) = rx.recv().await {
            events.push(event);
        }
        events
    };
    tokio::join!(batch_fut, collect_fut)
    // txの元ハンドルはrun_batch_with_progress内部でdrop済み(修正②)、
    // 各タスク用クローンもタスク終了時にdrop済みなので、
    // 全タスク完了時点でSenderのクローンは0個になり、rx.recv()はNoneを返す。
}

#[tokio::main]
async fn main() {
    let (report, events) = integrate_and_wait_fixed((1..=20).collect()).await;
    println!("{report:#?}");
    println!("received {} progress events", events.len());
}

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

    #[tokio::test]
    async fn broken_version_hangs_because_extra_sender_clone_outlives_the_join() {
        // 7や11の倍数を避け、全件成功させてprogressイベントの数を単純にする
        let inputs = vec![1, 2, 3, 4, 5];
        let result = tokio::time::timeout(
            Duration::from_millis(500),
            integrate_and_wait_broken(inputs),
        )
        .await;
        assert!(
            result.is_err(),
            "done_notifierがjoin!の間生きている限り、rx.recv()はNoneを返さずハングするはず"
        );
    }

    #[tokio::test]
    async fn fixed_version_completes_and_collects_all_progress_events() {
        let inputs = vec![1, 2, 3, 4, 5];
        let result =
            tokio::time::timeout(Duration::from_secs(3), integrate_and_wait_fixed(inputs)).await;

        let (report, events) = result.expect("余計なSenderクローンがなければ必ず完了するはず");
        assert_eq!(report.succeeded.len(), 5);
        assert_eq!(events.len(), 5); // 成功した5件分だけprogressイベントが届く
    }

    #[tokio::test]
    async fn application_errors_and_panics_do_not_emit_progress_events() {
        // 7の倍数はErr、11の倍数はpanicなのでprogressイベントは送られない
        let inputs = vec![7, 11, 14];
        let (report, events) = integrate_and_wait_fixed(inputs).await;

        assert_eq!(report.succeeded.len(), 0);
        assert_eq!(report.application_errors, 2); // 7, 14
        assert_eq!(report.panicked, 1); // 11
        assert_eq!(events.len(), 0); // 誰も成功していないのでイベントは0件
    }
}
🧪
cargo test でこの3件のテストが実際にokになることを確認済みです。broken_version_hangs_because_extra_sender_clone_outlives_the_joinはtokio::time::timeout(500ms, ...)がErr(Elapsed)を返すこと(=バグ再現)を確認し、fixed_version_completes_and_collects_all_progress_eventsは同じ入力に対して修正版が3秒以内に完了し、5件全てのprogressイベントが届くことを確認しています。

🪜 Step-by-Step 解説

1
バグ①はなぜ「1周目は動くが2周目で壊れる」タイプのエラーなのか
for input in inputs { set.spawn(async move { ... progress_tx.send(...) ... }); } において、progress_tx に .clone() を挟まずそのまま async move に持ち込むと、Rustのムーブセマンティクスは「1周目のループで progress_tx の所有権がそのクロージャにムーブされる」と解釈します。ループの2周目に到達した時点で、progress_tx という変数はすでに1周目でムーブ済み(=もう存在しない)ため、「ムーブ済みの値を使おうとしている」というエラーになります。これは Day 042 で見た「借用がスコープを逃げようとする」E0521とは別種の、E0382(use of moved value)です。
2
Arc::clone と .clone()(Sender)は同じ操作の異なる型への適用
Arc<Semaphore> に対して Arc::clone(&semaphore) を呼ぶのも、mpsc::UnboundedSender に対して progress_tx.clone() を呼ぶのも、「所有権を複製して、ループの各タスクに1つずつ配る」という同じ設計パターンです。今回のバグは、この2つの型のうち片方だけこのパターンを適用し忘れたことが原因でした。
3
バグ②は「コンパイラが検出できない所有権のバグ」
バグ①はコンパイルエラーとして即座に発覚しますが、バグ②は型としては完全に正しいコードです。done_notifierは正当にtx.clone()されており、SenderはSend + 'staticなのでasyncブロックをまたいで保持しても何の問題もありません。しかし「Senderの全クローンがdropされるまでrecv()はNoneを返さない」というのは実行時のプロトコルであり、型システムはこれを強制してくれません。
4
done_notifierが握られている期間を正確に数える
Senderのクローンは①tx(修正②で全タスクspawn後すぐdrop)、②各タスクのprogress_tx.clone()(タスク終了時にdrop)、③done_notifier(tokio::join!完了後までスタックに残る)の3種類。collect_futがNoneを受け取るには①②③全てのdropが必要ですが、③がdropされるのはjoin!の後の行。collect_futは③が生きている限りNoneを受け取れず、join!は永遠に完了しません。

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

tokio::sync::mpsc が「明示的なclose()」を持たず、代わりに「全Senderのdrop」でチャンネルを閉じる設計にしているのは、Rustの所有権システム全体に一貫する思想——「リソースの生死をスコープと所有権で表現し、専用の解放APIを人間が呼び忘れる余地をなくす」——の延長です。Day 042のSemaphore permitがDropで自動解放されたのと同じ発想が、ここでは「チャンネルのクローズ」という別のリソース管理に適用されています。

ただし今回のバグが示す通り、この設計は「クローンをどこかに残さない」という責任を完全には肩代わりしてくれません。ArcやSenderを安易に複製して長く生かしてしまうコードは、コンパイラのチェックをすり抜けたまま「誰も待っていない・何も終わらない」不具合を生みます。所有権システムは「解放し忘れ」を防ぐ一方で、「複製しすぎ・持ちすぎ」は依然として設計者の責任である、という限界を理解しておく必要があります。

⚠️
この種のバグを避ける実務上のコツは「Senderを保持する変数の生存期間を、必要最小限のスコープに閉じ込める」ことです。「後で使うかもしれないから」という理由でSenderのクローンを長生きする変数に代入するのは、RustではArcやRcを安易に共有し続けるのと同種のアンチパターンとして扱うべきです。

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

バグ①(progress_tx を .clone() せずにループで使い回す)を実際にビルドすると、以下のようなエラーになります。

// アンチパターン: progress_txをcloneせずループで使い回そうとする
for input in inputs {
    let semaphore = Arc::clone(&semaphore);
    set.spawn(async move {
        let _permit = semaphore.acquire_owned().await.unwrap();
        let result = tokio::time::timeout(per_task_timeout, fetch(input)).await;
        if let Ok(Ok(output)) = &result {
            let _ = progress_tx.send(ProgressEvent::Completed { input, output: *output }); // ここでエラー
        }
        result
    });
}
error[E0382]: use of moved value: `progress_tx`
  --> src/main.rs:NN:13
  |
N-3 |  for input in inputs {
  |  -------------------- inside this loop
...
N   |     set.spawn(async move {
  |            --------------- value moved into closure here, in previous iteration of loop
N+3 |        let _ = progress_tx.send(...);
  |                ^^^^^^^^^^^ value used here after move
⚠️
読み方: 「value moved into closure here, in previous iteration of loop」がこのエラーの核心です。Rustのコンパイラは「forループは1回のスコープではなく、本体が複数回実行される」ことを理解しており、1周目で`move`された変数は、2周目には存在しないと正しく指摘しています。修正はArc::clone(&semaphore)と全く同じ形で、ループの中でprogress_tx.clone()して新しい変数に束縛してから`move`することです。「1つのリソースをN個のタスクに配る」場面では、そのリソースの型が何であれ(Arc・Sender・将来登場する他のハンドル型でも)、この「ループ内でcloneしてから配る」という書き方がRustの基本パターンになります。

🌐 他言語との比較

観点Go(chan)Java(BlockingQueue)Rust(tokio::sync::mpsc)
チャンネルを閉じる操作明示的なclose(ch)を誰かが呼ぶ標準では存在しない(毒薬メッセージ等で代用)明示的なclose APIはない。全Senderのdropで自動的にクローズ扱いになる
複数の送信者がいる場合の注意誰か1人がclose()すると、他の送信者が送信しようとしてpanicする毒薬メッセージを送る責任者を1つに決める必要がある各Senderクローンは独立してdropしてよい。最後の1つがdropされた時点で受信側にNoneが返る
閉じ忘れの典型パターンcloseを呼ぶ責任者を決め忘れて誰も呼ばない毒薬メッセージの送信を実装し忘れるどこかの変数(今回のdone_notifier)がSenderのクローンを握ったまま、想定より長く生存する
受信側の終了検知v, ok := <-ch; !okで明示的に判定毒薬メッセージを受信して自前で判定while let Some(v) = rx.recv().awaitが自動的にNoneで終了する

Go・Javaは「誰が・いつクローズ(相当の合図)を送るか」を人間が設計・実装する必要があるのに対し、Rustは「所有権の生死」という言語機構がクローズ条件を代行します。ただし今回のバグが示す通り、代行してくれるのはあくまで「全クローンがdropされた」という事実の検知だけであり、「クローンをどこにも余分に残さない」設計自体は依然として書き手の責任です。

🏆 実務での使いどころ

  • CLIツールの進捗バー: 長時間実行するバッチ処理の各タスク完了をmpscチャンネル経由でメインスレッドに送り、indicatif等のプログレスバーを更新する
  • WebSocketでのリアルタイム進捗配信: サーバーサイドでバッチ処理を実行しつつ、SenderをWebSocketのブロードキャストタスクに渡してクライアントへ配信する
  • ジョブキューのライフサイクル管理: ワーカープールでジョブを処理し、全ワーカーが終了(=全Senderがdrop)したことを検知してリソースを解放する

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
Arcはcloneが必要だがSenderは共有できると思い込むどちらも「複数箇所から使いたい」という要求は同じなのに、Arcだけ特別扱いしてしまう直感SenderもCloneを実装した型であり、複数のタスクに配るにはArcと全く同じ「ループ内でcloneしてmove」というパターンが必要
コンパイルが通れば並行処理のバグは出ないと思う型システムがメモリ安全性を保証してくれることへの過信型システムは「データ競合」や「ムーブ後の使用」は防ぐが、「Senderのクローンを必要以上に長く保持してrecv()が終わらない」といったプロトコルレベルの不具合までは防げない
Senderのクローンを1つ多く持っておいても「使わなければ影響ない」と思うクローンを作るだけでは何も送信していないので無害に見えるrecv()の終了条件は「送信するかどうか」ではなく「生きているクローンの数」で決まる。使っていないクローンでも、生きているだけでチャンネルは閉じない
ハングするバグは再現・テストが難しいと思い込む「ハング」という言葉から、テストでは検出不能な問題だと考えてしまうtokio::time::timeoutで外側から時間制限をかければ、「規定時間内に完了しない」という形で自動テストとして再現・検証できる

🚀 次のステップ

  • 発展: run_batch_with_progressに「進捗イベントの送信先が受信を止めてしまった(Receiverがdropされた)場合、send()がErrを返す」ケースへの対処を追加する(ログだけ出して処理を継続するか、バッチ自体を打ち切るかの設計判断)
  • 次回予告: async trait(async-trait / RPITIT) — 非同期メソッドを持つトレイトの設計

🎯 自己評価

自分の回答

気づき・メモ