Day 042 — Semaphore × JoinSetで最大並行度を制御する。「動的な数のタスク」と「同時に何本まで」は別の設計軸

2026-09-08 🟣 上級者 / Phase 3 設計・アーキテクチャ tokioタスク・並行実行パターン

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

Day 041 では tokio::task::JoinSet を使い、「動的な数のタスクを束ね、完了順に集計し、全体のタイムアウトで打ち切る」バッチ処理 run_batch_correct を実装しました。しかしこの実装には本番投入前に必ず突き当たる制約が1つ抜けています。外部APIやDBには同時接続数の上限がある、という現実です。

inputs.len() が10万件あるバッチを run_batch_correct にそのまま渡すと、JoinSet は10万個のタスクを一斉に spawn し、10万本の fetch がほぼ同時に外部APIへリクエストを送ります。相手のレート制限に弾かれるか、あるいは自分のプロセスのソケット数・メモリを圧迫するかのどちらかです。「動的な数のタスクを扱える」ことと「同時に何本まで実行してよいか」は別の問題であり、後者を設計せずに前者だけ実装すると本番で事故ります。

この「同時実行数の上限(=最大並行度)を設ける」という要求に対して、Rust/tokioのエコシステムには複数のアプローチがあります。

  1. 入力をチャンクに分割し、チャンクごとにJoinSetで処理して順番に待つ — 実装は単純だが、1チャンク内で最も遅いタスクが次のチャンクの開始をブロックする(並行度の恩恵が段階的にしか出ない)
  2. tokio::sync::Semaphoreでタスク本体の実行区間を許可制にする — spawn自体は全タスク分すぐに行うが、各タスクの中身はSemaphoreからpermit(許可証)を取得できるまで待たされる。JoinSetの「完了順集計」「abort_all()による打ち切り」といったDay 041で学んだ性質はそのまま使える
  3. futures::stream::StreamExt::buffer_unordered(N)を使う — ストリームコンビネータとして簡潔に書けるが、JoinSet固有のabort_all()やタスク単位のJoinError(panic検知)とは別の抽象なので、Day 041の設計と混ぜるには変換コストがかかる

今日はこのうち2番目、SemaphoreをJoinSetと組み合わせて最大並行度を制御する設計を実装します。「タスクの数は動的」「タスクの同時実行数には上限がある」「全体にも個別にもタイムアウトがある」という3つの制約を同時に満たす設計判断がテーマです。

📌
これはGoでいう「chan struct{}をトークンバケツ代わりに使ったworker pool」やJavaのSemaphore + ExecutorServiceと同じ発想です。Rustで違うのは、許可証(permit)の解放が構造体のDropに紐づくことです。SemaphorePermitはスコープを抜ける(≒タスクが終わる)と自動的に解放され、明示的なrelease()呼び出しを書き忘れる余地がありません。

📝 問題

Day 041 の run_batch_correct を拡張し、最大並行度を指定できる run_batch_bounded を設計・実装してください。

  • シグネチャ: async fn run_batch_bounded(inputs: Vec<u32>, max_concurrency: usize, per_task_timeout: Duration, total_timeout: Duration) -> BatchReport
  • fetch 関数・BatchReport 構造体は Day 041 と同一のものを使用する(下記に再掲)
  • 設計判断として: Semaphore の permit をどこで取得するか(spawn する前のループの中か、spawn された非同期ブロックの中か)を決め、その理由を説明できるようにしてください。この選択次第で「spawn 自体が max_concurrency でブロックされるか」「JoinSet に登録されるタスク数がいつ確定するか」が変わります
  • 実際に同時実行数が max_concurrency を超えないことを、テストで(内部状態を覗くのではなく)観測可能な形で検証してください
  • total_timeout を超えた場合、Semaphore の permit 待ちで止まっているタスクも含めて abort_all() で打ち切れることを確認してください
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,
}

🔍 ヒント(段階的開示)

ヒント1 — 方向性

