Featured image of post コードテイマー【Deadlock】二つの釜が、互いの鍵を見つめ合って石になる〜鍵を拾う順を一つに定め、にらみ合いを解く〜

コードテイマー【Deadlock】二つの釜が、互いの鍵を見つめ合って石になる〜鍵を拾う順を一つに定め、にらみ合いを解く〜

二つの自動錬金釜は、単体ではどちらも完璧だった。なのに同時に動かすと、片方が水の鍵を握って火の鍵を待ち、もう片方が火の鍵を握って水の鍵を待ち——互いの一手を見つめ合ったまま石になる。データは壊れない。ただ、永久に止まる。JSはシングルスレッドでもデッドロックは無縁ではない。鍵を拾う順を全アプリで一つに揃え、循環の待ち合いを構造から断つLock Orderingと、復旧策のTimeoutの代償を、永久ハングを決定的に可視化した模擬戦で確かめるTypeScriptの契約の物語。

石になった二つの釜

その魔物使いを呼んだのは、私の自負が、現実に裏切られたからだった。

私は、錬金術ギルドの釜方を預かっている。自動錬金釜——仕込みを入れれば、水を満たし、火を熾し、頃合いを計って触媒を錬り上げる、手のかからない釜だ。私はそれを二基、自分の手で組み上げた。一基ずつなら、何百回回しても、寸分の狂いもない。それが、私の自慢だった。

なのに、二基を同時に回すと、ときどき——両方が、止まる。

その日も、そうだった。工房に下りると、二つの釜が、稼働の途中で固まっていた。片方は炉に火を熾したまま、もう片方は槽に水を満たしたまま、どちらも次の一手へ進まない。煙は立っている。熱もある。なのに、動かない。叩いても、待っても、動かない。仕込んだ触媒は、止まれば腐る。その朝の納品は、落ちた。

カイナの名は、ギルドの獣譜——過去に暴れた術式を鎮めた記録を綴じた、分厚い台帳——の片隅で見つけた。別のギルドの釜を鎮めた一件に、見慣れない契約印が押してあった。署名は、カイナ。他に手立ても無く、私はその印の主を辿った。

来た魔物使いは、挨拶よりも先に、固まった二釜の手元——それぞれが握っている鍵を、順に見た。

「片方は、水の鍵を握って、火を待っている」と彼女は言った。「もう片方は、火の鍵を握って、水を待っている」

シングルスレッドに、デッドロックはない——はずだった

「だが、おかしいでしょう」私は、自分でも整理しきれない苛立ちを、理屈にぶつけた。「この術式(JavaScript/TypeScript)は、一度に一つのことしかしない。テイマーは一人、巡回路は一本だ。二つの釜が、同じ刻に、物理で同じ鍵を奪い合う——スレッドのデッドロックなんて、起きようがない。なのに、なぜ固まる」

カイナは、すぐには答えなかった。固まった釜の鍵を、もう一度見てから、低く言った。

「お前の言う通り、巡回路は一本だ。二つの釜が、同じ刻に同じ鍵を物理で奪い合うことはない。——だが、デッドロックは、そこじゃない」

意味が掴めなかった。

「鍵が空くのを待つ、という約束がある。Promise だ。その約束は『鍵が解放されたら叶う』。だが、鍵を解放する出来事のほうが、別の——決して叶わない約束を待っているなら、どうなる。二つの待ちは、永久にほどけない。巡回路は回り続ける。CPU は、何も食わない。なのに、この二釜の処理だけが、永久に止まる。スレッドの石化じゃない。お前が、約束の上に組み上げた、論理の石化だ」

シングルスレッドと、永久ハング。相容れないと思い込んでいた二つが、初めて、頭の中で両立しかけた。

素直な術式

私は、二釜の術式(コード)を見せた。隠すことは何もない。素直な術式だ。

まず、鍵。鍵は一つずつしか握れない。誰かが握れば、他は待つ。それだけのものだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type Brew = () => Promise<void>;

// 鍵(共有リソース)。容量1の門番——前に立てた「N体まで通す門番」の、N=1 版だ。
// acquire は「解放する関数」を返す約束。握れたら即返し、握れなければ行列で待つ。
class Mutex {
  private locked = false;
  private readonly waiters: Array<(release: () => void) => void> = [];

