📚 背景知識(読んでから問題へ)
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 解説
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)です。
Arc::clone と .clone()(Sender)は同じ操作の異なる型への適用Arc<Semaphore> に対して Arc::clone(&semaphore) を呼ぶのも、mpsc::UnboundedSender に対して progress_tx.clone() を呼ぶのも、「所有権を複製して、ループの各タスクに1つずつ配る」という同じ設計パターンです。今回のバグは、この2つの型のうち片方だけこのパターンを適用し忘れたことが原因でした。
バグ①はコンパイルエラーとして即座に発覚しますが、バグ②は型としては完全に正しいコードです。
done_notifierは正当にtx.clone()されており、SenderはSend + 'staticなのでasyncブロックをまたいで保持しても何の問題もありません。しかし「Senderの全クローンがdropされるまでrecv()はNoneを返さない」というのは実行時のプロトコルであり、型システムはこれを強制してくれません。
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を安易に複製して長く生かしてしまうコードは、コンパイラのチェックをすり抜けたまま「誰も待っていない・何も終わらない」不具合を生みます。所有権システムは「解放し忘れ」を防ぐ一方で、「複製しすぎ・持ちすぎ」は依然として設計者の責任である、という限界を理解しておく必要があります。
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
});
}
--> 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) — 非同期メソッドを持つトレイトの設計