Day 039 — MutexGuardを.awaitをまたいで持つと、なぜtokio::spawnがコンパイルできなくなるのか

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

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

Day 038ではtokio::spawnに渡すFutureが'static + Sendでなければならない、という制約を「借用が漏れ出す('static違反)」の切り口から学びました。今日はもう一つの側面——Send違反——を扱います。実務のtokioコードで非常によく踏む地雷が、「std::sync::MutexGuardを.awaitをまたいで保持してしまう」バグです。

std::sync::Mutex<T>::lock()が返すMutexGuard<T>は、意図的にSendを実装していません(!Send——他スレッドへ移動できない設計です。これは「ロックを取得したスレッドとは別のスレッドからunlockされるとOS依存の未定義動作になりうる」というプラットフォームの制約を型で表現したものです)。

📌
tokioのワーカースレッド上では、1つのasync fnは.awaitのたびに一時停止・再開を繰り返します。再開されるとき、tokioのマルチスレッドランタイムは「前回止まったのと同じOSスレッドで再開する」保証をしません(work-stealingスケジューラのため、別スレッドが引き継ぐことがあります)。したがって.awaitをまたいでMutexGuard(!Sendな値)を保持していると、そのFuture自体がSendではなくなり、tokio::spawnに渡した瞬間にコンパイルエラーになります。

これは実行時のデータ競合ではなく、コンパイル時にRustが「実行時に起こりうる未定義動作」を型エラーとして先回りして止めてくれているという、Rustの所有権・借用システムの真骨頂とも言える挙動です。

📝 問題

以下は「アクセスカウンターをHashMapで管理し、複数のリクエストを並行処理する」ことを意図したコードですが、コンパイルが通りません。

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::sleep;

async fn handle_request(counters: Arc<Mutex<HashMap<String, u32>>>, path: String) {
    let mut map = counters.lock().unwrap();
    let entry = map.entry(path.clone()).or_insert(0);
    *entry += 1;
    println!("[{path}] count = {entry}");

    // 外部APIへの問い合わせを模した非同期処理(ロックを持ったまま待ってしまっている)
    sleep(Duration::from_millis(50)).await;

    println!("[{path}] request handled");
}

#[tokio::main]
async fn main() {
    let counters: Arc<Mutex<HashMap<String, u32>>> = Arc::new(Mutex::new(HashMap::new()));

    let mut handles = Vec::new();
    for path in ["/users", "/orders", "/users"] {
        let counters = Arc::clone(&counters);
        handles.push(tokio::spawn(handle_request(counters, path.to_string())));
    }

    for handle in handles {
        handle.await.unwrap();
    }
}

要件1: このコードがtokio::spawnの箇所でなぜコンパイルエラーになるのか、原因をMutexGuardとSendトレイトの関係から説明してください。

要件2: std::sync::Mutexを使ったまま(tokio::sync::Mutexに変更せずに)コンパイルが通るように修正してください。ヒント: ロックを保持するスコープを.awaitより前で終わらせます。

要件3: 修正後のコードで、「ロックを保持している時間」と「.awaitで待っている時間」が完全に分離されていることを確認できるprintln!ログ、またはテストを追加してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

エラーメッセージにはMutexGuardという型名とis not Send(あるいはcannot be sent between threads safely)という言葉が出てきます。「Sendでない値を.awaitをまたいで持っている」ことがFuture全体のSend性を破壊しているのがどの行かを、まず特定してください。ロックを取得してから解放するまでの区間(スコープ)に注目してください。

ヒント2 — アプローチ

let mut map = counters.lock().unwrap();で得たmap(MutexGuard)は、明示的にdrop(map)するか、{ }のブロックスコープに閉じ込めることで、そのスコープを抜けた時点で自動的に解放されます。「ロックして値を読み書きする」処理と「sleep().awaitで待つ」処理を、同じ関数の中で時間的に完全に分離することがポイントです。ロックが必要な処理を先に全部終わらせ、MutexGuardをスコープ外に出してから初めて.awaitする、という順番にします。

ヒント3 — コード骨格
async fn handle_request(counters: Arc<Mutex<HashMap<String, u32>>>, path: String) {
    let count = {
        let mut map = counters.lock().unwrap();
        let entry = map.entry(path.clone()).or_insert(0);
        *entry += 1;
        *entry
        // ここでブロックが終わり、`map`(MutexGuard)がdropされる
    };
    println!("[{path}] count = {count}");

    sleep(Duration::from_millis(50)).await; // この時点でロックは一切保持していない

    println!("[{path}] request handled");
}

✅ 模範解答

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

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::time::sleep;

/// 修正版: MutexGuardのスコープを`{ }`ブロックで明示的に区切り、
/// `.await`に到達する前にロックを解放してからawaitする。
/// これによりhandle_requestが返すFutureはロックを内部に保持し続けないため、
/// Sendトレイトを満たし、tokio::spawnに渡せるようになる。
async fn handle_request(counters: Arc<Mutex<HashMap<String, u32>>>, path: String) {
    // --- ロックを保持する区間はこのブロック内だけに限定する ---
    let count = {
        let mut map = counters.lock().unwrap();
        let entry = map.entry(path.clone()).or_insert(0);
        *entry += 1;
        *entry
        // ブロック終端で`map`(MutexGuard)がdropされ、ロックが解放される
    };
    println!("[{path}] count = {count} (lock released here)");

    // --- ここに到達した時点でロックは一切保持していない ---
    sleep(Duration::from_millis(50)).await;

    println!("[{path}] request handled");
}

