サイレント・ハングの構造Rustプログラムが突然停止し、行き詰まってしまった経験はないでしょうか。エラーメッセージも出ず、CPU使用率の急上昇もない、ただ静かにフリーズする現象です。これは通常、単一のスレッドが既に保持しているロックを再度取得しようとしたときに発生します。再帰関数や複雑なステートマシンでよく見られる、古典的なロジックの罠です。
以下の典型的な失敗パターンを見てみましょう。
use std::sync::Mutex;
struct Database {
connection_count: Mutex<u32>,
}
impl Database {
fn increment(&self) {
let mut count = self.connection_count.lock().unwrap();
*count += 1;
// まだロックを保持している!
// ここでlog_status()を呼び出すとデッドロックが発生する
self.log_status();
}
fn log_status(&self) {
// このスレッドは自身が所有するロックを永久に待ち続ける
let count = self.connection_count.lock().unwrap();
println!("現在の接続数: {}", *count);
}
}
fn main() {
let db = Database { connection_count: Mutex::new(0) };
db.increment();
}
標準ライブラリでこれを実行すると、ハングします。もしdeadlock_detection機能を有効にしたparking_lotクレートを使用している場合、プログラムは以下のような役立つパニックメッセージと共に即座にクラッシュします。
thread 'main' panicked at 'deadlock detected'
なぜロックが失敗するのかRustのstd::sync::Mutexは再入可能(reentrant)ではありません。スレッドごとに「一度に1つのロックのみ」という厳格なルールに従います。log_statusが.lock()を呼び出したとき、それが現在のスレッドが所有者であることを認識しません。代わりにスレッドを停止(パーク)させ、リソースが解放されるのを待ちます。increment関数はロックを解放する前にlog_statusの終了を待っているため、単一スレッド内で循環依存が発生してしまいます。
クイックフィックス:手動スコープMutexGuardを強制的にスコープ外に出すことで、この問題を素早く解決できます。Rustでは、ガードがドロップされた瞬間にロックが解放されます。専用のブロック{}を使用して機密性の高いロジックを隔離し、後続の呼び出しを行う前にリソースを解放します。
fn increment(&self) {
{
let mut count = self.connection_count.lock().unwrap();
*count += 1;
} // ここでMutexGuardがドロップされ、ロックが解放される
self.log_status(); // これで安全に呼び出せる
}
プロフェッショナルな修正:内部メソッドパターンコードベースが大きくなるにつれ、手動スコープは「いたちごっこ」のように感じられるかもしれません。より堅牢な設計アプローチは、メソッドを2つのカテゴリに分けることです。ロックを処理するパブリックメソッドと、生のデータを扱うプライベートな「内部」メソッドです。これはしばしば**内部メソッドパターン(Internal Method Pattern)**と呼ばれます。
impl Database {
pub fn increment(&self) {
let mut count = self.connection_count.lock().unwrap();
self.increment_internal(&mut count);
self.log_status_internal(&count);
} // 関数の最後でロックが綺麗に解放される
pub fn log_status(&self) {
let count = self.connection_count.lock().unwrap();
self.log_status_internal(&count);
}
// これらのプライベートメソッドは「ロックを意識しない」状態を保つ
fn increment_internal(&self, count: &mut u32) {
*count += 1;
}
fn log_status_internal(&self, count: &u32) {
println!("現在の接続数: {}", *count);
}
}
この戦略により、再ロックのリスクが排除されます。また、単一の実行パスで同じロックを複数回取得するオーバーヘッドを回避できるため、パフォーマンスも向上します。
ReentrantMutexを使用すべきケース時に、アーキテクチャ上どうしても標準のロックでは管理不可能な再帰が必要になる場合があります。このような稀なケースでは、parking_lotクレートのReentrantMutexを使用します。これにより、同じ回数だけロックを解放することを条件に、同じスレッドが複数回ロックを取得できるようになります。
use parking_lot::ReentrantMutex;
let m = ReentrantMutex::new(0);
let _g1 = m.lock();
let _g2 = m.lock(); // これは正常に動作する!
ただし、これの使用は控えめにしてください。再入可能なロックは、データ所有権に関するより深い問題を隠蔽し、コードの推論を困難にする可能性があります。
修正を確認する方法
- **ロック競合のトレース:** 非同期アプリの場合は`tokio-console`などのツールを使用して、ロックが保持されている時間を確認します。
- **デッドロック検出:** 開発中は`parking_lot`の`deadlock_detection`機能を使用します。本番環境にデプロイする前に、テストでこれらの問題をキャッチできます。
- **ストレステスト:** ロジックを1,000回以上のループで実行します。隠れたデッドロックがある場合、テストがハングするため、CI/CDパイプラインで容易に発見できます。