  acquire(): Promise<() => void> {
    if (!this.locked) {
      this.locked = true;
      return Promise.resolve(this.makeRelease()); // 空いていた:即握れる
    }
    return new Promise<() => void>((resolve) => {
      this.waiters.push(resolve);                  // 埋まっている:行列で待つ
    });
  }

  private makeRelease(): () => void {
    let released = false;
    return () => {
      if (released) return;          // 二重解放は無視(冪等)
      released = true;
      const next = this.waiters.shift();
      if (next) {
        next(this.makeRelease());    // 行列の先頭へ、握ったまま引き継ぐ
      } else {
        this.locked = false;         // 待ち手なし:解錠
      }
    };
  }
}

「鍵は、容量1の門番だ」とカイナ。「前に、一度にN体まで通す門番を立てた話をしたな。あれの、N=1 版だ。一度に、一つしか通さない」。握れたら解放する関数が返り、握れなければ行列で待つ。返すときは、待ち手がいればその先頭へ握ったまま引き継ぎ、いなければ錠を開ける。前の門番と、同じ作法だった。

その鍵を、釜はこう使う。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Before: 二つの鍵を「自分の都合の順」で取る。釜ごとに順が違う。
async function brewBad(first: Mutex, second: Mutex, brew: Brew): Promise<void> {
  const releaseFirst = await first.acquire();      // 一本目
  try {
    const releaseSecond = await second.acquire();  // 二本目(相手が握っていれば、ここで待つ)
    try {
      await brew();
    } finally {
      releaseSecond();
    }
  } finally {
    releaseFirst();
  }
}

// 釜A は 水→火、釜B は 火→水。順番が、逆。
// const runA = () => brewBad(water, fire, brewA);
// const runB = () => brewBad(fire, water, brewB);

釜A は、水を満たしてから火を熾す。だから 水の鍵→火の鍵 の順に取る。釜B は、火を熾してから水を足す。だから 火の鍵→水の鍵 の順に取る。それぞれの段取りに、嘘はない。

カイナは brewBad を端まで追って、低く言った。

「術式そのものに、間違いはない。鍵を取る、仕事をする、鍵を返す。一点の曇りもない。——だが、二つを向き合わせると、見落としが立ち上がる。取る順番が、逆だ」

にらみ合いを、檻の中で起こす

最悪の一瞬を、手で作る

カイナが組みはじめたのは、その石化を、檻の中で起こす模擬戦だった。

だが、ただ二釜を走らせるだけでは、固まらない。運が良ければ、片方が水も火も取り切って、すり抜けてしまう。「ときどき」しか起きない。だから彼女は、固まる最悪の一瞬を、手で作ることにした。釜A に水の鍵を、釜B に火の鍵を、先に握らせてしまう。その上で、両者に二本目を要求させる。

ここは、正直に書いておく。これは再現の檻だ。本番では、二つの釜が運悪く同じ刻にこの形へ達したときに、初めて起きる。檻は、その最悪の一瞬を狙って固定しただけで、本番の取得手順そのものではない。——もっと正確に言えば、この檻は、brewBad(water, fire) が一本目の水を取り、二本目の火で止まった状態(=釜A)と、brewBad(fire, water) が一本目の火を取り、二本目の水で止まった状態(=釜B)を、こちらの手で並べたものだ。本番なら運任せのその瞬間を、狙って捕まえる。

1
2
3
4
// flush は保留を確定させるまでマイクロタスクを数段進める道具(setTimeout は使わない)。
const flush = async (): Promise<void> => {
  for (let i = 0; i < 4; i++) await Promise.resolve();
};

flush は、保留中の約束を確定させるまで、手番を数段だけ進める小道具だ。実時間を待つわけではない。手番を、進めるだけ。acquire().then(...) の鎖は一段か二段で確定するが、四段回しているのは、保留が残らないことを確かめるための安全側の余裕で、多めに回しても害はない(前の門番の回と同じく、setTimeout は使わない。掃くのは手番だけだ)。

