PHPのflockで排他制御するとき、fopenを「w」で開くとロックの前に中身が消える理由
flock() は、複数のPHPプロセスが同じファイルを同時に書き換えないよう、ファイルに排他ロックをかける関数です。ただし fopen() を 'w' で開くとロックを取る前に中身が空になるため、'c' か 'c+' で開くのが安全です。
アクセスカウンターや連番の採番など、「読んで、足して、書き戻す」処理を複数リクエストが同時に通ると、更新が消えることがあります。flock() を入れたのに、たまにファイルが空になる、あるいは値が巻き戻る。そんなハマり方を、私は何度か見てきました。
なぜ読み書きの間にロックが必要なのか
2つのリクエストが同時に「現在値を読む」を実行すると、両方が同じ値を読んでしまい、片方の加算が失われます。ロックは、この「読む→書く」の一連をひとかたまりにするために使います。
カウンターで書くと、次のような形になります。
<?php
function incrementCounter(string $path): int
{
$fp = fopen($path, 'c+'); // 無ければ作る。開くだけでは中身を消さない
if ($fp === false) {
throw new RuntimeException("open failed: $path");
}
try {
if (!flock($fp, LOCK_EX)) {
throw new RuntimeException("lock failed: $path");
}
$count = (int) stream_get_contents($fp);
$count++;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, (string) $count);
fflush($fp);
flock($fp, LOCK_UN);
return $count;
} finally {
fclose($fp);
}
}
これを4プロセスから500回ずつ呼ぶ実験を、PHP 8.3のCLIでやってみたところ、最終値はきちんと2000になりました。ロックを外すと、この数字は保証されません。
ポイントは、ロックを取ってから読み、書き終えて fflush() したあとで解放することです。fflush() を挟まずに解放すると、バッファに残った内容が他のプロセスに見える前にロックだけ手放す形になるので、そこは気をつけたいところです。
fopenを「w」で開くと何が起きるのか
モード 'w' は、開いた時点でファイルを0バイトに切り詰めます。つまり、他のプロセスがロックを持って読み書きしている最中でも、fopen($path, 'w') を実行した瞬間に中身が消えます。flock() はそのあとに呼ばれるので、間に合いません。
<?php
file_put_contents('d.txt', 'hello');
$f1 = fopen('d.txt', 'c');
flock($f1, LOCK_EX); // こちらがロックを持っている
$f2 = fopen('d.txt', 'w'); // ロックは関係なく切り詰められる
clearstatcache();
var_dump(filesize('d.txt')); // int(0)
flock() はアドバイザリロック、つまり「協力するプロセス同士だけで効く約束事」です。ロックを取らずに開いて書く側がいれば、止められません。だからこそ、全員が同じ手順('c' または 'c+' で開いて、flock() して、必要なら ftruncate())を踏む必要があります。
手順をひとまとめにしたいなら、file_put_contents() に LOCK_EX を渡す方法もあります。こちらは、ロックを取ったあとに切り詰めてくれることを実験で確認しました(ロックを持つ別プロセスがいる間、ファイルサイズは5バイトのままで、解放後に書き込まれました)。ただし「読んで足して書く」ような読み取りを含む処理は、この関数だけでは守れません。
ロックが取れないときはどう待つのか
既定の LOCK_EX は、取れるまで待ち続けます。待たされるのが困る場面では、LOCK_NB を足すと、取れなければすぐ false を返します。
<?php
$fp = fopen('lock.txt', 'c');
if (!flock($fp, LOCK_EX | LOCK_NB, $wouldBlock)) {
if ($wouldBlock) {
echo "別のプロセスが実行中です\n"; // ロック競合のとき $wouldBlock は 1
}
exit(1);
}
// ここに排他したい処理。バッチの二重起動防止にも使える
cronで動かすバッチの二重起動防止は、この使い方の定番だと思います。別のプロセスが先に持っているとき、flock() が false を返し、$wouldBlock が 1 になることも、先ほどの環境で確認しました。
ロックは fclose() か、スクリプト終了時のハンドル破棄で解放されます。バッチ全体を守りたいなら、ハンドルを変数に持ち続けて、処理の最後まで閉じないようにしてください。
flockが当てにならない場面はあるのか
ここは断言しにくい部分です。NFSなどのネットワークファイルシステムでは、flock() の動作が環境に依存すると言われています。PHPのマニュアルにも、環境によって動作が異なる旨の注意書きがあります。共有ストレージ越しに複数サーバーから同じファイルを触る設計なら、ファイルロックに頼らず、DBのロックやRedisなどに寄せたほうが無難かもしれません。
単一サーバー内のプロセス同士の競合なら、flock() は十分実用的だと私は考えています。
まとめ
排他制御に使うファイルは、'w' ではなく 'c' か 'c+' で開き、ロックを取ってから読んで書く、が基本の形です。LOCK_NB を足せば二重起動の検知にも使えます。ロックは約束事でしかないので、そのファイルに触る全員が同じ手順を守る、という前提で使うのが一番事故が少ない気がしています。
よくある質問
Q. flock() で取ったロックは自動で解放されますか?
A. fclose() を呼んだとき、またはスクリプト終了などでファイルハンドルが破棄されたときに解放されます。明示的に LOCK_UN で解放することもできます。
Q. 読み取りだけの処理にもロックは必要ですか?
A. 書き込み中の中途半端な内容を読みたくないなら、LOCK_SH(共有ロック)で読むのが一般的です。共有ロック同士は同時に持てますが、排他ロックとは共存できません。
Q. flock() の戻り値は確認したほうがいいですか?
A. はい。失敗すると false を返すので、確認せずに進めると、ロックなしで書き込むことになります。例外にするなど、失敗時の扱いを決めておくと安心です。