ファイルで排他する

Windows で「1 つだけ成功する」を作る方法と、成り立たない書き方

📅 作成: 2026-08-30 / 更新: 2026-09-04

同じ処理が二重に走らないようにしたい。定期実行と手動実行が重なる、複数のタスクが同じ時刻に起きる、といった場面で必要になる。ファイルを 1 つ置いて「これがある間は他の人は入らない」と決めるのが手軽だが、置き方を間違えると 2 人とも「取れた」と思い込む

Windows 上の 6 つの処理系で実際に測った結果をまとめる。

目次

  1. 何が問題か
  2. 実測した結果
  3. 使ってよい書き方
  4. 使ってはいけない書き方
  5. 印を運用する

何が問題か

確認してから作る、では間に合わない

いちばん自然に思いつくのはこう書く形である。

if (!existsSync(LOCK)) {
	writeFileSync(LOCK, '取った');   // ← ここに来るまでに、もう一方も通り抜けている
	// 処理を始める
}

確認してから作るまでの間に、もう一方が同じ確認を通り抜ける。どちらも「無かった」と判断して両方が処理を始める。時間の隙間はごく短いが、毎時走る処理が何百回と繰り返せばいずれ当たる。

必要なのは「無ければ作る」を、途中で割り込まれない一続きの操作として OS に任せることである。

「作れたかどうか」で判断する。先に確認しない。作りに行って、失敗したら他の人が握っていると分かる。この順序でしか正しくならない。

名前を変える方式は移植できない

UNIX 系では、自分だけの名前で作ってから目的の名前に変える書き方が使われる。

echo $$ > lock.$$        # 自分の pid で作る
mv lock.$$ lock          # 目的の名前へ移す

POSIX の rename(2) は、既にあるものを原子的に置き換える。同時に走っても最後の 1 つが残り、それ以外は上書きされて消える……ように見えるが、全員が「成功した」と判断してしまうため、この形は POSIX でもそのままでは排他にならない(ハードリンクの数を数えるなどの一手間が要る)。

そして Windows では処理系によって挙動が割れる。次章の実測が示すとおり、同じ「移動」でも失敗するものと黙って上書きするものがある。

実測した結果

先に first と書いたファイル(またはフォルダ)を置き、後から来た側が同じ名前を取ろうとしたときにどうなるかを測った。

見出し意味
後から来た側失敗すれば排他が効いている。成功すると 2 人とも走り出す
先客中身が first のまま残っているか。消えていれば追い出されている

Node.js 26.8.1 / Bun 1.4.0

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) 成功消える 不可

Deno 1.31.1

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) 成功消える 不可

PowerShell 7.6.5

書き方後から来た側先客排他
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 が排他を壊す。

PowerShell から呼ぶ .NET / C#

書き方後から来た側先客排他
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 が成功するという食い違いは、名前からは読み取れない。

cmd(コマンドプロンプト)

書き方後から来た側先客排他
ren 失敗(ERRORLEVEL 1残る
mkdir 失敗(ERRORLEVEL 1残る
mkdir 親\子 成功残る 不可
move 成功消える 不可
move /Y 成功消える 不可
copy 成功消える 不可
echo x > file 成功消える 不可

cmd の move/Y を付けなくても上書きする。対話的に叩けば確認を求められるが、バッチファイルの中では確認なしに実行される。/Y の有無で結果は変わらなかった。同じ「移動」でも ren は失敗し、move は上書きする。

使ってよい書き方

いずれも「作りに行って、失敗したら諦める」形になっている。先に確認しない。

Node.js / Bun

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 });
}

Deno

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;
	}
}

PowerShell

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] {
	# 取れなかった
}

cmd

フォルダを印にする。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)

待つか、諦めるか

取れなかったときの振る舞いを決める。定期実行なら諦めてよいことが多い。次の回に取り直せるためである。

場面振る舞い
定期実行 少し待って、取れなければ諦める。失敗として扱わない。次の回がある
人が起動した処理 誰が握っているかを見せて止まる。黙って諦めると、動いたのかどうか分からない

印だけに頼らない

印はぶつからないための工夫であって、ぶつかっても壊れない保証ではない。作業用のフォルダを処理ごとに分ける、出力先の名前を重ならないようにするなど、万一同時に走っても壊れない作りを併せて持たせる。

排他が要らない作りにできるなら、その方がよい。出力先が最初から別々なら、同時に走っても困らない。印が必要になるのは、共有している何かがある場合に限られる。