Semaphore から permit を取得する acquire() 系メソッドには、&self を借用する版(acquire())と、Arc<Semaphore> の所有権を内部で共有する版(acquire_owned())の2種類があります。spawn されるタスクは 'static でなければならない(親のスタックフレームより長生きする可能性がある)ため、「spawn された非同期ブロックの中で permit を取得して、そのブロックの中で保持し続ける」という設計にするなら、どちらを使うべきかを考えてください。

ヒント2 — アプローチ

spawn する前のループの中で semaphore.acquire().await してから set.spawn(...) に permit を持ち込もうとすると、その permit は「ループのスコープにある Semaphore の借用」に紐づいたライフタイムを持ちます。これを 'static を要求する spawn のクロージャに移動しようとすると、コンパイラに拒否されます(詳細は下記「コンパイルエラーが出た場合」)。

そこで設計を反転させます。spawn 自体は全タスク分すぐに行い(JoinSet へのタスク登録はDay 041と同じ形を保つ)、各タスクの非同期ブロックの中で真っ先に permit を取得してから fetch を呼ぶという形にします。こうすると max_concurrency 本を超えるタスクは「spawn はされているがまだ fetch を開始していない(acquire 待ち)」状態でtokioランタイム上に留まります。Semaphore は Arc::clone して各タスクに渡します。

ヒント3 — コード骨格
async fn run_batch_bounded(
    inputs: Vec,
    max_concurrency: usize,
    per_task_timeout: Duration,
    total_timeout: Duration,
) -> 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
            // _permit はここでdropされ、Semaphoreに1つ返却される
        });
    }

    // 以降の集計ループ・total_timeout・abort_all() はDay 041のrun_batch_correctと同じ形
    // ...
}

同時実行数を観測するテストは、「max_concurrency を超えて並行実行されていればもっと速く終わるはずの処理を、あえて max_concurrency で頭打ちにさせ、所要時間の下限を検証する」というブラックボックスな方法が実装を汚さずに済みます(fetch の中身にカウンタを仕込む必要がありません)。

✅ 模範解答

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

use std::sync::Arc;
use std::time::Duration;
use tokio::sync::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,
}

/// 最大並行度を`max_concurrency`に制限しつつ、Day 041の`run_batch_correct`と
/// 同じ完了順集計・per_task_timeout・total_timeoutによる打ち切りを行う。
///
/// 設計判断: Semaphoreのpermitは「spawnする前のループ」ではなく「spawnされた
/// 非同期ブロックの内側」で取得する。理由は2つ:
///   1. spawn自体をブロックしないため、JoinSetには常に全inputs分のタスクが
///      即座に登録される(Day 041の「動的な数のタスクを束ねる」性質を壊さない)
///   2. permitのライフタイムがspawnされたタスク('static)の内部に閉じるため、
///      acquire_owned()でSemaphoreの所有権を共有すれば借用の問題が起きない
async fn run_batch_bounded(
    inputs: Vec,
    max_concurrency: usize,
    per_task_timeout: Duration,
    total_timeout: Duration,
) -> 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 {
            // acquire_owned(): Arcの所有権を共有するため、この
            // 非同期ブロックが'staticであることと矛盾しない。
            // permitを保持したままfetchの.awaitをまたぐのは合法(tokioの
            // MutexGuardと違い、Semaphoreは.awaitをまたいで保持する使い方を
            // 前提に設計されている)。
            let _permit = semaphore
                .acquire_owned()
                .await
                .expect("semaphore is never closed");
            tokio::time::timeout(per_task_timeout, fetch(input)).await
            // _permitはここでdropされ、Semaphoreにpermitが1つ返却される。
            // 次にacquire_owned()で待っているタスクがあれば起床する。
        });
    }

    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 {
        // abort_all()は「permit待ちでまだfetchを開始していないタスク」も
        // 「fetch実行中のタスク」もまとめてキャンセル要求する。
        // permit待ち中のタスクはpermitを一度も取得していないのでdropしても
        // 何も返却する必要がなく、リークは起きない。
        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
}