どちらも、間違えていない

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
it("逆順に二本取ると、互いに片方を握って相手を待ち、永久に固まる(循環待機の再現)", async () => {
  const water = new Mutex();
  const fire = new Mutex();

  // 再現の檻:A に水を、B に火を先に握らせ、「保持と待機」を決定的に作る
  const aHoldsWater = await water.acquire(); // 釜A が水の鍵を保持
  const bHoldsFire = await fire.acquire();   // 釜B が火の鍵を保持

  // ここから A は火を、B は水を要求する。どちらも相手が保持=決して取れない。
  let aGotFire = false;
  let bGotWater = false;
  const aWaitsFire = fire.acquire().then((r) => { aGotFire = true; return r; });
  const bWaitsWater = water.acquire().then((r) => { bGotWater = true; return r; });
  aWaitsFire.catch(() => {});  // 未解決のまま放置=石化の証明。await しない
  bWaitsWater.catch(() => {});

  await flush();

  assert.equal(aGotFire, false);   // A は火を取れない(B が保持)
  assert.equal(bGotWater, false);  // B は水を取れない(A が保持)

  void aHoldsWater; void bHoldsFire; // 解放すれば解けるが、ここでは解かない(固まりの証明)
});

カイナが檻を回すと、aGotFirebGotWater も、false のままだった。釜A は火に届かない。釜B は水に届かない。どちらも二本目を取れず、錬成にすら入れない。

ここで肝心なのは、なぜこれが永久に続くか、だ。釜A が水を返すのは、火を取って、錬成を終えた後だ。だがその火は、釜B が握っている。釜B が火を返すのは、水を取って、錬成を終えた後だ。だがその水は、釜A が握っている。A の解放は B 待ち、B の解放は A 待ち。二つの解放が、互いの解放を待つ輪を作って、閉じている。だから、どちらの解放も、永久に来ない。マイクロタスクを何段進めようが、来ないものは来ない。輪が閉じている限り、針は動かない。

だから、テストはこの二つの約束を await しない。待てば、テスト自身が永久に止まる。待たずに、flush で手番を進めて「両方とも二本目を取れていない」とだけ確かめ、未解決の約束は .catch を添えて放っておく。固まりの瞬間を捉えて、そっと閉じる。

私は、計器の前で立ち尽くした。

「待ってくれ」と私は言った。「これは……どちらの釜も、間違ったことを、一つもしていない。鍵を取って、待って、それだけだ。なのに、止まる」

「そうだ」とカイナは言った。「型も、この石化を見張れん。brewBad の戻りは Promise<void>。どちらの釜の術式も、単体では型も中身も完璧だ。固まるのは、二つを向き合わせたときの『取る順』——型のどこにも書かれない、走らせて初めて立ち上がる関係だ」

そして、彼女は古い話を持ち出した。

「前に、双頭の犬が、荷を二度送った話をした。連打で、同じ荷が二つ走った。あれは、動いて、間違える獣だ。荷が、壊れる。——こいつは、逆だ。何も間違えない。ただ、動かなくなる。壊れた触媒は、一つも出ない。出ないんじゃない。何も、出ないんだ」

何も間違えないのに、止まる。私はその不条理を、ようやく論理として飲み込んだ。シングルスレッドだから安全、ではなかった。一本の巡回路の上でも、約束が約束を待つ輪を作れば、処理は永久に止まる。二つの術式がどう向き合い、どうやって見つめ合うのかを、カイナは一本の糸の絡まりに例えて示した。それは、互いが相手の懐にある鍵を求め合う、閉じた約束の輪だった。

Infographic sequence diagram showing double-submission deadlock condition where two kettles wait for each other’s keys eternally

二つの釜が、相手の返した約束を自らの解放の糧としようとする。だがその約束は、自分が握ったままのもう一つの鍵が解放されなければ叶わない。この無限 of 待ち合いこそが、シングルスレッドに現れた「論理の石化」の正体だった。

刻限を、設ければいい

不条理が論理になると、私の頭は勝手に直し方を探しはじめた。

「なら、こうすればいい」と私は、自分から踏み込んだ。「鍵を待つのに、刻限を設ける。一定の刻、待っても取れなければ、諦める。そうすれば、少なくとも——永久には、固まらないだろう」

