【Python】共有変数の更新がずれる原因は?Lockで競合を防ぐ

PythonのTopに戻る

結論:読み取りから更新までを同じLockで保護する

複数スレッドが同じ値を更新するなら、読む・計算する・書き戻すという一まとまりをLockで保護する。書き込みだけを囲んでも、古い値を同時に読んでしまえば更新が失われることがある。すべての更新経路が同じロックを使うことが重要で、関数を呼ぶたびに新しいLockを作っても互いの処理は制御できない。

GILがあるから複数手順の状態変更も自動的に安全、と考えてはいけない。ある式が特定のCPython版で偶然問題を起こしにくく見えても、それはプログラムの同期契約とは別である。共有する状態と守るべき条件を先に決め、複数の操作を一つの不可分な処理として扱いたい範囲へ明示的な同期を入れよう。

そのまま動かせる例

前半は2スレッドが同じ古い値を読んでから書く状況をBarrierで意図的に作る。自然発生する競合を何百万回も待つのではなく、失われる更新を短い例で確実に観察するためである。出力が1になるのは想定した失敗であり、この書き方を実処理に使うことは勧めない。Barrierにも時間切れを設定している。

後半では同じLockを共有し、各スレッドが1000回ずつ加算する。標準ライブラリだけを用い、最大2スレッド、有限回数の処理で終わる。Futureの結果も回収するため、スレッド内の失敗を見落とさない。ロックあり・なしの処理時間は比較しておらず、同期によって期待値を得られることに焦点を当てている。

from threading import Barrier, Lock
from concurrent.futures import ThreadPoolExecutor

unsafe = {"count": 0}
barrier = Barrier(2, timeout=2)
def lost_update():
    old = unsafe["count"]
    barrier.wait()  # Force both readers to observe the same old value.
    unsafe["count"] = old + 1

with ThreadPoolExecutor(max_workers=2) as pool:
    futures = [pool.submit(lost_update) for _ in range(2)]
    for future in futures:
        future.result(timeout=3)
assert unsafe["count"] == 1

safe = {"count": 0}
lock = Lock()
def add_many():
    for _ in range(1000):
        with lock:
            safe["count"] += 1
with ThreadPoolExecutor(max_workers=2) as pool:
    futures = [pool.submit(add_many) for _ in range(2)]
    for future in futures:
        future.result(timeout=3)
assert safe["count"] == 2000
print("forced lost update:", unsafe["count"], "(expected total: 2)")
print("protected total:", safe["count"], "(expected total: 2000)")

実行結果

forced lost update: 1 (expected total: 2)
protected total: 2000 (expected total: 2000)

なぜ2回加えても1になるのか

前半では両方のスレッドがcountの0をoldへ読み込む。その後Barrierで足並みをそろえると、両方がold + 1、つまり1を書き戻す。後の書き込みが前の更新を上書きし、合計2になるはずの値が1になる。各代入だけを見ると正しくても、読み取りと書き戻しの間に別の処理が入ると全体の意味が変わる例である。

後半のwith lockでは、あるスレッドの更新が終わるまで別のスレッドが同じ保護範囲へ入れない。結果は2000となる。withを使うと保護範囲で例外が起きてもロックが解放されるため、acquireとreleaseを別々に書いて解放を忘れる危険を減らせる。ただし複数のロックを取る順番の問題まで自動的に解決するわけではない。

保護するのは一つの変数だけとは限らない

在庫数と予約数、合計と件数など、複数の値が一つの条件を満たす必要がある場合は、それらをまとめて保護する。更新側がロックを使っていても、読取側が途中の状態を読めば整合しない組を見てしまうことがある。一貫したスナップショットが必要な読取処理も、同じロックの範囲で値を取り出すようにする。

ロックの範囲は必要なだけに絞る。通信や長いファイル読込、時間のかかる計算まで囲むと、他のスレッドが待ち続けて並行性を失う。計算できる部分は外で準備し、共有状態の確認と更新だけを短く保護する。ただし外へ出すことで確認時点が古くならないかを考える必要がある。速さのために整合性を壊しては意味がない。

デッドロックと共有状態を減らす設計

別のロックを逆順に取得する二つの経路があると、互いに相手の解放を待ち続けるデッドロックになり得る。ロック順序を統一し、ロックを持ったまま結果を待ったり未知のコールバックを呼んだりしない設計を検討しよう。通常のLockを同じスレッドが二重に取得する使い方にも注意が必要である。RLockへ変えるだけで全ての設計問題が消えるわけではない。

そもそも各ワーカーが独立した結果を返し、呼び出し元の一か所で集計できるなら、共有カウンターを持たない方が単純である。Queueによる受け渡しも選択肢となる。共有状態が必要な部分だけを明確にし、想定する競合を小さな例で再現してから修正を確認しよう。たまたま数回正しく動いたことを、スレッド安全性の証明として扱わないことが大切である。

確認環境と参考資料

例はLinux・CPython 3.12.14で実行した。掲載した出力はこの環境での結果である。公式資料のstable版や最新版は更新されるため、手元のバージョンと対応する仕様も確認してほしい。

関連項目:スレッドとプロセスはどう選ぶ?待ち時間と計算負荷で考える / ThreadPoolExecutorで結果と失敗を取りこぼさず回収する / queue.Queueで作業待ちをためすぎない生産者・消費者処理

PythonのTopに戻る