#[tokio::main]
async fn main() {
    let report = run_batch_bounded(
        (1..=50).collect(),
        10, // 同時実行数を10本に制限
        Duration::from_millis(500),
        Duration::from_secs(5),
    )
    .await;
    println!("{report:#?}");
}

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

    #[tokio::test]
    async fn correctness_matches_unbounded_behavior() {
        // max_concurrencyを十分大きく取れば、Day 041のrun_batch_correctと
        // 分類結果は一致するはず(7の倍数=Err, 11の倍数=panic, それ以外=成功)
        let report = run_batch_bounded(
            vec![1, 2, 7, 11, 14],
            100,
            Duration::from_secs(1),
            Duration::from_secs(5),
        )
        .await;

        assert_eq!(report.succeeded.len(), 2); // 1, 2
        assert!(report.succeeded.contains(&1));
        assert!(report.succeeded.contains(&4));
        assert_eq!(report.application_errors, 2); // 7, 14
        assert_eq!(report.panicked, 1); // 11
        assert_eq!(report.timed_out, 0);
        assert_eq!(report.aborted_by_total_timeout, 0);
    }

    #[tokio::test]
    async fn max_concurrency_caps_parallel_execution() {
        // 10件すべてが160ms sleepするタスク(input % 5 == 4)。
        // 7や11の倍数を意図的に避け、全件成功させて時間測定のノイズを消す。
        let inputs: Vec = vec![4, 9, 19, 24, 29, 34, 39, 54, 59, 64];

        let start = Instant::now();
        let report = run_batch_bounded(
            inputs,
            2, // 同時実行数を2本に制限
            Duration::from_secs(5),
            Duration::from_secs(5),
        )
        .await;
        let elapsed = start.elapsed();

        assert_eq!(report.succeeded.len(), 10);
        // 10タスク ÷ 同時実行数2 = 5ラウンド × 160ms = 800msが理論下限。
        // 無制限に並行実行されていれば160ms程度で終わるはずなので、
        // 十分な余裕を持って400ms以上かかっていることを確認する。
        assert!(
            elapsed >= Duration::from_millis(400),
            "max_concurrencyが効いていない可能性: elapsed = {elapsed:?}"
        );
    }

    #[tokio::test]
    async fn total_timeout_aborts_tasks_waiting_on_permit() {
        // 同時実行数1、10件のタスク。total_timeoutを1タスク分の実行時間より
        // 短く設定し、「permit待ちで一度もfetchを開始していないタスク」が
        // abort_allで正しく打ち切られることを確認する。
        let inputs: Vec = vec![4, 9, 19, 24, 29, 34, 39, 54, 59, 64];

        let report = run_batch_bounded(
            inputs,
            1, // 同時実行数1本 = ほぼ直列実行
            Duration::from_secs(5),
            Duration::from_millis(50), // 1件目の160ms sleepすら終わらせない
        )
        .await;

        // 誰も成功できないまま全件がabortされる
        assert_eq!(report.succeeded.len(), 0);
        assert_eq!(report.aborted_by_total_timeout, 10);
    }
}
🧪
cargo test でこの3件のテストがすべてokになることを実際に確認済みです(test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out)。max_concurrency_caps_parallel_executionは同時実行数2で10件・160ms/件のタスクを処理した所要時間が800ms前後(無制限実行なら160ms程度で終わるはずの処理)になることで並行度の上限が機能していることを検証し、total_timeout_aborts_tasks_waiting_on_permitは同時実行数1・50msという極端な全体タイムアウトで「permit待ちのまま一度もfetchを開始できなかった」9件を含む全10件がaborted_by_total_timeoutに計上されることを確認しています。

🪜 Step-by-Step 解説