カイナは、私の案を頭ごなしには否定しなかった。「やってみろ」とだけ言って、刻限つきの鍵取りを組んだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type Sleep = (ms: number) => Promise<void>; // 待ち(DI)。模擬戦では即解決=即タイムアウト

// タイムアウト付き獲得:acquire とタイマーを競わせる。
async function acquireWithTimeout(
  mutex: Mutex, sleep: Sleep, ms: number,
): Promise<(() => void) | null> {
  const acq = mutex.acquire();
  const timedOut = sleep(ms).then(() => "timeout" as const);
  const winner = await Promise.race([acq.then((release) => ({ release })), timedOut]);
  if (winner === "timeout") {
    // ★負けた acquire は止まらない。後から取れたら、その場で手放す(握りっぱなしを防ぐ)
    acq.then((release) => release()).catch(() => {});
    return null;
  }
  return winner.release;
}

「だが、一つ、罠がある」とカイナは、Promise.race の一行を指した。「刻限に勝たせても、負けた鍵取りは、止まらない。裏で、まだ鍵を取りにいったままだ。後から取れてしまったら、誰も使わない鍵を握って、また別の誰かを待たせる。だから——後から取れたら、その場で手放す一行が要る」。Promise.race は、勝ち負けを決めるだけで、負けたほうを断ち切ってはくれない。その後始末は、自分で書く。——断っておくと、この「後から取れたら手放す」一行(★)が実際に火を握って手放すのは、刻限を過ぎた後で、火を握っていた誰かが鍵を返したときだ。この後に見せる模擬戦では、火はずっと握られたままにする。だからそこで効くのは、この★ではなく、もう一つの後始末——握った一本目を確実に返すほうだ。二つの後始末は、効く局面が違う。

諦めた鍵は、誰が返す

「凍結は、これで解ける」とカイナは言った。「だが、刻限で諦めるのは、もう一段、面倒を呼ぶ」。

彼女は、こんな模擬戦を見せた。水を握ったまま、火の鍵取りが刻限で諦めた——そのとき、握っていた水を、誰が返すのか。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
it("二本目がタイムアウトしたとき一本目を解放しないと、鍵が残る(解放漏れ=別の障害)", async () => {
  const water = new Mutex();
  const fire = new Mutex();
  const holdFire = await fire.acquire();          // 別フローが火を握って離さない=必ずタイムアウト
  const sleep: Sleep = () => Promise.resolve();   // 即タイムアウト(DI、setTimeout不使用)

  // 誤実装:水を握り、火がタイムアウト→でも水を解放せずに諦める
  const releaseWater = await water.acquire();
  const second = await acquireWithTimeout(fire, sleep, 0);
  assert.equal(second, null);                     // 火は取れなかった
  // ★ここで releaseWater() を呼ばずに諦めている(解放漏れ)

  let gotWater = false;
  water.acquire().then((r) => { gotWater = true; return r; }).catch(() => {});
  await flush();
  assert.equal(gotWater, false);                  // 水が握られたまま=次のフローが取れない(リーク)

  void holdFire; void releaseWater;
});

火を諦めても、水を返さなければ、水の鍵はロックされたまま残る。次に水を欲しがる誰かが、永久に待つ。石化は防いだが、今度は鍵が残るという別の障害——リークに化けた。直すには、刻限で諦めるときも、握った鍵を finally で確実に返す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
it("一本目を finally で確実に解放すれば、タイムアウトしても鍵は残らない", async () => {
  const water = new Mutex();
  const fire = new Mutex();
  const holdFire = await fire.acquire();
  const sleep: Sleep = () => Promise.resolve();

  // 正しい実装:finally で一本目を必ず解放
  const releaseWater = await water.acquire();
  try {
    const second = await acquireWithTimeout(fire, sleep, 0);
    assert.equal(second, null);
  } finally {
    releaseWater();                               // ★タイムアウトでも水は必ず返す
  }

  let gotWater = false;
  water.acquire().then((r) => { gotWater = true; return r; }).catch(() => {});
  await flush();
  assert.equal(gotWater, true);                   // 水が解放された=次が取れる
});

