file_exists() で「その名前はまだ無い」と確かめてから書き込む方法は、同時に2つ以上の処理が走ると、両方が同じ名前を選びます。fopen($path, 'x') で名前を取る方法に変えると、重なりませんでした。PHP 8.4 で、同時に起動したプロセスに同じ秒の名前を作らせて数えた結果です。
| 方法 | 同時に走らせた数 | 試行 | 2つ以上が同じ名前を使った試行 |
|---|---|---|---|
file_exists() で確かめてから書く |
2 | 100 | 98 |
| 〃(確かめてから書くまでに 50ms 空ける) | 2 | 100 | 86 |
| 〃 | 8 | 70 | 70 |
fopen($path, 'x') で取ってから書く |
2 | 400 | 0 |
| 〃 | 8 | 350 | 2(下に注記) |
| 〃 | 16 | 150 | 0 |
(fopen の8並列は、最初の50回で2回重なり、そのあとの300回は0回でした。最初の2回の原因は特定できていません。詳しくは後半に書きます)
先に断っておくと、これは PHP マニュアルに書いてあるとおりの話です。fopen() の x モードは「Create and open for writing only; ... If the file already exists, the fopen() call will fail by returning false」で、「This is equivalent to specifying O_EXCL|O_CREAT flags for the underlying open(2) system call.」とあります。確かめる操作と作る操作を、OS が1回でやってくれる、ということです。ここで書くのは、確かめてから作る書き方を使っていた自分のプラグインで何が起きたかと、それを実際に数えた結果です。
何が起きたか
自作の WordPress プラグイン Rapls PDF Image Creator は、PDF のサムネイルを作り直すとき、ファイル名に時刻を足しています。
$outputFilename = $pdfBasename . '-pdf-thumbnail-' . dechex(time()) . '.' . $extension;
同じ名前で上書きすると、ブラウザや CDN のキャッシュに古い画像が残るからです。time() は秒単位なので、同じ PDF の再生成が同じ秒に2つ走ると、2つとも同じ名前を思いつきます。
そのうえで、名前が空いているかを file_exists() で確かめていました。
// Ensure unique filename
$counter = 1;
$stem = pathinfo($outputFilename, PATHINFO_FILENAME);
while (file_exists($outputPath)) {
$outputFilename = $stem . '-' . $counter . '.' . $extension;
$outputPath = $pdfDir . '/' . $outputFilename;
$counter++;
}
// このあと PDF を描画して、$outputPath に書き出す
2つの処理がほぼ同時に確かめると、どちらにも「まだ無い」と見えます。描画して書き出すのはそのあとなので、2つとも同じファイルに書き、メディアライブラリに2回登録します。あとでどちらかを削除すると、もう片方の画像も消えます。
測った方法
同じディレクトリに、指定した時刻ちょうどにプロセスを何個か同時に起動して、それぞれに名前を1つ取らせます。名前は doc-pdf-thumbnail-<time() の16進>.png で、取れなければ -1, -2 と番号を足します。全員が出力した名前を並べて、重複があればその試行を「重なった」と数えました。
<?php
// php worker.php <dir> <mode:exists|x> <gap_ms> <start_at>
[$_, $dir, $mode, $gapMs, $startAt] = $argv;
while (microtime(true) < (float)$startAt) { usleep(100); } // 全員そろって始める
$stem = 'doc-pdf-thumbnail-' . dechex(time());
$path = "$dir/$stem.png"; $n = 1;
if ($mode === 'exists') {
while (file_exists($path)) { $path = "$dir/$stem-$n.png"; $n++; }
usleep((int)$gapMs * 1000); // 描画にかかる時間のかわり
file_put_contents($path, getmypid());
} else {
while (true) {
$h = @fopen($path, 'x');
if ($h !== false) { fclose($h); break; }
if (!file_exists($path)) { exit(2); } // 存在しないのに作れない=書けない場所
$path = "$dir/$stem-$n.png"; $n++;
}
usleep((int)$gapMs * 1000);
file_put_contents($path, getmypid());
}
echo basename($path), "\n";
PHP 8.4.21(CLI)/ Ubuntu 24.04 / ext4
file_exists() のほうは、ほぼ毎回重なる
2並列で100回中98回、8並列では70回すべてで、2つ以上のプロセスが同じ名前を使いました。全員が同じ瞬間に始めているので、全員が「まだ無い」を見てから書きにいきます。
確かめてから書くまでに 50ms 空けても、86回でした。空けた時間は確かめた後なので、重なる理由は変わりません。
この測り方は、プロセスの起動をそろえているので、実際より重なりやすくしています。実際のサイトで2つの再生成がまったく同じ瞬間に始まることは、まれです。ただ、実際の描画には数秒かかるので、確かめてから書き終わるまでの時間は、この実験の 50ms よりずっと長くなります。そのあいだにもう1つの処理が確かめれば、同じことが起きます。
fopen($path, 'x') のほうは、重ならなかった
x モードは、ファイルが無いときだけ作って開きます。作れた側がその名前の持ち主で、作れなかった側は番号を足して次を試します。確かめるのと作るのが1回の操作なので、2つが同時に同じ名前を取ることはありません。
2並列で400回、16並列で150回、どちらも0回でした。
8並列は350回中2回です。この2回は、最初に回した50回の中で出ました。そのあと、重なったときに名前とファイルの数を記録するように直して300回回したところ、0回でした。最初の2回で何が起きていたのかは、記録が残っていないので分かりません。測定の仕組みの側の問題なのか、本当に重なったのかを、今の手元のデータでは区別できません。
実装で気をつけたこと
1.4.14 で、こう直しました。
$counter = 1;
$stem = pathinfo($outputFilename, PATHINFO_FILENAME);
$reserved = false;
while (true) {
$handle = @fopen($outputPath, 'x');
if (false !== $handle) {
fclose($handle);
$reserved = true;
break;
}
if (!file_exists($outputPath)) {
break;
}
$outputFilename = $stem . '-' . $counter . '.' . $extension;
$outputPath = $pdfDir . '/' . $outputFilename;
$counter++;
}
fopen が失敗した理由は2通りあります。すでにファイルがあるか、そもそもそのディレクトリに書けないかです。後者で番号を足し続けると、ループが終わりません。file_exists() で「無いのに作れなかった」を見分けて、そこで抜けるようにしています。
取った名前のファイルは、空のまま残ります。描画に失敗したら、その空ファイルを消します。消さないと、使われない名前が1つずつ増えていきます。
なお、Linux の open(2) の man ページには、NFS で O_EXCL が使えるのは NFSv3 以降(カーネル 2.6 以降)で、使えない環境では競合が残る、と書かれています。ここで測ったのはローカルの ext4 だけで、NFS では試していません。
あなたのコードで、file_exists() のすぐ後に書き込んでいる場所はありませんか。
手元で試すコマンド
上の worker.php と同じディレクトリに置いて、./run.sh exists 2 0 100 のように動かします。引数は、方法、同時に走らせる数、確かめてから書くまでの ms、試行回数です。
#!/bin/bash
mode=$1; procs=$2; gap=$3; trials=$4; coll=0
for t in $(seq "$trials"); do
d=$(mktemp -d)
start=$(php -r 'printf("%.6f", microtime(true)+0.3);')
for p in $(seq "$procs"); do
php worker.php "$d" "$mode" "$gap" "$start" >> "$d.out" &
done
wait
dl=$(sort "$d.out" | uniq -d)
[ -n "$dl" ] && { coll=$((coll+1)); echo " dup: $dl / files=$(ls "$d" | wc -l)"; }
rm -rf "$d" "$d.out"
done
echo "mode=$mode procs=$procs gap=${gap}ms trials=$trials 重なった試行=$coll"
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。
https://raplsworks.com/