残り1席の予約を、ふたつの処理が同時に受け付けました。どちらも『残席は1』と確認し、両方が予約を確定すると定員を超えます。個々の読込みや書込みが壊れていなくても、確認と更新の間へ別の処理が入れば、このような不具合が起こります。
この単元でできるようになること
読み・計算・書きの順序を表に起こし、更新が失われる理由を説明します。また、ひとつの変数の操作を原子的にすることと、複数の処理をまとめて守ることの違いを理解し、守るべき条件から同期の範囲を決められるようになります。
1行の更新を複数の段階へ分ける
インターリーブは、複数の実行の手順が入り交じることです。counter += 1という1行でも、概念上は『値を読む』『1を足す』『結果を書く』に分けられます。ソースコードの1行という区切りが、そのままほかの処理から割り込めない境界になるわけではありません。
初期値 counter = 0
A: counterを読む → 0
B: counterを読む → 0
A: 0 + 1を計算し、1を書く
B: 0 + 1を計算し、1を書く
最終値 counter = 1(2回の加算のうち1回が失われた)これは更新消失を説明する抽象的な実行例です。実際の言語処理系では、最適化やメモリモデルの規則も加わります。特にCやC++で通常の変数へ同期なしの競合するアクセスを行うと、未定義動作になり、結果を0・1・2のような単純な一覧だけで予測できません。説明用の手順と、実装言語が保証する振舞いを混同しないことが大切です。
競合状態とデータ競合は同じ範囲ではない
競合状態は、実行のタイミングや順序によって、守りたい結果が変わってしまう問題です。データ競合は、共有メモリへの同期されていない競合アクセスなど、言語のメモリモデルで定める条件に関わります。すべてのメモリアクセスを原子的にしても、予約の確認と確定の間へ別の処理が入るという論理上の競合は残り得ます。
ロックは守りたい一連の操作を囲む
from threading import Lock
lock = Lock()
remaining = 1
def reserve():
global remaining
with lock:
if remaining == 0:
return False
remaining -= 1
return Trueこの例では、同じプロセス内で、同じlockを使ってreserveを呼ぶスレッドを想定します。残席の確認から減算までをロックの内側へ入れるため、ほかの参加者はその途中へ入れません。守る条件は『残席を負にしないこと』です。remainingを書き換える別のコードがロックを使わなければ、この保証は崩れます。
ロックを読み取りだけに掛け、解除してから減算する形では、最初の予約問題を防げません。逆に、通信の待ち時間まで大きなロックの中へ入れると、無関係な仕事まで待たせます。共有する状態を少なくし、不変条件を保つのに必要な範囲を囲むことから始めます。複数プロセスや複数サーバーにまたがる予約には、別の仕組みが必要です。
原子的でも複数の操作は自動でまとまらない
原子性は、ある操作を、途中が見えないひとまとまりとして扱える性質です。原子的な加算なら、読込み・計算・書込みの間で更新が失われる問題を防げます。一方、原子的な読込みの後で条件を判断し、別の原子的な書込みを行っても、二つを合わせた全体の原子性までは得られません。
ひとつのカウンタには原子的な加算が適していても、残高と振込記録を同時に整合させるような処理では、複数の状態をまとめて守る必要があります。同期手段の名前から選ぶより、途中で他者に見えてはいけない状態は何か、処理後に必ず成立すべき条件は何かを書き出すと判断しやすくなります。
理解を確かめる
問1:単一コアなら競合状態は起こらないでしょうか。起こります。処理Aが確認を終えて更新する前に処理Bへ切り替われば、両方が古い状態で判断できます。同時刻に走ることは必要条件ではありません。
問2:残席の読込みと書込みをそれぞれ原子的にしました。二重予約は必ず防げるでしょうか。防げません。確認から確保までを一体として守る必要があります。ロックや条件付き更新など、要求全体に合う仕組みを選びます。
プロセス・スレッド・コンテキストスイッチに戻ると、なぜCPUが切り替わるだけでも手順が入り交じるかを確認できます。