「データベースでも、同じ罠がある」とカイナは言い添えた。「デッドロックを検出したときは、掴んだものを全部巻き戻す。だが、待ちの刻限が切れただけのときは、設定次第で巻き戻さん。掴んだ分が、残る。同じ『諦める』でも、後始末まで、自分で書かねばならん」。

「それと、もう一つ」と彼女は続けた。これは、コードにせずに話した。「二つの釜が、同じ刻限で、揃って諦めて、揃って取り直したら——また同じ形でぶつかる。諦めて、ぶつかって、また諦めて。固まりはしない。だが、誰も前に進まない。石になる代わりに、堂々巡りだ。これをライブロック——止まってはいないのに、進まない状態、という」。

私は、別の獣の話を思い出した。前に彼女は、嵐の中で雷鳥が揃って飛び立って、立ち直りかけた塔をまた潰した話をしていた。

「そうだ」とカイナは頷いた。「あのとき効いたのが、待つ刻に揺らぎを混ぜること——ゼロから上限までの幅で、待ちを一頭ずつ運任せに散らす、Full Jitter だった。ここも同じだ。諦める刻を、一頭ずつ散らせば、揃って取り直す対称が崩れて、どちらかが先に通る」。

このライブロックだけは、檻の中で決定的には見せられなかった。にらみ合いも、解放漏れも、「必ずこうなる一瞬」を狙い撃って固定できた。だがライブロックがほどけるかどうかは、揺らぎという確率の話だ。デッドロックのように針を止めて捕まえる、というわけにいかない。だからカイナは、これだけは檻に載せず、口で語った。決定的に見せられる暴走と、確率でしか語れない暴走の、境目だった。

そして、彼女は刻限つきの鍵取りを、脇へ置いた。

「タイムアウトは、悪い手じゃない。鍵を取る相手を、全部、自分の術式で握れない——別のギルドや、遠くの誰かが、こちらの決めた順を守らずに鍵を取る——そういう場には、これしかない。検知して、諦めて、揺らぎを入れて取り直す。だが、これは『凍結を検知して、復旧する』手だ。にらみ合いそのものは、まだ残っている。お前の二釜は、どちらもお前が書いた。なら——もっと根本から、断てる」

鍵を拾う順を、一つに定める

逆向きの組を、作れなくする

カイナの本命は、鍵の取り方を組み替えることでも、一行を足すことでもなかった。

「鍵に、序列をつける」と彼女は言った。「どの釜も、どの職人も、鍵を取るときは必ず——番号の小さい鍵から、大きい鍵へ。たった一つの順でしか、取らせない」。

なぜそれで石化が消えるのか。私が問う前に、彼女は続けた。

「石化が起きるには、片方が水→火、もう片方が火→水、という逆向きの組が要る。だが、全員が同じ順——たとえば火が先、水が後——でしか取らなければ、その逆向きの組が、そもそも生まれない。にらみ合いの輪を、組もうとしても、閉じない」。

ここで言う「順」は、鍵に一意な名前(id)を振って、その大小で決める。名前は何でもいい——文字列を辞書順に並べてもいいし、数値の通し番号でも、UUID でもいい。肝心なのは二つ。鍵ごとに一意であること(同じ名前が二つあると、どちらが先か決まらない)。そして、全員が同じ物差しで並べること。誰か一人が違う比較規則を使えば——たとえば片方が数値として、もう片方が文字列の辞書順として並べれば("10""2" の前後が食い違うように)——そこから逆向きの組が忍び込む。順序は、全員で一つに揃えて、初めて効く。

brewBad の「自分の都合の順」を、必ず id 昇順で取る acquireInOrder に差し替える。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type IdLock = { id: string; mutex: Mutex };

// 複数の鍵を「id の昇順」で取得する。全員がこれを通れば、逆順の組が生まれない。
async function acquireInOrder(locks: IdLock[]): Promise<() => void> {
  const sorted = [...locks].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
  const releases: Array<() => void> = [];
  try {
    for (const lock of sorted) {
      releases.push(await lock.mutex.acquire());   // 昇順に、一本ずつ
    }
    return () => { for (const r of [...releases].reverse()) r(); }; // まとめて逆順に解放
  } catch (err) {
    for (const r of [...releases].reverse()) r();  // 途中で失敗したら、取った分はロールバック解放
    throw err;
  }
}

