8件届いて1件しか残らなかった
目標
スタートラインをそろえて競合状態を再現可能な事実にしたうえで、ロストアップデートを排他ロックで防ぎ、check-then-actの隙間をアトミックな作成で閉じ、まるごと書くドキュメントをアトミックなリネームで差し替えます。最後に、ロックを取る前にファイルを空にするミスが何を消してしまうかを、自分で確認します。
なぜ重要なのか
競合状態は、「たまに数字が合わない」として報告されます。1人で実行すると必ず合うので、調査が再現で行き詰まります。ところが、プロセスを先にすべて起動しておき、開始マーカー1つで同時に送り出すと、競合は確率ではなく必ず起きることになります。再現できた瞬間から、それは直せる欠陥です。
防ぐ方法は、症状の形によって違います。読んで書き換える区間はロックで束ね、確認してから使う処理は1回の呼び出しに置き換え、まるごと書くファイルはリネームで差し替えます。この3つを混ぜて使うと、ロックをかけても症状が残ります。
最もよくはまる落とし穴は、ロックの範囲です。open(path, "w")は開いた瞬間にファイルを空にするので、次の行でロックを取っても、もう手遅れです。このミスはロックをかけたコードの中で起きるので、いっそう長く生き残ります。
採点ツールは、あなたの説明を信じません。採点ツールが自前のハーネスで、あなたのワーカーを実際に同時に起動して、最終的な値と勝者の数を直接数えます。プロセス数は実行のたびに変わるので、値を暗記して入れることはできません。
ステップ
- /root/race/gen_race.pyを作成して実行し、/root/race/spawn.py(スタートラインのハーネス)を作ってください。
- /root/race/naive_add.pyでロックなしの更新を同時に送り、/root/race/lost.jsonにロストアップデートを書いてください。
- /root/race/locked_add.pyで同じ処理を排他ロックの中で行い、/root/race/lock.jsonに書いてください。
- /root/race/claim.pyでcheck-then-actの隙間を閉じ、/root/race/claim.jsonに勝者と敗者を書いてください。
- /root/race/safe_write.pyと、/root/race/read_probe.pyで部分書き込みを防ぎ、/root/race/atomic.jsonに書いてください。
- /root/race/append_line.pyで、ロックの前にファイルを空にせず追記して、/root/race/append.jsonに書いてください。
- 2つのバージョンを複数回実行して、/root/race/evidence.jsonに、再現可能な証拠を残してください。
- /root/race/summary.jsonを作り、/root/race/race_report.mdに4つの節で報告してください。
参考
- ハーネスの契約:
python3 /root/race/spawn.py --worker <스크립트> --procs <수> --barrier <파일> -- <워커 인자...>(プレースホルダーは順に、スクリプト、数、ファイル、ワーカーの引数です)は、ワーカーをその数だけ起動し、すべて準備ができたあとでマーカーファイルを作って同時にスタートさせ、procs・exit_codesを含むJSONを出力します。 - ワーカーの契約: すべてのワーカーは
--barrier <파일>(プレースホルダーはファイルです)を受け取ります。待つ前に、準備マーカー<barrier>.<pid>.upを残し、そのあとマーカーファイルができるまで待ってから、仕事を始めます。ハーネスは、その準備マーカーを数えて、全部そろってからスタートさせます。数秒待つ方式だと、負荷が高い日に遅れて起動したプロセスを見逃して、再現が崩れます。 - 更新ワーカー:
--file <카운터 파일> --barrier <파일> [--work-ms <밀리초>](プレースホルダーは順に、カウンターファイル、ファイル、ミリ秒です)。カウンターファイルには整数が1つ入っていて、ワーカーはその値を1つ増やします。デフォルトの作業時間は300ミリ秒です。 - 先取りワーカー:
--dir <디렉터리> --barrier <파일> --owner <이름>(プレースホルダーは順に、ディレクトリ、ファイル、名前です)。勝てば終了コード0で終わり、そのディレクトリのleaseファイルにownerを書きます。負ければ9で終わります。 - 書き込み・読み取り:
python3 /root/race/safe_write.py --file <파일> --rounds <횟수> --entries <개수>(プレースホルダーは順に、ファイル、回数、個数です)とpython3 /root/race/read_probe.py --file <파일> --reads <횟수> [--out <json>](プレースホルダーは順に、ファイル、回数です)は、file・reads・bad_readsを含むJSONを出力します。 - 追記ワーカー:
--file <파일> --barrier <파일> --line <문자열>(プレースホルダーは順に、ファイル、ファイル、文字列です)。すでにあった行はそのままにして、自分の行を1つ追記します。 - ロックはアドバイザリーです。ロックを取らずに開くプログラムは防げないので、同じファイルを触るすべてのコードが、同じ約束を守る必要があります。
- アトミックなリネームは、一時ファイルが同じファイルシステムにあるときだけ成立します。同じディレクトリに作るのが最も簡単です。
- よくあるミスは、ロックの前に
"w"で開くこと、読み取りをロックの外に置くこと、プロセスごとに別のファイルをロックすること、/tmpに一時ファイルを作って別の場所に移すことの4つです。 - 負荷テストを作らないでください。Podは2コアで、採点1回の予算は60秒です。プロセスは8個で十分です。
スタートラインをそろえるハーネスを手に入れる
/root/race/gen_race.pyを作成して実行し、/root/race/spawn.pyを作ってください。このハーネスは、ワーカーを複数起動したあと、マーカーファイル1つで同時にスタートさせます。
競合状態の調査が行き詰まるのは、再現です。手で2つのターミナルを交互に操作しても、同じ瞬間に入ることはあまりありません。このハーネスをそのまま保存して実行してください。ワーカーはまだないので、次のステップで作ります。
8件が1つになるのを見る
/root/race/naive_add.pyを作り(読み、300ミリ秒作業して、1つ増やして書く)、8個のプロセスで同時に実行して、/root/race/lost.jsonにprocs・expected・observed・lost・work_msを書いてください。カウンターファイルは0から始まります。
ワーカーは、待つ前に準備マーカー(<barrier>.<pid>.up)を残し、--barrierファイルができるまで待ってから始める必要があります。読み取りと書き込みの間に--work-msだけ休むと、その間に別のプロセスが同じ値を読んでしまいます。結果に驚かないでください。それがこのステップの目的です。
排他ロックで1つのまとまりにする
/root/race/locked_add.pyを作成して、読み取りから書き込みまでを排他ロックの中に入れ、同じハーネスで8個を実行して、/root/race/lock.jsonにprocs・expected・observed・lost・methodを書いてください。observedは8である必要があります。
fcntl.flockで、ファイルにLOCK_EXをかけます。ロックを取る前に値を読むと何も防げないので、順序に注意してください。また、"w"で開くと、ロックを得る前にファイルが空になります。読み書きする場所なので、"r+"で開きます。
check-then-actの隙間を閉じる
/root/race/claim.pyを作成してリースを先取りさせ(勝てば0、負ければ9)、8個を同時に実行して、/root/race/claim.jsonにprocs・winners・losers・owner_in_lease・methodを書いてください。勝者はちょうど1つである必要があります。
os.path.existsで確認してから作ると、その間に他のプロセスが作ってしまいます。os.openにO_CREATとO_EXCLを一緒に渡せば、確認と作成が一度に起き、負けた側はFileExistsErrorを受け取ります。例外を受け取ることが正常な動作だという点が、この方式の核心です。
書きかけのファイルを読まないようにする
/root/race/safe_write.pyでドキュメントをアトミックに差し替え、/root/race/read_probe.pyで読みながら数えて、/root/race/atomic.jsonにrounds・entries・reads・bad_reads・methodを書いてください。bad_readsは0である必要があり、roundsは50以上、readsは100以上です。
書いている最中に読むとJSONのパースが失敗し、そのエラーは読む側のコードのバグに見えます。同じディレクトリの一時ファイルにすべて書いてos.replaceで移せば、読む側には古いファイルか新しいファイルだけが見えます。一時ファイルを別のファイルシステムに作ると、アトミック性が壊れます。
ロックの前にファイルを空にするミス
/root/race/append_line.pyを作成して、すでにあった行を消さずに自分の行を追記させてください。あらかじめ数行を入れたファイルに対して8個を同時に実行し、/root/race/append.jsonにprocs・lines_before・lines_after・kept_original・duplicate_linesを書いてください。
このステップの落とし穴は、ロックではなく、開き方です。"w"は開いた瞬間にファイルを空にするので、次の行でロックを取っても、もう手遅れです。追記する場所は"a"で開いてからロックを取ってください。lines_afterは、lines_beforeにプロセス数を足した値である必要があります。
再現可能な証拠を残す
ロックなしバージョンとロックありバージョンを、それぞれ3回以上同じハーネスで実行して、/root/race/evidence.jsonにtrials・procs・expected・naive_finals・locked_finalsを書いてください。2つのリストの長さは、trialsと同じである必要があります。
1回の観測は偶然かもしれません。同じハーネスで何回も実行して2つのリストを並べて書けば、ロックがないほうは常に同じ位置にとどまり、ロックがあるほうは常に期待値に届くことが見えます。これが、顧客に見せる証拠です。
何を失い、何で防いだかを報告する
/root/race/summary.jsonにprocs・expected・naive_observed・locked_observed・claim_winners・bad_reads・lines_afterを書き、/root/race/race_report.mdに## 무엇을 잃었나、## 왜 잃었나、## 무엇으로 막았나、## 남은 위험(韓国語の見出しは、順に「何を失ったか」「なぜ失ったか」「何で防いだか」「残るリスク」という意味です)の4つの節で報告してください。
顧客が買うのは、「ロックをかけました」ではなく、「8件のうち7件が消えていて、今は8件がすべて残ります」です。数字をそのまま書いてください。アドバイザリーロックの限界も、残るリスクに書きます。