隙間は命令の間、await、そして文の間にある
一言でいうと
競合状態は、「スレッドが複数あるとき」に生じるのではなく、「読んだ値と書く値の間に、他者が割り込める隙間があるとき」に生じます。スレッドではその隙間がバイトコード命令の間に、asyncioではawaitごとに、データベースではSELECTとUPDATEの間にあります。隙間をなくすか(アトミックな操作)、隙間全体を一度に1つの処理だけが通るように(ロック)する必要があります。
なぜ必要なのか
「オペレーティングシステム」コースの並行性モジュールは、4つのスレッドが同じ値を増やして更新を失う場面と、デッドロックを自分で作ります。そのラボを終えた人でも、サービスのコードでは、3つのことをよく勘違いします。「PythonにはGILがあるので、x += 1は安全です」「asyncioはスレッドが1つだけなので、競合はありません」「データベースが自動的に防いでくれます」。3つとも間違っていて、このモジュールは3つを数字で確認します。
どう動くのか
GILが保証するものと、保証しないもの: 用語集は、GILを、CPythonが一度に1つのスレッドだけにバイトコードを実行させる仕組みと定義しています。ライブラリFAQは、Pythonがスレッドを切り替えるのは、たいていバイトコード命令の間だけなので、命令1つはアトミックだと説明し、L.append(x)はアトミックだが、i = i+1やD[x] = D[x] + 1はそうではないと記しています。disでcounter += 1を展開すると、このラボのイメージ(3.12)では、LOAD_GLOBAL・LOAD_CONST・BINARY_OP・STORE_GLOBALの4つの命令が出てきます。読み取りと書き込みが別の命令なので、その間でスレッドが切り替わると、他者が増やした値が上書きされます。どれくらいの頻度で切り替わるかはsys.setswitchintervalが決めますが、ドキュメントは、その値は「理想的な」タイムスライスにすぎず、どのスレッドが引き継ぐかはOSが決めると記しています。そのため、競合は、テストではなかなか見えず、負荷が上がったときに現れます。ラボでは、読み取りと書き込みの間にtime.sleep(0)を挟んで、隙間をわざと広げます。バグを作っているのではなく、もともとあったバグを毎回見えるようにする仕掛けです。
ロックは隙間全体を包む必要があります: threadingのLockは、with文に入るときにacquire、出るときにreleaseされます。よくある中途半端な修正は、書き込みだけをロックで包むことです。読んだ値はすでに古いので、書き込みを順に並べても、古い値が順番に記録されるだけです。座席予約のように「空いているか確認 → 予約」する、確認してから行動する(check-then-act)パターンも同じで、確認だけをロックで包むと、2つのリクエストが同時に「空いている」という答えを持って出てきます。
asyncioではawaitが隙間です: イベントループは1つのスレッドで動き、タスクはawaitでだけ制御を譲ります。awaitのない区間は事実上アトミックで、awaitが挟まる区間はそうではありません。残高を確認し → 記録のためにawaitし → 差し引くと、そのawaitの間に、同じ口座の別の出金が、同じ残高を確認します。asyncioの同期プリミティブのドキュメントは、asyncio.Lockをasync withで使うよう勧め、取得は公平で、先に待ったコルーチンが先に入ると記しています。同じドキュメントは、これらのツールはスレッドセーフではないので、OSスレッドの同期にはthreadingを使うよう、明記しています。逆に、コルーチンの中でthreading.Lockを握ったままawaitすると、次のタスクがそのロックを待ってループのスレッド自体を止めてしまいます。
ループを塞ぐ呼び出し: asyncioの開発ガイドは、CPUを1秒使う関数を直接呼ぶと、すべてのタスクとIOが1秒ずつ遅れると説明し、デバッグモードでは100msを超えたコールバックをログに残すと記しています。time.sleepや同期ドライバーの呼び出しも、同じようにループを止めます。ラボでは、10msごとに起きるタスクが、予定よりどれだけ遅れて起きたかを「ループ遅延」として測ります。asyncio.to_threadは、関数を別のスレッドで実行してループを空けてくれますが、ドキュメントが述べるとおり、GILのため、通常はIO中心の関数にしか効果がありません。
データベースも、文と文の間が隙間です: SQLiteのトランザクションのドキュメントは、読み取りトランザクションの最中に書き込みが来ると、可能なときだけ書き込みトランザクションに昇格させ、別の接続がすでに変更した、または変更中なら、SQLITE_BUSYで失敗すると記しています(ロック段階)。逆に、トランザクションなしでSELECTで読み、計算した値をUPDATEで書くと、エラーなしで更新を失います。Pythonのsqlite3モジュールは、デフォルトの設定では、INSERT・UPDATE・DELETE・REPLACEの前でだけトランザクションを暗黙的に開くため、SELECTは保護されません。UPDATE t SET n = n + 1は、読み取りと書き込みを1つの文に入れて、隙間をなくします。
| 場所 | 隙間 | 防ぐ方法 |
|---|---|---|
| スレッド | バイトコード命令の間 | threading.Lockで、読み取りから書き込みまで全体を包みます |
| asyncio | await | asyncio.Lockで、確認から差し引きまで全体を包みます |
| DB | 文と文の間 | アトミックなUPDATE、または失敗を表に出すトランザクション + リトライ |
GILと速度: threadingのドキュメントは、CPythonでは一度に1つのスレッドだけがPythonコードを実行するので、複数のコアを使うにはmultiprocessingやProcessPoolExecutorを使い、複数のIO作業を同時に動かすには、スレッドが今でも適していると記しています。用語集は、IOの間はGILが常に解放されると述べています。3.13から、GILを無効にしたfree-threadedビルドを作れますが、標準のビルドではなく、その案内も、組み込み型の内部ロックに頼らず、threading.Lockを使うよう勧めています。GILがあってもなくても、読み取り・変更・書き込みの隙間は、コードで防ぐ必要があるということです。
現場での姿
Nodeも構造は同じです。「遅くなったのは一つではなく全部だった」コースが、イベントループの遅延を観測・アラートまで扱うので、このモジュールのループ遅延の測定は、そのPython版です。行ロックとSELECT FOR UPDATE、デッドロックは「並行性 — 二人が同じ行を触るとき」コースが、スレッドを増やしてもCPU作業が速くならないことをプロファイラーで見ることは「CPU・メモリリークの見極め」コースが扱います。このモジュールは、その間にあって、隙間がどこにあるかを測る場所です。
次のラボですること
counter += 1をバイトコードに展開して見て、スレッドで更新を失ったあと、ロックで防ぎます。座席の二重予約と、awaitの間の過剰な出金を作って直したあと、ブロッキング呼び出しがループをどれだけ止めるかを測り、to_threadへ移します。SQLiteの2つの接続で、静かな損失と、騒がしい失敗を比較し、最後に、GILの下でのスレッドとプロセスの速度を測ります。