#[tokio::main]
async fn main() {
    let counters: Arc<Mutex<HashMap<String, u32>>> = Arc::new(Mutex::new(HashMap::new()));

    let mut handles = Vec::new();
    for path in ["/users", "/orders", "/users"] {
        let counters = Arc::clone(&counters);
        handles.push(tokio::spawn(handle_request(counters, path.to_string())));
    }

    for handle in handles {
        handle.await.unwrap();
    }

    let final_map = counters.lock().unwrap();
    println!("最終カウント: {final_map:?}");
    assert_eq!(final_map.get("/users"), Some(&2));
    assert_eq!(final_map.get("/orders"), Some(&1));
}

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

    /// ロックを保持している時間と.awaitで待っている時間が
    /// 分離されていることを確認するテスト。
    /// もしロックをまたいで.awaitしていたら、このテスト自体が
    /// (Sendでないため)コンパイルの時点で失敗するはずである。
    #[tokio::test]
    async fn lock_is_released_before_await() {
        let counters: Arc<Mutex<HashMap<String, u32>>> = Arc::new(Mutex::new(HashMap::new()));

        // 3つのタスクを並行spawnできること自体が、
        // handle_requestの返すFutureがSendであることの証明になる。
        let mut handles = Vec::new();
        for path in ["/a", "/b", "/a"] {
            let counters = Arc::clone(&counters);
            handles.push(tokio::spawn(handle_request(counters, path.to_string())));
        }
        for handle in handles {
            handle.await.unwrap();
        }

        let map = counters.lock().unwrap();
        assert_eq!(map.get("/a"), Some(&2));
        assert_eq!(map.get("/b"), Some(&1));
    }
}
🧪
修正版はcargo runで3行の[path] count = N (lock released here)ログの後にそれぞれrequest handledログが続き、最後に最終カウント: {"/users": 2, "/orders": 1}が出力されます。cargo testのlock_is_released_before_awaitがコンパイル・実行できること自体が「handle_requestの返すFutureがSendである」ことの動く証拠になります。

🪜 Step-by-Step 解説

1
何が起きてコンパイルが通らなくなるのか
問題のコードではlet mut map = counters.lock().unwrap();で得たMutexGuardが、その後のsleep(...).await;という行をまたいで生き続けています。async fnは内部的に「一時停止・再開が可能な状態機械(state machine)」にコンパイルされ、.awaitをまたいで生存する変数はすべてその状態機械のフィールドとして持ち運ばれます。つまりmap: MutexGuard<HashMap<...>>が、コンパイラが生成するFuture型の一部になってしまうのです。
2
Send要求はどこから来るのか
tokio::spawnのシグネチャは概ねfn spawn<F>(future: F) -> JoinHandle<F::Output> where F: Future + Send + 'static, F::Output: Send + 'staticという形をしています。マルチスレッドランタイムはタスクをワーカースレッド間で移動させる(work-stealing)ため、渡すFuture自体がSendでなければなりません。Step 1で見た通り、handle_requestが返すFutureは内部にMutexGuard(!Send)を抱えているため、Future全体も!Sendになり、F: Sendという境界を満たせずコンパイルエラーになります。
3
スコープを区切って解決する
{ }ブロックの評価値をcountというu32(SendかつCopyな値)で受け取ることで、MutexGuardはブロックの終端、つまり.awaitに到達する前にdropされます。この時点で「.awaitをまたいで生存する変数」からMutexGuardが消えるため、コンパイラが生成する状態機械にもMutexGuardは含まれなくなり、Future全体がSendを回復します。

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

このバグが型エラーとして現れること自体が、Rustの所有権システムの価値を最も分かりやすく示す例の一つです。もしこれがC++やJavaであれば、「マルチスレッドランタイムがロックを取得したのとは別のスレッドでロックを解放しようとする」というバグは、プラットフォームによっては動くこともあれば、実行時にクラッシュしたり、稀にしか再現しないデータ競合になったりする——つまり「本番環境の特定条件でしか踏まないバグ」になりがちです。

📌
RustではMutexGuardが!Sendであるという事実が型システムに刻まれているため、この危険な使い方はそもそもコンパイルが通らないという形で、コードを書いている最中に強制的に気づかされます。「実行時に起きうる問題を、型チェックの段階で機械的に検出する」——これはゼロコスト抽象化と並ぶ、Rustの並行性に対する中心的な設計思想("fearless concurrency")の具体的な現れです。

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

問題のコード(修正前)をtokio::spawnに渡すと、次のようなエラーが出ます(tokioのバージョンにより文言は多少異なります)。

error: future cannot be sent between threads safely
  --> src/main.rs:22:32
  |