// After: 渡す順がどうであれ、acquireInOrder が必ず id 昇順に揃える。
async function brewOrdered(locks: IdLock[], brew: Brew): Promise<void> {
  const release = await acquireInOrder(locks);
  try {
    await brew();
  } finally {
    release();
  }
}

// 釜A も釜B も、同じ二鍵を渡す順は違っても、取得は必ず id 昇順(fire < water)。
// const runA = () => brewOrdered([water, fire], brewA);
// const runB = () => brewOrdered([fire, water], brewB);

末尾の catch——途中まで取った鍵を、まとめて返すロールバック——について、カイナは一言付け加えた。「この素の鍵取りは、決して失敗しない。だから、この catch は、順調なときは走らん。効いてくるのは、さっきの刻限つきの鍵取りと組んだときだ。二本目を諦めたときに、一本目を取りこぼさず確実に返す。掴みかけて失敗したぶんの、後始末だ。今は、保険として置いておく」。

同じ二鍵で、今度は brewOrdered を回す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
it("取得順を id 昇順に揃えれば、両フローとも完遂する(循環が生まれない)", async () => {
  const water: IdLock = { id: "water", mutex: new Mutex() };
  const fire: IdLock = { id: "fire", mutex: new Mutex() };
  const done: string[] = [];

  // A は [water, fire]、B は [fire, water] と渡す順はバラバラ。だが取得は両方 fire→water に揃う。
  const a = brewOrdered([water, fire], async () => { done.push("A"); });
  const b = brewOrdered([fire, water], async () => { done.push("B"); });

  await Promise.all([a, b]);          // ハングしない=両方とも完遂
  assert.deepEqual([...done].sort(), ["A", "B"]); // 取りこぼし0
});

今度は、両方とも通った。Promise.all は、固まらずに帰ってきた。

なぜ通ったのかを、運に帰してはいけない。釜A も釜B も、acquireInOrder を通る以上、まず必ず火(id の小さいほう)を取りにいく。先に取れた釜が火を握り、もう一方は火の行列で待つ。先の釜は続けて水を取り、錬成し、火と水を返す。返った火を、待っていた釜が取り、続けて水を取り、錬成する。——ここが核心だ。全員が火を先に取るのだから、「片方が水を握って火を待ち、もう片方が火を握って水を待つ」という逆向きの組が、原理的に作れない。にらみ合いの輪は、組もうとしても閉じない。だから両者は、必ず完遂する。これは、たまたまではない。取得順を一つに揃えたことで、循環待機——にらみ合いの最後の一本——を、構造から外した帰結だ。カイナは、水と火の鍵を卓に並べ直し、今度はそれらを同じ順序で手にとる二つの釜の挙動を指でなぞった。規律がもたらす秩序は、にらみ合いそのものが成立する余地を最初から奪い去っていた。

Infographic sequence diagram showing Lock Ordering preventing deadlock by securing keys in ID order

火を握った者が仕事を終えてそれを手放すまで、もう一方は次の鍵に決して手を触れない。この一本道の規律こそが、循環の輪を断ち切り、二つの釜を迷いなく稼働させるための絶対の鍵だった。

順は、型に書けない

「揃えた、と言うだけでは足りん」とカイナは言った。「本当に、渡す順がどうであれ、取る順が一つに揃うのか。確かめる」。

彼女は、鍵取り(acquire)をテスト用の記録ラッパで包んで、どの鍵をどの順に取りにいったかを控えた。本番の鍵を書き換えるのではない。模擬戦で、取得順を覗くための仕掛けだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
it("acquireInOrder:渡す順がどうであれ、必ず id 昇順で取得する(全順序の徹底)", async () => {
  const acquired: string[] = [];
  const makeLock = (id: string): IdLock => {
    const mutex = new Mutex();
    const origAcquire = mutex.acquire.bind(mutex);
    mutex.acquire = async () => { acquired.push(id); return origAcquire(); }; // 取得を記録
    return { id, mutex };
  };
  const water = makeLock("water");
  const fire = makeLock("fire");

  const r1 = await acquireInOrder([water, fire]); r1(); // 渡す順:water, fire
  const r2 = await acquireInOrder([fire, water]); r2(); // 渡す順:逆

  assert.deepEqual(acquired, ["fire", "water", "fire", "water"]); // どちらも fire→water
});

