TT Lab
はじめる
学ぶ 学習パス コース

デバッグ実戦

数が合わないのは順序のせいだ

TT Labで続きを見る

一言でいうと

競合状態は「たまに起きること」ではなく、スタートラインをそろえれば必ず起きることであり、再現できるようになった瞬間に、直せる欠陥になります。

なぜ必要なのか

「昨日の精算が3件足りませんでした。」ログにエラーはありません。コードを読むと問題ありません。1回実行してみると合っています。このような報告の多くが競合状態で、共通点が1つあります。1人で実行すると、必ず合っているのです。

最もよくある形は、ロストアップデート(lost update)です。プロセスが値を読み、その値で何かをして、1つ増やして書きます。その間に別のプロセスが同じ値を読んでいたなら、あとから書いたほうが前の結果を上書きします。2件入ってきたのに、1件しか残りません。10件が完全に重なって入ってくると、9件が消えます。

ここで調査が行き詰まる理由は、たいてい再現です。2つのプロセスがちょうど同じ瞬間にその区間に入る必要があるのに、手で2つのターミナルを交互に操作しても、そのようなことはあまり起きません。そこで、スタートラインを作ります。プロセスを先にすべて起動しておき、全部が待機状態になったあとで開始マーカーを1つ作れば、その瞬間から、競合は確率ではなく再現可能な事実になります。

どう動くのか

防ぐ方法は、問題の形によって違います。3つを区別すれば、大半が整理できます。

1. 読んで書き換える区間は、ロックで束ねる。読み取りと書き込みを、1つのまとまりにする必要があります。Pythonでは、fcntlモジュールのflockで、ファイルに排他ロックをかけます。このロックはアドバイザリー(advisory)です。ロックを取らずにそのまま開くプログラムは、何の妨げもなく書き込めます。そのため、同じファイルを触るすべてのプログラムが、同じ約束を守る必要があります。

ここに、非常によくはまる落とし穴があります。open(path, "w")は開いた瞬間にファイルを空にします。次の行でロックを取っても、もう手遅れです。読み書きする場所は"r+"、追記する場所は"a"で開いてからロックを取ります。

2. 確認してから行う処理は、確認と実行を一度に行う。「ファイルがなければ作る」「リースが空いていれば取る」には、間に隙間があります。この隙間をなくす方法は、作成そのものを判定に使うことです。POSIXのopenは、O_CREATとO_EXCLを一緒に渡すと、ファイルがすでにあるときに失敗し、その確認と作成がアトミックだと規定しています。Pythonではos.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)で、失敗はFileExistsErrorとして届きます。負けた側が例外を受け取るのが、正常な動作です。

3. まるごと書き直すファイルは、アトミックに差し替える。設定ファイルや状態のスナップショットを書いている間に、別の側が読むと、書きかけの内容を見ます。JSONならパースエラーになり、そのエラーは読む側のコードのバグに見えます。解決策は、同じディレクトリの一時ファイルにすべて書いてから、名前を変えることです。POSIXのrenameは、新しい名前がすでにあっても、その置き換えがアトミックで、その間のどの瞬間にも名前が消えないと定めています。Pythonではos.replaceです。一時ファイルが同じファイルシステムになければならないという条件を忘れると、黙ってコピーになり、アトミック性が壊れます。

증상                         틈이 있는 자리            막는 방법
합계가 모자란다              읽기와 쓰기 사이          배타 잠금으로 한 덩어리
둘 다 자기가 주인이라 한다   확인과 생성 사이          O_CREAT 와 O_EXCL
읽는 쪽이 파싱에 실패한다    쓰는 도중의 파일          임시 파일에 쓰고 rename
파일이 비어 있다             잠금 전에 "w" 로 열기     "r+" 또는 "a" 로 열기

現場での姿

1つ目は、再現をあきらめてコードをにらむことです。目で探す競合状態は見落としやすく、見つけたと思っても、それがその症状の原因かどうかはわかりません。スタートラインを作って症状を手に入れてから直せば、直したことも同じ方法で証明できます。

2つ目は、ロックをかけたのに変わらないことです。よくある原因の2つは、ロックの範囲と対象です。読み取りはロックの外で行い、書き込みだけをロックの中で行うと、何も防げません。別々のファイルをロックしていたり、プロセスごとに違う一時ファイルをロックしていたりする場合もあります。

3つ目は、片方だけが礼儀を守ることです。アドバイザリーロックは、全員が守って初めて意味があります。運用スクリプトの1つが>で上書きすれば、その日のロックはすべて無意味になります。そのため、ロックの規約は、コードではなくドキュメントとレビューで守られます。

4つ目は、コアが1つなら起きないと思い込むことです。プロセスはいつでもプリエンプションされうるので、コア数に関係なく、読み取りと書き込みの間に別のプロセスが割り込めます。実際に、2コアのPodでも、8個のプロセスを同じ瞬間にスタートさせれば、更新は1つしか残りません。

実務で本当に大切なこと

次のラボですること

スタートラインをそろえるハーネスを受け取って、ロックなしの更新8件が1つしか残らないことを、目で確認します。同じことを排他ロックの中で行って、すべて残ることを確かめ、check-then-actの隙間をO_EXCLで閉じて、勝者が必ず1つになるようにします。まるごと書くドキュメントをアトミックに差し替えて、読む側のパースエラーを0にし、ロックの前にファイルを空にするミスがデータをどのように消すかを確認したうえで、証拠を1枚で報告します。