コンピューターサイエンス:アルゴリズム / OS / ネットワーク / DB
レースコンディション
残高 10,000 円から 2 人が 3,000 円ずつ引いて、残高が 7,000 円になる
口座から引き落とす処理を考えます。やることは 3 つで、残高を読み、引き算し、書き戻します。1 件ずつ順に処理する限り、何も問題は起きません。
問題は、同じ口座への 2 件のリクエストが、別々のスレッドで同時に走ったときです。
プレーンテキスト
時刻 スレッド A スレッド B 残高
1 残高を読む → 10000 10000
2 残高を読む → 10000 10000
3 10000 - 3000 = 7000 10000
4 10000 - 3000 = 7000 10000
5 7000 を書き戻す 7000
6 7000 を書き戻す 7000合計 6,000 円引いたのに、減ったのは 3,000 円です。例外は飛びません。ログを見ても、2 件とも正常に完了しています。こういう壊れ方を レースコンディション と呼びます。
1 行のコードが、途中で止まる
「読んで、引いて、書き戻す」を 1 行で書けば安全に見えます。
Python
balance -= 3000しかし CPU が実行するのは 1 命令ではありません。RAM から値をレジスタへ読み、レジスタで引き算し、レジスタの値を RAM へ書き戻す、という 3 段階です。OS はこの 3 段階のどこででもスレッドを止め、別のスレッドに CPU を渡せます。上の表と同じことが、たった 1 行の中で起きます。
厄介なのは、止まる場所が毎回違うことです。1 万回に 1 回しか壊れず、手元では再現せず、負荷が上がった本番でだけ数字が合わなくなります。
途中を見せてはいけない区間がある
問題の本質は、分割されると困る区間があるのに、そこが分割されてしまうことです。読んでから書き戻すまでの間、残高は「古い値のまま」という嘘をついています。この嘘を他人に見せてはいけません。このような区間を クリティカルセクション と呼びます。
やるべきことは 1 つです。この区間には、同時に 1 つのスレッドしか入れないようにする。これを相互排除と言います。
手軽な逃げ道と、その限界
整数を 1 つ増やす程度の単純な操作なら、CPU に「読み・変更・書き戻しを分割不可能に実行する」専用命令があります。Java の AtomicInteger、Go の sync/atomic、Rust の AtomicI64 はこれを使っています。
Go
var counter int64
atomic.AddInt64(&counter, 1) // 途中で止まらない速くて確実ですが、守れるのは 1 つの数値だけです。冒頭の引き落としのように「残高を確かめ、減らし、履歴を 1 行足す」といった複数の操作をひとまとまりで守ることはできません。そこで必要になるのが、区間そのものに鍵をかける道具です。