デッドロック:四つの条件のどれか一つを壊せばよい
相互排他・保持と待機・横取り不可・循環待機。四つが同時に成立して初めて起きます。実務で壊しやすいのは循環待機で、ロックに全体順序を与えます。
デッドロックは偶発的な不具合ではありません。四つの条件が同時に成立した必然的な結果です。それが分かれば対策は機械的になります。どれか一つを壊せばよいのです。
四つの条件
| 条件 | 意味 |
|---|---|
| 相互排他 | 資源は同時に一つの持ち主しか持てない |
| 保持と待機 | 一つを保持したまま別を要求する |
| 横取り不可 | ロックを強制的に奪えない |
| 循環待機 | A が B を、B が A を待つ環がある |
前三つは通常変えられません。排他はロックの本質であり、横取りは臨界区間の前提を壊します。変えられるのは四番目です。
循環待機を壊す:順序を決める
すべてのロックに全体順序を与え、その順序でのみ取得します。
// 各資源は安定した id を持つ
const order = (a: Account, b: Account) => (a.id < b.id ? [a, b] : [b, a]);
async function transfer(from: Account, to: Account, amount: number) {
const [first, second] = order(from, to);
await first.lock();
try {
await second.lock();
try {
// 両方のロックを全体順序で保持しているので環はできない
first.balance -= amount;
second.balance += amount;
} finally { second.unlock(); }
} finally { first.unlock(); }
}
送金は教科書的な例です。順序を決めないと A→B と B→A の同時実行が確実にデッドロックします。順序を決めれば環は存在しません。
保持と待機を壊す:まとめて取得
全部取るか、一つも取らないかです。代償は並行度の低下で、複数のロックを原子的に取る仕組みが必要です。
横取り不可を壊す:時間制限
if (!await lock.tryAcquire({ timeoutMs: 500 })) {
// 保持分を解放して再試行
}
これはデッドロックを無くすのではなく、永久の停止を再試行可能な失敗に変えるものです。退避経路が必須です。でなければデッドロックをエラーに置き換えただけです。
予防ではなく検出
データベースは概ねこの道を選びます。デッドロックを許し、環を検出して一方のトランザクションを巻き戻します。したがって本番コードは「巻き戻されたので再試行」を扱える必要があります。
for (let attempt = 0; attempt < 3; attempt++) {
try { return await db.transaction(work); }
catch (e) { if (!isDeadlock(e) || attempt === 2) throw e; }
}
最も見落とされる例
非同期コードの暗黙のロック。 ロックを保持したまま長時間または無期限に掛かり得る操作を await すると、保持時間が無限に伸び、確率的なデッドロックが必然になります。
まずロックに順序を与えてください。それだけで実務上のデッドロックの大半が解消し、実行時の支援も要りません。

コメント
…