📚 背景知識(読んでから問題へ)
Day 042 で Semaphore による最大並行度の制御を、Day 043 で mpsc による進捗通知を実装しました。今日はこのテーマの最後のピース——外部からのシャットダウン信号でバッチ処理を安全に打ち切る——に取り組みます。
本番運用では「全タスクが終わるまで待つ」だけでは不十分な場面があります。Kubernetes は Pod を止める前に SIGTERM を送り、猶予期間内にプロセスが自発的に終了することを期待します。管理画面の「バッチを今すぐ中断」ボタンも同じ要求です。total_timeout による強制打ち切り(Day 041・042)は「時間切れ」を検知する仕組みでしたが、「誰かが明示的に止めろと言った」という外部イベントを検知する仕組みはまだありません。
これを実現する自然な道具が tokio::sync::watch です。watch チャンネルは「最新の1つの値を、複数の受信者にブロードキャストする」ためのチャンネルで、「シャットダウン中かどうか(bool)」のような『現在の状態』を表現するのに向いています(mpsc が『次々に届く個別のメッセージの列』に向いているのとは設計思想が異なります)。Receiver::changed() は「値が(前回観測してから)変わるまで待つ」Future を返し、これと set.join_next()(次のタスク完了を待つFuture)を tokio::select! で競争させることで、「次のタスクが終わるのを待ちながら、同時にシャットダウン信号にも耳を傾ける」というループが書けます。
tokio::select! の基本形は「複数の Future を同時に .await し、最初に完了したものの結果で分岐する」というものです。Day 040で学んだ「キャンセル安全性」(=選ばれなかった側の Future がその場で drop される)はここでも重要な前提になります。今日はこれに加えて、select! が実務で踏みやすいもう1つの罠——片方の分岐が「二度と完了しない」ことが判明した後も律儀に監視し続けてしまう問題——を扱います。📝 問題
Day 042 の run_batch_bounded を土台に、外部からのシャットダウン信号を受け取って早期に打ち切れる run_batch_cancellable を実装してください。
- シグネチャ:
async fn run_batch_cancellable(inputs: Vec<u32>, max_concurrency: usize, per_task_timeout: Duration, total_timeout: Duration, shutdown: tokio::sync::watch::Receiver<bool>) -> BatchReport fetch関数は Day 042 と同一のものを使用してくださいBatchReportにaborted_by_shutdown: usizeフィールドを追加し、total_timeoutによる打ち切り(aborted_by_total_timeout)と区別して集計してください- 集計ループは
tokio::select!で「次のタスク完了」と「シャットダウン信号の変化」を競争させる形にしてください。シャットダウン信号(*shutdown.borrow() == true)を検知したらset.abort_all()で残りのタスクを打ち切り、aborted_by_shutdownに計上してください - 罠を踏まないこと:
watchチャンネルの送信側(Sender)が全てdropされた場合、Receiver::changed()は以後ずっと即座にErrを返すようになります。これを何も考えずにselect!のループに書くと、送信側が二度とtrueを送ってこないと分かった後も、その分岐が「即座に完了するFuture」として毎回選ばれ続け、もう一方のset.join_next()の分岐が実行される機会を奪う(=タスクの完了に気づけずバッチが進まなくなる)危険があります。この罠を回避する実装にしてください - 以下の3つをテストで検証してください
- シャットダウン信号が一度も送られない場合、通常通り全タスクが完了すること
- シャットダウン信号が処理の途中で送られた場合、早期に打ち切られ
aborted_by_shutdownが計上されること - シャットダウン信号の送信側が「一度も送らずにdrop」された場合でも、上記の罠でハングせず正常に完了すること
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)
}
🔍 ヒント(段階的開示)
ヒント1 — 方向性
Semaphore や Sender はタスクごとに複製して spawn の中へ持ち込みましたが、今回の shutdown: watch::Receiver<bool> は集計ループの中でただ1つだけ使います(各タスクに配る必要はありません)。まず tokio::select! { ... } に2本の腕(shutdown.changed() の腕と set.join_next() の腕)を並べて、ループで回すところから組み立ててください。Receiver::changed() は &mut self を要求する点に注意してください。
ヒント2 — アプローチ
select! の各腕には <パターン> = <Future> [, if <条件式>] => <処理> という書き方があります。if <条件式> は「その腕を今回の選択に参加させるかどうか」を制御する前置条件で、条件が false ならその Future は生成・ポーリングすらされません。shutdown.changed() が Err を返した(=送信側が全て消えた=二度と true は来ない)ら、以後その腕を無効化するフラグを立て、if 条件でガードしてください。こうすれば select! は自動的に「join_next() だけを待つループ」に縮退します。
ヒント3 — コード骨格
let mut shutdown_active = true;
let mut shutdown_triggered = false;
loop {
tokio::select! {
biased;
changed = shutdown.changed(), if shutdown_active => {
match changed {
Ok(()) if *shutdown.borrow() => {
shutdown_triggered = true;
break;
}
Ok(()) => {} // false→false等、無関係な変化なので無視して継続
Err(_) => {
shutdown_active = false; // 送信側消滅: この腕はもう待たない
}
}
}
maybe_result = set.join_next() => {
match maybe_result {
Some(join_result) => { /* Day 042と同じ集計処理 */ }
None => break, // 全タスク完了
}
}
}
}
✅ 模範解答
// Cargo.toml に以下を追加して検証すること:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{watch, 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,
aborted_by_shutdown: usize,
}
/// `Semaphore`で並行度を制限しつつ、`watch`チャンネル経由の外部シャットダウン
/// 信号と`total_timeout`のどちらでも早期打ち切りができるバッチ処理。
///
/// 設計判断: `shutdown_active`フラグで「送信側が消滅した後のshutdown.changed()腕」
/// をselect!の前置条件(if)で無効化する。無効化しないと、送信側drop後は
/// changed()が毎回即座にErrを返す「常に準備完了のFuture」になり、biased select!
/// が他の腕(join_next())を一切ポーリングしなくなるビジーループに陥る。
async fn run_batch_cancellable(
inputs: Vec,
max_concurrency: usize,
per_task_timeout: Duration,
total_timeout: Duration,
mut shutdown: watch::Receiver,
) -> 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");
tokio::time::timeout(per_task_timeout, fetch(input)).await
});
}
let mut report = BatchReport::default();
let mut shutdown_triggered = false;
let mut shutdown_active = true;
let timed_out_overall = tokio::time::timeout(total_timeout, async {
loop {
tokio::select! {
biased;
changed = shutdown.changed(), if shutdown_active => {
match changed {
Ok(()) if *shutdown.borrow() => {
shutdown_triggered = true;
break;
}
Ok(()) => {
// false→falseのような無関係な変化。監視を継続する。
}
Err(_) => {
// 送信側(Sender)が全てdropされた = 今後二度とtrueは
// 送られてこない。この腕を無効化しないと、biased
// selectは毎回ここをErrで即座に選び続け、
// join_next()の腕が永遠にポーリングされなくなる。
shutdown_active = false;
}
}
}
maybe_result = set.join_next() => {
match maybe_result {
Some(join_result) => 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) => {}
},
None => break, // 全タスク完了
}
}
}
}
})
.await
.is_err();
if timed_out_overall || shutdown_triggered {
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) => {
if shutdown_triggered {
report.aborted_by_shutdown += 1;
} else {
report.aborted_by_total_timeout += 1;
}
}
}
}
}
report
}
#[tokio::main]
async fn main() {
let (_tx, rx) = watch::channel(false);
let report = run_batch_cancellable(
(1..=20).collect(),
4,
Duration::from_secs(2),
Duration::from_secs(5),
rx,
)
.await;
println!("{report:#?}");
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
#[tokio::test]
async fn completes_normally_when_shutdown_never_fires() {
// 送信側を握ったまま一度もtrueを送らない = shutdown.changed()は
// ずっとPendingのまま、join_next()側だけで正常に完了するはず。
let (_tx, rx) = watch::channel(false);
let inputs = vec![1, 2, 3, 7, 11, 14];
let report =
run_batch_cancellable(inputs, 3, Duration::from_secs(2), Duration::from_secs(5), rx)
.await;
assert_eq!(report.succeeded.len(), 3); // 1, 2, 3
assert_eq!(report.application_errors, 2); // 7, 14
assert_eq!(report.panicked, 1); // 11
assert_eq!(report.aborted_by_shutdown, 0);
assert_eq!(report.aborted_by_total_timeout, 0);
}
#[tokio::test]
async fn shutdown_signal_triggers_early_abort() {
// 全て160ms sleepする10件(7・11の倍数は避けて時間測定のノイズを消す)。
// 同時実行数2なので、無制限なら800ms前後で全件完了する。
let inputs: Vec = vec![4, 9, 19, 24, 29, 34, 39, 54, 59, 64];
let (tx, rx) = watch::channel(false);
let batch_fut =
run_batch_cancellable(inputs, 2, Duration::from_secs(5), Duration::from_secs(5), rx);
let trigger_fut = async {
tokio::time::sleep(Duration::from_millis(100)).await;
let _ = tx.send(true);
};
let start = Instant::now();
let (report, ()) = tokio::join!(batch_fut, trigger_fut);
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_millis(600),
"shutdown信号後も打ち切られず走り続けている可能性: elapsed = {elapsed:?}"
);
assert!(
report.aborted_by_shutdown > 0,
"shutdown信号によるabortが計上されていない"
);
assert_eq!(report.aborted_by_total_timeout, 0);
}
#[tokio::test]
async fn shutdown_sender_dropped_without_signal_does_not_hang() {
// 送信側を一度もtrueを送らないままdropする。shutdown_activeガードが
// なければ、changed()が毎回即座にErrを返し続けるビジーループに陥り、
// join_next()の腕が進まず外側のtimeout(2秒)がErrになるはず。
let (tx, rx) = watch::channel(false);
drop(tx);
let inputs = vec![1, 2, 3];
let result = tokio::time::timeout(
Duration::from_secs(2),
run_batch_cancellable(inputs, 2, Duration::from_secs(5), Duration::from_secs(5), rx),
)
.await;
let report =
result.expect("shutdown_activeガードがないとここでハング/タイムアウトするはず");
assert_eq!(report.succeeded.len(), 3);
}
}
cargo test でこの3件のテストが実際にokになることを確認済みです。shutdown_signal_triggers_early_abortは無制限なら800ms前後かかる処理がshutdown信号(100ms後に送信)によって600ms未満で打ち切られaborted_by_shutdownが計上されることを確認し、shutdown_sender_dropped_without_signal_does_not_hangは送信側drop後もshutdown_activeガードのおかげで2秒のタイムアウト内に正常完了することを確認しています。🪜 Step-by-Step 解説
watch::Receiver::changed()は「前回観測した値との差分」を待つwatch::channel(false)で作った直後のReceiverにとって、初期値falseは「まだ観測していない値」として扱われますが、これはchanged()が即座に完了する対象にはなりません。changed()が完了するのは、Sender::send()によって値が実際に更新された(かつ、その更新をまだこのReceiverが「見ていない」)ときです。この性質があるからこそ、送信側が黙っている限りその腕は永遠にPendingのままで、join_next()側の進行を妨げません。
select!の前置条件ifは「腕そのものを無効化する」スイッチselect! { arm, if cond => ... }のif condは、条件がfalseならそもそもそのFutureを生成・ポーリングしません。今回shutdown_activeをfalseにすると、以後のループの反復ではshutdown.changed()というFuture自体が作られなくなり、select!は実質的にset.join_next()だけを待つ形に縮退します。
watch::Senderが全てdropされた後、Receiver::changed()は「もう更新は来ない」ことを表すErrを即座に返すようになります。ifガードなしでselect!に置いたままにすると、ループの毎回の反復で「即座にReady(Err)」→「biasedにより最初に評価されるこの腕が毎回選ばれる」→「join_next()の腕は一度もポーリングされない」という状態に陥ります。「もう起こらないと分かったイベントを待ち続けない」という判断をコードで表現する必要があります。
shutdown_triggeredとtimed_out_overallを分けて集計するどちらも最終的に
set.abort_all()を呼ぶ点は同じですが、呼び出し側にとっての意味が異なります。前者は「処理が遅すぎた」という異常系のシグナル、後者は「運用者が意図的に止めた」という正常系のシグナルです。分けて集計しておくことで、呼び出し側はログやアラートの扱いを変えられます。
💡 設計思想・なぜこう書くのか
tokio::select!に複数の腕を並べるだけでは、「今後絶対に起こらないイベント」を区別できません。Rustの型システムはResultで成功/失敗を表現させますが、「このFutureはもう意味のある結果を返さない」という時間的な性質までは型では表現しきれず、ifガードで明示的にプログラマが管理する必要があります。これは所有権システムがメモリ安全性を静的に保証してくれるのとは対照的に、並行処理のプロトコル設計は依然として書き手の責任範囲であることを改めて示す例です(Day 043で見た「Senderのクローンを持ちすぎる」問題と同じ種類の教訓です)。
watchチャンネルは「送信側が1つも残っていない状態」をchanged()のErrという形で通知しますが、これは一度きりの通知ではなく、以後ずっとErrを返し続けるという性質を持ちます。「Errが1回返ってきたら終わり」ではなく「Errが返ってきたら、その事実を自分で覚えておいて、二度とこの腕を待たないようにする」という一手間が、今回の実装の核心です。🛑 コンパイルエラーが出た場合
shutdown: watch::Receiver<bool>を関数引数で受け取る際にmutを付け忘れると、以下のようなエラーになります。
// アンチパターン: mutを付け忘れる
async fn run_batch_cancellable(
inputs: Vec,
max_concurrency: usize,
per_task_timeout: Duration,
total_timeout: Duration,
shutdown: watch::Receiver, // mut shutdown ではない
) -> BatchReport {
// ...
tokio::select! {
changed = shutdown.changed() => { /* ... */ } // ここでエラー
// ...
}
}
--> src/main.rs:NN:19
|
N-2 | shutdown: watch::Receiver<bool>,
| -------- help: consider changing this to be mutable: `mut shutdown`
...
N | changed = shutdown.changed() => { /* ... */ }
| ^^^^^^^^ cannot borrow as mutable
watch::Receiver::changed(&mut self)は「前回観測した値」を内部状態として更新するため、&mut selfを要求します。これはmpsc::Receiver::recv(&mut self)と同じ理由(自分が読み取った位置を内部で覚えておく必要がある)です。関数の引数として受け取る場合、Rustではlet mut x = ...と同様に引数パターンにもmutを明示する必要があり、これを忘れると「宣言時にmutを付けていない変数を可変借用しようとした」というエラーになります。コンパイラのhelp行がそのまま修正方法(mut shutdown)を示してくれる、比較的読みやすいエラーです。🌐 他言語との比較
| 観点 | Go(context.Context + select) | Java(ExecutorService.shutdown() + Future.isCancelled()) | Rust(tokio::sync::watch + select!) |
|---|---|---|---|
| シャットダウン信号の表現 | ctx.Done()が返す<-chan struct{}(クローズされたら発火) | 共有されるvolatile booleanフラグ、またはFutureのキャンセル状態 | watch::Receiver<bool>(最新の状態を保持するチャンネル) |
| 「今後二度と来ない」の扱い | ctx.Done()は一度クローズされたら永久に即座に読める状態になる | フラグの読み取りは常に軽量なので偏りの問題自体が起きにくい | changed()がErrを返し続ける状態を自分で覚えておき、select!の腕を無効化する必要がある |
| 複数の待受箇所への伝播 | context.Contextはツリー状に子へ伝播する設計が標準 | Futureごとに個別のキャンセル状態を持つ | watch::Receiverをclone()すれば同じ最新値を複数箇所で観測できる |
| ビジーループのリスク | 各腕は基本的に「そのラウンドで一度だけ」評価されるため、Rustほど明示的なガードは要求されない | ポーリングベースでフラグを見る設計だと同種の問題が起こりうる | select!の前置条件(if)で明示的にガードしないと、biased指定時に顕著な形で問題が露呈する |
Go の context.Done() も「クローズされたチャンネルは即座に読める」という似た性質を持ちますが、Goのselectは「一度クローズされたらそのままDone()分岐を選び続けて終了処理へ向かう」という設計を前提にしている場面がほとんどで、今回のような「無効化しないとハング側の分岐が飢餓する」問題は起きにくい書き方が主流です。Rustではselect!をループで回し続ける設計自体を多用するため、「もう起きないイベントを待ち続けない」という一手間が顕在化しやすいと言えます。
🏆 実務での使いどころ
- Kubernetes Podのgraceful shutdown:
SIGTERMハンドラでwatch::Sender::send(true)し、進行中のバッチ処理に「猶予期間内に安全に終われ」と伝える - 管理画面からの処理中断: 「このジョブを中断」ボタンのAPIコールを
watchチャンネルの送信に変換し、実行中のワーカーに伝播させる - 段階的な機能フラグの反映:
watchは「シャットダウンbool」に限らず「現在の設定値」全般のブロードキャストに使える(例: レート制限の上限値をホットリロードする)
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
select!の腕は「順番に」試される(1つ目がPendingなら2つ目を試す)と思い込む | if/else ifのような制御構文の見た目に引っ張られる | select!は基本的に全ての腕を並行にポーリングし、最初にReadyになったものを選ぶ。biasedは「複数が同時にReadyだった場合にどちらを優先するか」の順序であり、Pendingかどうかの判定順ではない |
Senderがdropされたらchanged()は1回だけErrを返して、その後は永久にPendingになると思う | mpsc::Receiver::recv()が全Sender drop後にNoneを返し続ける(終端を示す)挙動と混同する | watch::Receiver::changed()はSender消滅後、呼ぶたびに毎回即座にErrを返す。呼び続けてもPendingには決してならないため、無視して待ち続けるコードはビジーループになる |
ifガード付きのselect!腕は「一度falseにしたら二度と有効化できない」設計だと思う | 今回の実装でしか使わなかったため一方向にしか見えない | ifの条件式は毎回のループの反復で再評価されるただのbool式であり、後からshutdown_active = trueに戻せば同じ腕を再度有効化できる(今回はその必要がなかっただけ) |
watchとmpscはどちらも「チャンネル」なので用途に大差はないと思う | 名前も使い方(send/recv系メソッド)も似ている | mpscは「送られた全メッセージを1つずつ順番に消費する」設計、watchは「今の1つの値だけを常に参照する(古い値は上書きされ失われる)」設計。シャットダウンフラグや設定値のような「今どうなっているか」を表すにはwatchが自然 |
🚀 次のステップ
- 発展:
shutdownが発火した際、即座にabort_all()するのではなく「猶予期間(grace period)」を設け、その間は新規タスクのspawnを止めつつ実行中のタスクの自然な完了を待ち、猶予期間を過ぎたタスクだけabortする設計に変更する - 次回予告: async trait(async-trait / RPITIT) — 非同期メソッドを持つトレイトの設計