1
なぜpermitの取得場所が設計の核心なのか
Semaphore::acquire()を「spawnする前のループの中」で呼ぶか「spawnされた非同期ブロックの中」で呼ぶかは、見た目には小さな違いですが、意味は大きく異なります。前者は「同時にspawnできるタスク数」を制限し、後者は「同時にfetchを実行できるタスク数」を制限します。今回の要件は後者(外部APIへの同時接続数を制限したい)なので、JoinSetへのタスク登録自体はブロックさせず、各タスクの実行本体だけをゲートする設計を選びました。
2
acquire_owned()を使う理由
Semaphore::acquire()は&selfを借用し、戻り値のSemaphorePermit<'_>はその借用のライフタイムを引き継ぎます。今回のようにArc<Semaphore>をspawnされた'staticな非同期ブロックの中にmoveで持ち込み、その中でacquire()を呼ぶこと自体は(Arcはブロック内で所有されているので)コンパイルは通ります。しかしacquire_owned()を使うと、permitの型がOwnedSemaphorePermit(ライフタイムパラメータなし)になり、関数から返したり別の構造体に格納したりする際に一切ライフタイムを気にしなくてよくなります。「Arcを経由して所有権を共有する設計」と「借用のライフタイムに縛られない設計」は思想として一貫しているため、spawnに持ち込むpermitは常にacquire_owned()を使うのがtokioのイディオムです。
3
permitのdropタイミングがそのまま「解放」になる
_permitはタスクの非同期ブロックの最後(fetchの.awaitが完了した直後)でスコープを抜け、DropによってSemaphoreに1つ返却されます。これは明示的なsemaphore.release()を呼び忘れるバグが構造的に起きないことを意味します。C++やJavaで手動のlock()/unlock()ペアを書く感覚に近いことをやろうとすると、Rustでは「RAII(Resource Acquisition Is Initialization)でスコープに紐づける」のが自然な書き方になります。
4
abort_all()は「permit待ち」のタスクにも効く
total_timeoutを超えた場合、max_concurrencyの枠に入れずacquire_owned().awaitでブロックされたままのタスクもJoinSet::abort_all()の対象です。これらのタスクは一度もpermitを取得していないため、abortされてもSemaphoreに返却すべきpermitはありません(そもそも借りていない)。この「借りていないものは返さなくていい」という当たり前の性質が、テストtotal_timeout_aborts_tasks_waiting_on_permitで10件全てが正しくaborted_by_total_timeoutに計上されることの裏付けになっています。

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

Semaphoreを「spawnの外」ではなく「spawnの中」に置くという判断は、Rustにおける並行処理設計が「リソースの多重度をどの粒度の非同期タスクに割り当てるか」を型とコードの構造で明示させるという思想の延長線上にあります。Goであればsync.WaitGroupとバッファ付きチャンネルを組み合わせて似たパターンを書けますが、「どこでブロックし、どこで解放されるか」はコメントを読まないと分かりません。RustではArc<Semaphore>の所有権がどのスコープに属しているか、OwnedSemaphorePermitがどの変数の生存期間に紐づいているかを見れば、解放タイミングをコードの構造そのものから読み取れます。

📌
Day 041の学びと合わせると、「JoinSetは“何個のタスクがあるか”をランタイムに委ね、Semaphoreは“何個が同時に実行してよいか”を制御する」という役割分担が見えてきます。この2つは競合する概念ではなく、レイヤーの異なる直交した制御であるため、素直に組み合わせられます。

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

ヒント2で触れた「spawnする前のループの中で借用版acquire()してからspawnに持ち込む」誤った設計を試すと、以下のようなエラーになります。

// アンチパターン: ループの中で借用版acquire()を呼び、permitをspawnへ持ち込もうとする
for input in inputs {
    let permit = semaphore.acquire().await.unwrap(); // semaphore: &Semaphore を借用
    set.spawn(async move {
        let _permit = permit; // ここでエラー
        tokio::time::timeout(per_task_timeout, fetch(input)).await
    });
}
error[E0521]: borrowed data escapes outside of function
  --> src/main.rs:NN:9
  |
