デッドロック:四つの条件のどれか一つを壊せばよい

相互排他・保持と待機・横取り不可・循環待機。四つが同時に成立して初めて起きます。実務で壊しやすいのは循環待機で、ロックに全体順序を与えます。

デッドロックは偶発的な不具合ではありません。四つの条件が同時に成立した必然的な結果です。それが分かれば対策は機械的になります。どれか一つを壊せばよいのです。

四つの条件

条件 意味
相互排他 資源は同時に一つの持ち主しか持てない
保持と待機 一つを保持したまま別を要求する
横取り不可 ロックを強制的に奪えない
循環待機 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 すると、保持時間が無限に伸び、確率的なデッドロックが必然になります。

まずロックに順序を与えてください。それだけで実務上のデッドロックの大半が解消し、実行時の支援も要りません。

← 記事一覧に戻る

コメント

…