渡す順を逆にしても、取りにいくのは、いつも火、水の順だった。「序列さえ全員が守れば、誰がどんな順で頼んでも、取るのは一つの順だ」とカイナ。「逆向きの組は、現れようがない」。

そして、彼女は術式と向き合ったまま、淡々と続けた。

「面白いのは、この順序の規律を、型は強制してくれないことだ。brewBadbrewOrdered も、型の上では、どちらも通る。『全員が同じ順で取る』という約束は、型に書けない。書けるのは、取り方の作法と、それを全員に守らせる規律だけだ。——だから、お前が直すのは、一つの釜じゃない。釜を動かす、全員の鍵の取り方だ」

この序列が、防げないもの

「これで、もう二度と、石にはならないと——言い切れるのか」私は、最後にそれを確かめたかった。職人の性で、保証の範囲を知りたかった。

カイナは即答せず、鍵を一つ、卓に置いた。

「この序列が縛るのは、お前のギルドの中だけだ。全部の釜、全部の職人が、同じ順で鍵を取る限り、逆向きの組は生まれん。にらみ合いは、構造から消える。それは、確かにやった」。彼女は指を折った。

「だが、その前提が崩れたら——別のギルドや、遠くの誰かが、こちらの順を知らずに鍵を取るなら、順序は強制できん。そういう場では、さっきの刻限と揺らぎ(復旧)に頼るしかない。予防が使えない場所のための、二番目の手だ。——勘違いするな。予防と復旧は、優劣じゃない。手が届く範囲の差だ。鍵を取る全員を自分で握れるなら、順を揃えて根を断つ。握れないなら、検知して諦めて、賢く取り直す」。

「それから、一つ気をつけろ」と彼女は付け足した。「同じ鍵を、一つの釜が、二度握ろうとするな。自分で自分の解放を待って、自分だけで固まる。輪が、一つの結び目に縮んだ形だ。これは、順序づけでは防げん。同じ鍵に、前も後もないからな」。

最後に、彼女は炉のほうへ目をやった。

「それと——その水の鍵。水は、この工房だけのものじゃないな。街の、共同の水源から引いている。皆が一気に汲み上げれば、源が干上がる。それは、鍵を取る順の話じゃない。一定の刻ごとに、滴をためる別の器が要る。——それは、また別の話だ」

防げるものと、防げないもの。その境目がはっきりして、私は今日刻んだ序列が、どこまで効くのかを、ようやく飲み込めた。順序は、万能の手ではない。だが、私のギルドの釜は、もう、向き合って固まらない。

試運転

カイナは、逆順で取る古い術式と、順を揃えた術式の、両方の模擬戦を、まとめて走らせた。固まる側には「永久ハング」を、手懐けた側には「両方とも完遂する」ことを、それぞれ記録として残してある。刻限つきの鍵取りには、後始末を怠ったときの「鍵が残る」と、正しく返したときの「残らない」を、並べて。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ node --test
▶ After: Lock Ordering(acquireInOrder=取得順の全順序化)
  ✔ 取得順を id 昇順に揃えれば、両フローとも完遂する(循環が生まれない)
  ✔ acquireInOrder:渡す順がどうであれ、必ず id 昇順で取得する(全順序の徹底)
▶ Timeout: 検知と復旧(acquireWithTimeout)
  ✔ 二本目がタイムアウトしたとき一本目を解放しないと、鍵が残る(解放漏れ=別の障害)
  ✔ 一本目を finally で確実に解放すれば、タイムアウトしても鍵は残らない
▶ Before: brewBad(逆順取得・循環待機)
  ✔ 逆順に二本取ると、互いに片方を握って相手を待ち、永久に固まる(循環待機の再現)
ℹ tests 5
ℹ pass 5
ℹ fail 0

試運転、合格。にらみ合いの再現も、その封じも、これからはこの模擬戦が見張り続ける。次に誰かが——あるいは私自身が、新しい釜を組んで、つい自分の都合の順で鍵を取らせれば、逆順の組が、檻の中で先に捕まる。