NN  |    set.spawn(async move {
  |            ^^^^^^^^^^ requires that `semaphore` is borrowed for `'static`
⚠️
読み方: Semaphore::acquire()が返すSemaphorePermit<'_>は、呼び出し元の&Semaphore借用のライフタイム(この場合はループのスコープにあるsemaphore変数の生存期間)を引き継ぎます。このpermitを'staticを要求するspawnのクロージャにmoveしようとすると、「借用がスコープの外まで“逃げよう”としている」とコンパイラに拒否されます。これはDay 041で見た「&inputsをspawnにそのまま渡せない」エラーと本質的に同じで、spawnされるタスクは親のスタックより長生きしうるため、参照や借用を一切含められないというtokioの制約が形を変えて現れたものです。acquire_owned()はこの制約を回避するために、借用ではなくArc経由の所有権共有でpermitを表現しています。

🌐 他言語との比較

観点Go(chan struct{} トークンバケツ)Java(Semaphore + ExecutorService)Rust(Arc<Semaphore> + JoinSet)
許可証の取得sem <- struct{}{}でチャンネルに送信(バッファが空くまでブロック)semaphore.acquire()(チェック例外なし、InterruptedExceptionのみ)semaphore.acquire_owned().await(非同期・キャンセル安全)
許可証の解放defer func() { <-sem }()を書き忘れるとリークするtry { ... } finally { semaphore.release(); }を書き忘れるとリークするOwnedSemaphorePermitのDropで自動解放。書き忘れが構造的に起きない
解放漏れの検出実行時に気づく(デッドロック・ハング)実行時に気づくコンパイル時には検出できないが、Drop実装により書き忘れる余地自体がない
タスクの動的な数との組み合わせgoroutineをforループで起動するだけで自然に両立Futureのリストを自前で管理する必要があるJoinSetと直交する形で組み合わせられる(今回の設計)

Go・Javaいずれも「取得したら解放を書く」という規約でリークを防ぐのに対し、Rustは「解放は変数のスコープが尽きた時に自動で起きる」という言語機構でリークを防ぎます。これは所有権システムがロックやリソース管理全般に一貫して適用される設計思想の一例です。

🏆 実務での使いどころ

  • 外部APIのレート制限順守: サードパーティAPIの「秒間N接続まで」という制約下で、大量のバッチリクエストを安全に流し込む
  • DBコネクションプールとの整合: Semaphoreのpermit数をDBコネクションプールのサイズと合わせ、sqlx等のプールが枯渇してタイムアウトエラーを起こす前に、アプリケーション側で並行度を絞る
  • CPUバウンドな処理のスレッドプール保護: tokio::task::spawn_blockingを使う処理で、OSスレッドプールのサイズ以上のブロッキングタスクを同時に走らせないようにする

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
Semaphoreの許可数を絞ればspawn自体もブロックされると思い込む「上限を設ける」=「作る数自体を減らす」という直感今回の設計ではspawnはタスク数分すぐに完了する。ブロックされるのは各タスクの中のacquire_owned().await。JoinSetに登録されるタスク数と、同時にfetchを実行するタスク数は別の値
acquire()とacquire_owned()はどちらでも同じだと思うどちらも「permitを1つ取得する」という意味では同じ操作に見えるacquire()は&Semaphoreの借用に紐づいたライフタイムを持つpermitを返す。'staticが要求されるspawnに持ち込むにはacquire_owned()(Arc経由)が必要
Semaphoreのpermitを.awaitをまたいで持つのはMutexと同様に危険だと思うDay 039で学んだ「MutexGuardを.awaitをまたいで持つとコンパイルエラーになる」という教訓からの過剰な一般化tokio::sync::Semaphoreはまさに「.awaitをまたいで許可証を保持する」ことを前提に設計されている。危険なのはstd::sync::Mutexのガードを.awaitをまたいで持つことであり、Semaphoreのpermitはむしろその用途のために存在する
max_concurrencyが効いているかをログ出力や目視で確認しようとする並行処理の検証はデバッグプリントで済ませたくなる今回のテストのように「並行度が制限されていれば伸びるはずの所要時間」を計測することで、実装を汚さずに自動テストで検証できる

🚀 次のステップ

  • 発展: max_concurrencyを実行時に動的に変更できるようにする(Semaphore::add_permits()を使い、外部からのバックプレッシャー信号に応じて許可数を増減させるサーキットブレーカー的な設計)
  • 次回予告: async trait(async-trait / RPITIT) — 非同期メソッドを持つトレイトの設計

🎯 自己評価

自分の回答

気づき・メモ