同じ処理が二重に走らないようにしたい。定期実行と手動実行が重なる、複数のタスクが同じ時刻に起きる、といった場面で必要になる。ファイルを 1 つ置いて「これがある間は他の人は入らない」と決めるのが手軽だが、置き方を間違えると 2 人とも「取れた」と思い込む。
Windows 上の 6 つの処理系で実際に測った結果をまとめる。
いちばん自然に思いつくのはこう書く形である。
if (!existsSync(LOCK)) {
writeFileSync(LOCK, '取った'); // ← ここに来るまでに、もう一方も通り抜けている
// 処理を始める
}
確認してから作るまでの間に、もう一方が同じ確認を通り抜ける。どちらも「無かった」と判断して両方が処理を始める。時間の隙間はごく短いが、毎時走る処理が何百回と繰り返せばいずれ当たる。
必要なのは「無ければ作る」を、途中で割り込まれない一続きの操作として OS に任せることである。
「作れたかどうか」で判断する。先に確認しない。作りに行って、失敗したら他の人が握っていると分かる。この順序でしか正しくならない。
UNIX 系では、自分だけの名前で作ってから目的の名前に変える書き方が使われる。
echo $$ > lock.$$ # 自分の pid で作る
mv lock.$$ lock # 目的の名前へ移す
POSIX の rename(2) は、既にあるものを原子的に置き換える。同時に走っても最後の 1 つが残り、それ以外は上書きされて消える……ように見えるが、全員が「成功した」と判断してしまうため、この形は POSIX でもそのままでは排他にならない(ハードリンクの数を数えるなどの一手間が要る)。
そして Windows では処理系によって挙動が割れる。次章の実測が示すとおり、同じ「移動」でも失敗するものと黙って上書きするものがある。
先に first と書いたファイル(またはフォルダ)を置き、後から来た側が同じ名前を取ろうとしたときにどうなるかを測った。
| 見出し | 意味 |
|---|---|
| 後から来た側 | 失敗すれば排他が効いている。成功すると 2 人とも走り出す |
| 先客 | 中身が first のまま残っているか。消えていれば追い出されている |
Bun は Node 互換の API を持つ。同じコードを両方で走らせたところ、結果は完全に一致した。
| 書き方 | 後から来た側 | 先客 | 排他 |
|---|---|---|---|
writeFileSync(p, x, {flag:'wx'}) |
失敗(EEXIST) | 残る | 可 |
openSync(p, 'wx') |
失敗(EEXIST) | 残る | 可 |
linkSync(src, p) |
失敗(EEXIST) | 残る | 可 |
mkdirSync(p) |
失敗(EEXIST) | 残る | 可 |
mkdirSync(p, {recursive:true}) |
成功 | 残る | 不可 |
renameSync(src, p) |
成功 | 消える | 不可 |
API の名前は違うが、結果は Node と同じだった。例外の型が AlreadyExists になる点だけが異なる。
| 書き方 | 後から来た側 | 先客 | 排他 |
|---|---|---|---|
Deno.openSync(p, {createNew:true}) |
失敗(AlreadyExists) | 残る | 可 |
Deno.writeFileSync(p, x, {createNew:true}) |
失敗(AlreadyExists) | 残る | 可 |
Deno.linkSync(src, p) |
失敗(AlreadyExists) | 残る | 可 |
Deno.mkdirSync(p) |
失敗(AlreadyExists) | 残る | 可 |
Deno.mkdirSync(p, {recursive:true}) |
成功 | 残る | 不可 |
Deno.renameSync(src, p) |
成功 | 消える | 不可 |
| 書き方 | 後から来た側 | 先客 | 排他 |
|---|---|---|---|
New-Item -ItemType File |
失敗(IOException) | 残る | 可 |
New-Item -ItemType Directory |
失敗(IOException) | 残る | 可 |
Rename-Item |
失敗(IOException) | 残る | 可 |
Move-Item |
失敗(IOException) | 残る | 可 |
Out-File -NoClobber |
失敗(IOException) | 残る | 可 |
New-Item -ItemType File -Force |
成功 | 中身が消える | 不可 |
New-Item -ItemType Directory -Force |
成功 | 残る | 不可 |
Move-Item -Force |
成功 | 消える | 不可 |
PowerShell は既定で安全側に倒れている。-Force が排他を壊す。
| 書き方 | 後から来た側 | 先客 | 排他 |
|---|---|---|---|
new FileStream(p, FileMode.CreateNew) |
失敗(IOException) | 残る | 可 |
File.Move(src, p) |
失敗(IOException) | 残る | 可 |
Directory.CreateDirectory(p) |
成功 | 残る | 不可 |
new FileStream(p, FileMode.Create) |
成功 | 中身が消える | 不可 |
同じ「フォルダを作る」でも結果が逆になる。PowerShell の New-Item -ItemType Directory は既にあれば失敗するが、Directory.CreateDirectory は既にあっても黙って成功する(何もせずに返る仕様)。File.Move が失敗するのに Directory.CreateDirectory が成功するという食い違いは、名前からは読み取れない。
| 書き方 | 後から来た側 | 先客 | 排他 |
|---|---|---|---|
ren |
失敗(ERRORLEVEL 1) | 残る | 可 |
mkdir |
失敗(ERRORLEVEL 1) | 残る | 可 |
mkdir 親\子 |
成功 | 残る | 不可 |
move |
成功 | 消える | 不可 |
move /Y |
成功 | 消える | 不可 |
copy |
成功 | 消える | 不可 |
echo x > file |
成功 | 消える | 不可 |
cmd の move は /Y を付けなくても上書きする。対話的に叩けば確認を求められるが、バッチファイルの中では確認なしに実行される。/Y の有無で結果は変わらなかった。同じ「移動」でも ren は失敗し、move は上書きする。
いずれも「作りに行って、失敗したら諦める」形になっている。先に確認しない。
import { writeFileSync, rmSync } from 'node:fs';
/** 印を置けたら true、既にあれば false */
function acquire(lockPath, owner) {
try {
// wx は O_CREAT | O_EXCL。「無ければ作る」を OS が一続きで行う
writeFileSync(lockPath, owner, { flag: 'wx' });
return true;
} catch (err) {
if (err.code === 'EEXIST') return false;
throw err; // 権限が無い等は別の話なので、握りつぶさない
}
}
function release(lockPath) {
rmSync(lockPath, { force: true });
}
function acquire(lockPath, owner) {
try {
Deno.writeFileSync(lockPath, new TextEncoder().encode(owner), { createNew: true });
return true;
} catch (err) {
if (err instanceof Deno.errors.AlreadyExists) return false;
throw err;
}
}
function Get-Lock {
param([string] $LockPath, [string] $Owner)
try {
# -Force を付けないこと。付けると既にある印を上書きしてしまう
New-Item -ItemType File -Path $LockPath -Value $Owner -ErrorAction Stop | Out-Null
return $true
} catch [System.IO.IOException] {
return $false
}
}
より確実にしたい場合は .NET を直接呼ぶ。FileMode.CreateNew は「既にあれば例外」が仕様として明記されている。
try {
$fs = New-Object System.IO.FileStream($LockPath, [System.IO.FileMode]::CreateNew)
$fs.Close()
# 取れた
} catch [System.IO.IOException] {
# 取れなかった
}
フォルダを印にする。mkdir は既にあれば ERRORLEVEL 1 を返す。
mkdir "%LOCK_DIR%" 2>nul
if errorlevel 1 (
echo 別の処理が動いています
exit /b 1
)
rem ここから本処理
rmdir "%LOCK_DIR%"
親を含む階層をまとめて作らないこと。mkdir 親\子 の形にすると、既にあっても成功してしまう。印にするフォルダは 1 階層だけ作る。
| 書き方 | なぜだめか |
|---|---|
renameSync(Node / Deno) |
既にある印を黙って上書きする。2 つとも「取れた」と思い込む |
move(cmd) |
同上。バッチの中では確認も出ない |
copy・リダイレクト(cmd) |
同上。中身を丸ごと置き換える |
-Force 付き(PowerShell) |
-Force は「あっても構わず実行する」という指定。排他とは逆の意味になる |
recursive: true(mkdir) |
「途中の階層が無ければ作る」という指定であり、既にある場合もエラーにしない |
Directory.CreateDirectory(.NET) |
既にあっても何もせず成功する仕様。例外は投げない |
FileMode.Create(.NET) |
既にあれば中身を空にして開く。CreateNew と 1 語違いなので取り違えやすい |
迷ったら「既にあったらどうなるか」が名前や引数に書かれているかを見る。
| 語 | 意味するところ | 排他 |
|---|---|---|
wx / CreateNew / createNew / NoClobber |
新しく作るときだけ成功する | 可 |
Force / overwrite / /Y |
あっても構わず実行する | 不可 |
recursive / -p |
足りないものを補う。あるものは黙って受け入れる | 不可 |
| 何も書かれていない | 処理系ごとに違う。測るまで分からない | 要確認 |
取れるようになっただけでは足りない。置いたまま終わった印をどうするかを決めておかないと、いずれ全部が止まる。
処理の最後で必ず消すよう書いても、強制終了させられれば残る。残ったままだと、以降どの処理も永久に取れない。誰も気づかないまま止まり続ける。
印に開始時刻を書いておき、一定より古ければ残骸とみなして奪う。しきい値は処理にかかる最大時間より十分長く取る。タスクに実行時間の上限を設けているなら、その数倍が目安になる。
/** 印が効いているか。古すぎるものは残骸とみなす */
function isLockActive(file, staleMs) {
if (!existsSync(file)) return false;
if (staleMs <= 0) return true; // 人が置いた印は奪わない
return Date.now() - statSync(file).mtimeMs < staleMs;
}
空でも排他は働くが、誰が握っているのか分からないと、消してよいか判断できない。次の 3 つを書いておく。
| 項目 | 使いどころ |
|---|---|
| 誰が | どの処理が握っているか。複数の種類が同じ印を使う場合に要る |
| いつから | 残骸かどうかの判断。ファイルの更新日時でも代用できるが、目で見て分かる方がよい |
| プロセス番号 | 本当にまだ動いているかを確かめられる |
hourly が 2026/08/30 10:03:12.481 に開始しました(pid 41244)
取れなかったときの振る舞いを決める。定期実行なら諦めてよいことが多い。次の回に取り直せるためである。
| 場面 | 振る舞い |
|---|---|
| 定期実行 | 少し待って、取れなければ諦める。失敗として扱わない。次の回がある |
| 人が起動した処理 | 誰が握っているかを見せて止まる。黙って諦めると、動いたのかどうか分からない |
印はぶつからないための工夫であって、ぶつかっても壊れない保証ではない。作業用のフォルダを処理ごとに分ける、出力先の名前を重ならないようにするなど、万一同時に走っても壊れない作りを併せて持たせる。
排他が要らない作りにできるなら、その方がよい。出力先が最初から別々なら、同時に走っても困らない。印が必要になるのは、共有している何かがある場合に限られる。