順を、ギルドの掟にする

私は、一つの釜の術式を直すだけでは足りないと気づいていた。カイナが、それを促した。

「一つの釜を直しても、隣の釜が逆の順で取れば、また石になる。順番は、全部の釜で一つに揃えて、初めて効く」

だから私は、鍵の序列——火が先、水が後——を、自分の二釜だけでなく、工房の壁に刻んだ。ギルドの掟として。これから誰が、どんな釜を組もうと、鍵を取るときは、この一つの順に従う。個別の手当てを、皆の規律に変えた。

カイナは、私が壁に刻むのを、振り返りもしなかった。卓に残った鍵を一本ずつ箱へ戻しながら、言った。

「術式の正しさは、一つの釜で測れる。だが、にらみ合いの正しさは、釜の数だけ掛け合わせて、初めて出る。お前が今、壁に刻んだのは——その掛け算の、答えだ」

私は、刻んだ掟の前に立った。二つの釜は、もう向き合って固まらずに、それぞれの順で火を取り、水を取り、静かに回りはじめていた。煙が、まっすぐ上っていく。止まっていた朝が、ようやく動き出した。


📜 カイナの魔獣契約録(Tamer’s Registry)

  • 魔獣名(クラス/パターン名): ガルゴイル(対峙の石像鬼)/ デッドロック・循環待機(Lock Ordering=取得順の全順序化で予防/Timeout=検知と復旧)
  • 危険度(難易度/バグの影響度): ★★★★☆(単体の釜は完璧で無害。二つ以上が逆向きの順で複数の鍵を取り合った刻にだけ、互いの解放を待って永久に固まる。データは壊れない=liveness 障害)
  • 主な生態(アンチパターンの特徴):
    • JS はシングルスレッドゆえ OS スレッドのデッドロックは起きないが、Promise ベースの非同期ロック上で「決して resolve されない約束を互いに待つ」論理的デッドロックが起きる。イベントループは回り続け、CPU 0% のまま処理だけ永久停止する(検出しづらい)
    • フローA が lock1→lock2、フローB が lock2→lock1 の逆順で取り、A が lock1・B が lock2 を保持した刻に循環待機が成立(Coffman の4条件:相互排他・保持と待機・横取り不可・循環待機が揃う)
    • 単体の術式・型はどちらも完璧。固まるのは「二つを動かしたときの取得順」という、型に書かれない実行時の関係
  • 契約のポイント(設計の要点):
    • Lock Ordering(予防・本命): 全リソースに一意な順序(id の昇順)を課し、acquireInOrder で必ずその順に取得する。逆向きの組が構造的に生まれず、循環待機(4条件の1つ)を破る=デッドロックを原理的に不可能にする。途中失敗は reverse でロールバック解放
    • ミューテックス=容量1のセマフォ(前回の N=1 門番)。鍵=一つずつ握る門番。直すのは一つの釜でなく「釜を動かす全員の鍵の取り方」。順序の規律は型では強制できず、全員が守って初めて効く
  • 契約外事項(保証しないこと):
    • Timeout(検知/復旧・代替): 全獲得者を自分で握れない(第三者・分散で順序を強制できない)場合の二番目の手。Promise.race で刻限と競わせるが、(1) 負けた acquire は裏で走り続ける→後から取れたら即解放、(2) 保持中の鍵は finally で確実に解放しないとリーク、(3) 揃って諦め揃って取り直すとライブロック→揺らぎ(Full Jitter)で対称を破る。予防ではなく復旧で、優劣でなく適用条件の差
    • 自己デッドロック(同じ鍵を二度取る=1ノードの循環)は順序づけでは防げない。再入設計か、二度取らない規律が要る
    • 共有水源の枯渇(一気に汲み上げる過負荷)は順序でなく流量の話=別の器(Token Bucket)の領分
  • 現在のステータス: 🟢 取得順を全順序化して循環待機を構造から排除(予防が効く=全獲得者を自分で握れる限り)/順序を強制できない場では Timeout+揺らぎで復旧・共有水源の流量は別の獣として後日
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。