同じ設定ファイルを、GUIアプリとバックグラウンドで動く別プロセスが同時に書き込もうとしたらどうなるか——タイミングが悪ければ、片方の書き込みがもう片方を上書きし、最悪の場合はファイルの内容が壊れる。この「複数プロセスによる同時書き込み」問題への対処として使われるのがファイルロックである。Pythonの標準ライブラリだけでこれをクロスプラットフォームに実装しようとすると、Unix系OSとWindowsでAPIがまったく違うという壁にぶつかる。この記事では、その違いを一から整理する。
補足: ここでいう「ロック」は、同一マシン上で動く複数のプロセス間の排他制御(プロセス間ロック)を指す。1つのプロセス内の複数スレッド間の排他(
threading.Lock)とは別の話である。
なぜ外部ライブラリなしで実装したいか
filelock のような定評あるサードパーティ製パッケージも存在するが、配布形態によっては「依存パッケージを増やしたくない」場面がある。デスクトップアプリをPyInstaller等でパッケージングして配布する場合、依存が1つ増えるごとにビルドの複雑さと配布サイズが積み上がる。標準ライブラリの fcntl(Unix系)と msvcrt(Windows)を使い分ければ、追加の依存なしでプロセス間ロックを実装できる。
Unix系OS: fcntl.flock は「アドバイザリーロック」
Unix系OS(Linux/macOS)で使う fcntl.flock() の最も重要な特性は、これがアドバイザリーロック(advisory lock)であるという点である。OSカーネルはロックの状態を記録するが、ロックを無視して書き込もうとするプロセスを強制的に止めることはしない。ロックが機能するのは、すべての関係者がロック取得の手順を守っている場合のみである。
import fcntl
fd = open('target.lock', 'w')
try:
# LOCK_EX: 排他ロック / LOCK_NB: ノンブロッキング(即座に失敗を返す)
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
# 他のプロセスが既にロック中
pass
LOCK_NB を付けずに flock() を呼ぶと、ロックが取れるまでその場でブロック(待機)する。GUIアプリのメインスレッドでこれを無防備に呼ぶと、画面が固まったように見えてしまうため、多くの実装ではノンブロッキングで試行し、失敗したら少し待って再試行するポーリング方式を取る。
Windows: msvcrt.locking はバイト範囲を明示的にロックする
Windowsには fcntl が存在しない。代わりに使うのが msvcrt.locking() で、ファイルの特定バイト範囲を明示的にロックするAPIである。
import msvcrt
fd = open('target.lock', 'r+b')
try:
# LK_NBLCK: ノンブロッキングでロック取得を試行(1バイト分)
msvcrt.locking(fd.fileno(), msvcrt.LK_NBLCK, 1)
except OSError:
# 他のプロセスが既にロック中
pass
fcntl.flock() がファイル全体を対象にするのに対し、msvcrt.locking() は「ファイル先頭から何バイト分」という粒度で指定する。今回のようにロック専用のサイドカーファイルを使う設計であれば、対象は1バイトで十分であり、複雑なバイト範囲管理は不要になる。
抽象化の核心: sys.platform で分岐する
この2つのAPIを1つのクラスにまとめる際の骨格は単純で、sys.platform の値で処理を分岐させるだけである。
import sys
if sys.platform == 'win32':
import msvcrt
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
インポート文を関数内に置いているのは、Windows環境で import fcntl を実行するとその時点で ModuleNotFoundError になるためである。分岐先でだけインポートすることで、両OS向けのコードを1つのファイルに共存させられる。
設計上の工夫: 本体ファイルを直接ロックしない
素朴に実装すると、書き込み対象のファイル自体(例: sites.json)を開いてロックしたくなる。しかしこの方式には、Windows特有の落とし穴がある。Windowsでは、あるプロセスが排他モードでファイルを開いていると、別プロセスがそのファイルに触れる操作自体(読み取りや別モードでの open)が拒否されることがある。ロックの実装がファイル書き込みの妨げになってしまっては本末転倒である。
この問題を避けるため、本体ファイルとは別に 対象ファイル名.lock という使い捨てのサイドカーファイルを用意し、ロックの対象はそちらだけにするという設計がある。本体ファイル(sites.json)は一切開かず、ロック取得・解放はすべて sites.json.lock の生成・削除で完結させる。この分離により、OSごとのファイルオープン挙動の違いに、本体データのI/Oが巻き込まれなくなる。
O_CREAT | O_EXCL でロック取得を原子的にする
サイドカーファイルの作成自体にも競合状態(race condition)が起こりうる。2つのプロセスが「ファイルが存在するか確認 → なければ作成」という2ステップの処理を別々に行うと、確認と作成の間にもう一方のプロセスが割り込む余地が生まれる。これを避けるのが os.open() の O_CREAT | O_EXCL フラグの組み合わせである。
import os
try:
fd = os.open('target.lock', os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o644)
# ここに到達した時点でファイルは自分が作成した = ロック取得成功
except FileExistsError:
# 既に他プロセスが作成済み = ロック取得失敗
pass
O_EXCL を付けると、「ファイルが存在しなければ作成し、存在すればエラーを返す」という一連の処理がOSカーネル内で単一の原子的操作として実行される。確認と作成の間に他プロセスが割り込む隙が構造的に存在しないため、追加のロックなしに競合状態を防げる。
残された課題: stale lock(放置されたロック)
ロックを取得したプロセスが正常終了せずクラッシュした場合、サイドカーファイルだけが残り続け、以後誰もロックを取得できなくなるという事態が起こりうる。この対策として有効なのが、ロックファイルの更新時刻を見て一定時間(たとえば30分)以上前のものは「stale(放置された)」とみなし、削除してから再取得を試みるという処理である。
import time
mtime = os.path.getmtime('target.lock')
if time.time() - mtime > 1800: # 30分以上前なら stale とみなす
os.remove('target.lock')
# 削除後、再度 O_CREAT | O_EXCL で取得を試みる
この方式は完全ではない。「本当にまだロックを保持しているが、たまたま30分以上動いている正常なプロセス」と「クラッシュして放置されたロック」を区別できないためである。実運用では、対象の処理が通常どのくらいの時間で完了するかを踏まえて、十分に長い閾値を設定することでこのリスクを抑える。
with 文で使えるコンテキストマネージャにまとめる
ここまでの要素(プラットフォーム分岐・原子的なファイル作成・stale判定・タイムアウト付き再試行)を1つのクラスに集約し、__enter__ / __exit__ を実装すれば、呼び出し側は次のように簡潔に使える。
with FileLock('sites.json', timeout=10):
# このブロック内は他プロセスから排他される
write_json('sites.json', data)
# ブロックを抜けると自動的にロック解放
__exit__ の中でロック解放とサイドカーファイルの削除を必ず行うようにしておけば、通常終了だけでなく例外発生時(with ブロック内で例外が起きた場合)にもロックが確実に解放される。これは try/finally を毎回書く手間を省く、コンテキストマネージャの本来の利点である。
まとめ
| 観点 | Unix系OS | Windows |
|---|---|---|
| API | fcntl.flock() |
msvcrt.locking() |
| ロックの性質 | アドバイザリー(強制力なし) | バイト範囲の明示的ロック |
| ノンブロッキング指定 | LOCK_NB フラグ |
LK_NBLCK 定数 |
| ロック単位 | ファイル全体 | 指定バイト範囲 |
fcntlとmsvcrtは設計思想もAPIの形も別物だが、「本体ファイルを直接ロックせずサイドカーファイルを使う」「O_CREAT | O_EXCL で原子的に取得する」という周辺の設計を工夫すれば、プラットフォーム分岐そのものは数行に閉じ込められる。クロスプラットフォームなツールを配布する際に、追加の依存なしでプロセス間ロックが必要になったら、この構成を検討する価値がある。
Windows特有の落とし穴という意味では、ビルド前ゲートの cp932 クラッシュで扱った文字コードの罠も同種のパターンである。Macでの開発中は一切再現せず、Windows環境で初めて表面化するという点は、ファイルロックの実装差と同じ構造の問題と言える。