22  |       handles.push(tokio::spawn(handle_request(counters, path.to_string())));
  |                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ future returned by `handle_request` is not `Send`
  |
  = help: within `impl Future<Output = ()>`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, HashMap<String, u32>>`
note: future is not `Send` as this value is used across an await
  --> src/main.rs:9:5
  |
7  |    let mut map = counters.lock().unwrap();
  |       ------- has type `std::sync::MutexGuard<'_, HashMap<String, u32>>` which is not `Send`
...
9  |    sleep(Duration::from_millis(50)).await;
  |    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ await occurs here, with `mut map` maybe used later
note: required by a bound in `tokio::spawn`
⚠️
読み方: ①一番上のfuture cannot be sent between threads safelyが結論。「このFutureはスレッド間を安全に移動できない」。②help:の行が原因の型を教えてくれます——MutexGuardがSendを実装していない。③note: future is not Send as this value is used across an awaitが決定的な手がかりです。「mut mapという値が.awaitをまたいで使われている(maybe used later)」と、問題の変数名と行番号まで具体的に示してくれます。修正方針は常に同じ——その変数のスコープを.awaitより前で終わらせることです。

🌐 他言語との比較

観点GoJavaRust(tokio)
ロック保持中に非同期待ちをするとどうなるかsync.Mutexはgoroutineをブロックするだけなので「保持したまま<-chで待つ」ことは普通に書けてしまう(デッドロックの温床)synchronizedブロック中に他スレッドの完了を待つコードは書けてしまう(同様にデッドロックのリスク)MutexGuardが.awaitをまたぐとコンパイルが通らない(Send違反として検出)
検出タイミング実行時(デッドロック発生時、または静的解析ツールが検出できれば)実行時(多くはロードテストや本番で偶発的に発覚)コンパイル時(cargo buildの時点で確実に検出)
修正の強制力強制力なし。レビューやツールに依存強制力なし型システムによる強制。危険なコードはそもそもビルドできない

GoやJavaでも「ロックを持ったまま長時間待つな」という同種のベストプラクティスは存在しますが、それを守るかどうかは開発者の規律に委ねられています。RustはMutexGuardに!Sendという型レベルの制約を持たせることで、tokioのマルチスレッドランタイムという特定の文脈においては、このベストプラクティス違反をコンパイラが強制的に握りつぶすという一歩踏み込んだ設計を取っています。

🏆 実務での使いどころ

  • Webサーバーの共有状態管理: リクエストカウンター・キャッシュ・コネクションプールの空き状況などをArc<Mutex<T>>で管理する際、DBやHTTPクライアントへの.awaitをロックの外に追い出す設計は、tokioを使う実務コードで最も頻繁に登場するリファクタリングパターンの一つ
  • tokio::sync::Mutexとの使い分けの判断材料: 「ロックを持ったまま非同期I/Oを待つ必要が構造上どうしてもある」場合はtokio::sync::Mutex(非同期対応・.awaitをまたげる)に切り替える選択肢もあるが、それは「ロック保持時間を延ばす」ことと同義であり、まずは今回のようにロック保持区間を縮めてstd::sync::Mutexのまま解決できないかを先に検討するのが定石
  • コードレビューの着眼点: PRレビューでasync fn内に.lock()と.awaitが両方登場していたら、両者の間に他の.awaitが挟まっていないか(=ロックを保持したままの.awaitが発生していないか)を機械的にチェックする習慣が、実務での事故防止に直結する

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
このエラーは「デッドロックの警告」だと思うエラー文言にMutex・lockが出てくるため、実行時のデッドロックと混同しやすいこれは実行時の現象ではなく、コンパイル時のSendトレイト境界違反。デッドロックそのものではなく「デッドロックや未定義動作を引き起こしうる書き方」を型検査で事前に弾いている
tokio::sync::Mutexに変えれば常に「正解」だと思うエラーメッセージの解決策として広く紹介されがちなためtokio::sync::Mutexは.awaitをまたげるが、その間ロックは保持され続け、他のタスクを長時間待たせる可能性がある。まず「ロック保持区間を縮めてstd::sync::Mutexのままawaitの外に出せないか」を検討し、それでも構造上どうしても無理な場合の最終手段と位置づけるのがよい
{ }で囲むだけで意味があるのか半信半疑になる見た目上は何も処理していないブロックに見えるRustでは{ }はスコープであり、スコープの終端は変数のDropが呼ばれるタイミングを決定する。ブロックの戻り値だけを外に持ち出し、MutexGuard自体はブロック内に閉じ込めて破棄する、という所有権システムを積極的に使ったイディオムである

🚀 次のステップ

  • 発展: 今回のhandle_requestを、tokio::sync::Mutex版でも実装し、std::sync::Mutex版と比べて「ロックを解放するタイミング」がどう変わるかをログで比較してみましょう
  • 次回予告: async trait(async-trait / RPITIT) — トレイトに非同期メソッドを持たせる設計と、async-traitクレートとRPITIT(Return Position Impl Trait In Trait)の使い分けに進みます

🎯 自己評価

自分の回答

気づき・メモ