シナリオ:1つのパニック、全体の停止毎秒100リクエストを処理するマルチスレッドのWebサーバーを想像してみてください。データベースの接続プールやグローバル設定を共有するために、Arc<Mutex<T>>を使用しています。すべては順順調に進んでいますが、あるスレッドがエッジケース(おそらくインデックスの範囲外アクセス)に遭遇し、ロックを保持したままパニックを起こしたとします。すると突然、そのデータにアクセスしようとする後続のすべてのリクエストが、次のメッセージとともにクラッシュします:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: PoisonError { data: .. }'
これはロック自体のバグではありません。安全機能です。データが破損している可能性がある状態でアプリケーションを継続させる代わりに、Rustは「汚染(poisoned)」されたリソースに触れようとするスレッドを停止させます。
なぜこれが起こるのか:整合性のセーフティネットRustでは、スレッドがMutexGuardを保持している間にパニックを起こすと、Mutexは「汚染(poisoned)」されます。Rustは最悪の事態を想定します。更新の途中でスレッドが終了すると、データが不整合な状態(例えば、ある口座からお金を引き出したのに、もう一方の口座に加算されていない銀行振込のような状態)になっている可能性があります。他のスレッドがこの「壊れた」データを読み取るのを防ぐため、RustはそのMutexを危険なものとしてマークします。
my_mutex.lock()を呼び出すと、Resultが返されます。Mutexが汚染されている場合、Err(PoisonError)を受け取ります。ほとんどの開発者はここで.unwrap()を使用します。これにより、処理可能なエラーがパニックに変わり、他のスレッドも汚染され、ミリ秒単位でプロセス全体を停止させるドミノ倒しのような現象が引き起こされます。
クイックフィックス:手動リカバリデータの整合性がミッションクリティカルでない場合や、状態を検証する方法がある場合もあります。そのような時は、PoisonErrorをキャッチしてデータを抽出することができます。エラーオブジェクト自体が、内部にガードを保持しています。
use std::sync::{Arc, Mutex};
let mutex = Arc::new(Mutex::new(0));
// スレッドをクラッシュさせずにロックを処理する
let mut guard = match mutex.lock() {
Ok(g) => g,
Err(poisoned) => {
// Mutexは汚染されていますが、内部データにはアクセス可能です
eprintln!("Warning: Recovering from a poisoned Mutex.");
poisoned.into_inner()
}
};
*guard += 1;
into_inner()を使用すると、パニックの拡散を止めることができます。ただし、これは慎重に使用してください。不完全な更新がシステムの他の場所でロジックバグを引き起こさないと確信できる場合にのみ行ってください。
プロの解決策:防御的設計リカバリロジックに頼ることは、多くの場合、より大きなアーキテクチャ上の問題の兆候です。より堅牢な戦略は、汚染状態を完全に防ぐか、より現代的なロックライブラリに切り替えることです。
1. クリティカルセクションを最小化するロックは可能な限り短い時間だけ保持する必要があります。JSONのパース、オーバーフローの可能性がある計算、配列のインデックス指定など、失敗する可能性のある操作はすべてロックのスコープ外に移動してください。ロックを取得する前、あるいは解放した後にパニックが発生すれば、Mutexは健全な状態を保ちます。
// 危険:失敗する可能性のある操作中にロックを保持している
{
let mut data = my_mutex.lock().unwrap();
let value = risky_calculation(); // ここでパニックが発生すると、Mutexが汚染されます
data.push(value);
}
// 改善:先に計算し、後でロックする
let value = risky_calculation();
{
let mut data = my_mutex.lock().unwrap();
data.push(value);
}
2. parking_lot に切り替えるparking_lot クレートは、高性能なRustにおける業界標準です。その Mutex 実装はより小さく、高速で、特筆すべき点として汚染(poisoning)を使用しません。スレッドがパニックを起こしても、ロックは単純に解放されます。次のスレッドは、Result 型を扱うことなく、すぐにロックを取得できます。
Cargo.toml に追加します:
[dependencies]
parking_lot = "0.12"
実装を更新します:
use parking_lot::Mutex;
use std::sync::Arc;
let mutex = Arc::new(Mutex::new(0));
// ResultもunwrapもPoisonErrorも不要
let mut guard = mutex.lock();
*guard += 1;
解決策の検証バックグラウンドスレッドを意図的に終了させるテストによって、リカバリロジックを検証できます。これにより、混乱の中でもメインスレッドが存続することを確認できます。
#[test]
fn test_mutex_survival() {
use std::sync::{Arc, Mutex};
use std::thread;
let m = Arc::new(Mutex::new(100));
let m_clone = m.clone();
// ロックを保持したままパニックを強制する
let _ = thread::spawn(move || {
let _lock = m_clone.lock().unwrap();
panic!("Intentional crash");
}).join();
// ロックへのアクセスを試みる
let lock_result = m.lock();
assert!(lock_result.is_err(), "Standard Mutex should be poisoned");
// エラーからデータ(100)を復旧する
let guard = lock_result.unwrap_or_else(|e| e.into_inner());
assert_eq!(*guard, 100);
}
このテストに合格すれば、システムは単一スレッドの障害に対して耐性があります。parking_lot に切り替えた場合、lock() 呼び出しは単純にガードを直接返し、テストはさらにシンプルになります。

