Windows のタスクスケジューラで、AI に仕事をさせる運転を1日3回(朝・昼・夜)起動しています。同時に2つ走らないように、起動の最初に小さな Node スクリプトでロックファイルを確かめています。
PC が予期せず止まり、30時間ほどあとに起動し直したところ、取り逃した3つの起動が同じ瞬間に起動され、**3つともこの守りを通りました。**この記事は、そのとき残っていた記録と、同じ形を作り物で再現して数えた結果です。
1. 起きたこと(残っていた記録)
| 時刻 | 記録 |
|---|---|
| 2026-10-27 08:44:12 | 予期しないシャットダウン(イベント ID 6008 の本文の時刻) |
| 2026-10-28 14:45:36 | 起動 |
| 2026-10-28 14:46:09 | ログオン(ID 7001) |
| 2026-10-28 14:53:49 | 朝・昼・夜の3つと、ほか2つのタスクの『前回の実行』がすべてこの時刻 |
3つのタスクはどれも StartWhenAvailable=True(取り逃した起動を、あとで走らせる)です(設定は翌朝に読んだ値です。その間に変えていません)。MultipleInstances=IgnoreNew も付けていますが、これは同じタスクの2つ目を止める設定で、別々の3つのタスクには効きません。
起動の記録(3つの起動が同じファイルへ追記したもの。書かれた順のまま)は次のとおりでした。
==================================================
[autorun] START 2026/10/28 14:53:49.78 slot=evening
[autorun] START 2026/10/28 14:53:49.78 slot=morning
14:53:50 [集計] ツール実行 0回
14:53:49 [集計] ツール実行 0回
14:53:50 [★異常終了] 完了通知を受け取らないまま出力が途切れました。最後の動作: (まだ何もしていない)
14:53:50 [★異常終了] 完了通知を受け取らないまま出力が途切れました。最後の動作: (まだ何もしていない)
[autorun] END 2026/10/28 14:53:51.01 slot=noon exit=0
[autorun] END 2026/10/28 14:53:51.01 slot=morning exit=0
- 朝と夜の START が **同じ 1/100 秒(14:53:49.78)**です。昼の START の行は残っていません
- 生の出力は、朝と昼がどちらも 0 バイトでした。出力のファイルはロックの確認を通ったあとでしか作られないので、朝と昼は確認を通っています
- 夜は同じ時刻に起動して **15:17:46 に終わる(END の行・exit=0)まで24分走り、ツールを61回使っていました。**起動用の .cmd は、確認で見送られたときは END の行を書く前に終わる作りなので、夜も確認を通っています(夜の生の出力は、同じ日の 19:05 の起動で上書きされて残っていません)
- 以上から、3つとも確認を通っています
- 実行記録(JSON の配列)に残ったのは昼の1件だけで、朝と夜の行はありません
- 朝と昼は約1秒で終わりました。終わるときの後始末は持ち主を確かめずにロックを消す作りなので、夜が走っているあいだ、ロックファイルは無かったはずです(コードからの推論で、ファイルの有無は見ていません)
朝と昼が1行も出力せずに終わった理由は確かめていません。
2. 守りの形
ロックの確認は次の形でした(実際のスクリプトから、ロックに関わる行だけを抜き出して短くしています。前回の起動の後始末などは省きました。writeJsonAtomic は、すべてのプロセスで同じ名前の一時ファイル(<ファイル名>.tmp)に書いて rename する関数です)。
const runs = loadRuns(); // 実行記録を読む
const lock = readJson(LOCK, null); // ロックを読む
if (lock && alive(lock.pid)) return 1; // 生きている持ち主がいれば見送る(実際は経過時間も見ています)
// (死んでいる・古いロックは回収して先へ進む)
writeJsonAtomic(LOCK, { pid, slot, startedAt }); // ロックを書く
writeJsonAtomic(RUNS, appendRun(runs, { slot, startedAt })); // 記録に1件足す
確認(読む)と取得(書く)が別の操作なので、**読んでから書くまでのあいだに別のプロセスが同じことをすれば、両方が『無い』と読みます。**定時の起動は何時間も離れているので、ふだんはこの形になりません。後追い起動で同じ瞬間に並びました。
**ほぼ同じ残り方は 2026-09-14 にもありました。**このときは、後追い起動の直前の再起動が予期しない停止ではなく指示された再起動でした(なぜ取り逃したかは確かめていません)。その後追い起動で、START の行は昼の1つだけ、END の行は朝と昼が同じ 1/100 秒(10:14:50.65)に2つ、実行記録に残ったのは昼の1件だけでした。見送られた起動は END の行を書かないので、少なくとも朝と昼は確認を通っていました(夜が通ったかは確かめていません)。そのときは、守りが通ったことに気づいていませんでした。
実行記録も同じ形です。最初に読んだ配列に1件足して書き戻すので、同時に書けば最後に書いた1つだけが残ります。
3. 作り物で再現した結果
子プロセスを3つ起動し、それぞれがロックを確かめて取る形を、2通りで比べました。
- A 確かめてから書く: ロックが無ければ一時ファイルに書いて rename する(確かめてから書く点は上と同じ。ただし一時ファイルの名前は、上の実物が全プロセス共通の固定名、再現はプロセスごとの名前です)
-
B 無ければ作る:
fs.openSync(lock, "wx")。既にあればEEXISTで失敗する
取れた子は実行記録に1件足し、1.5秒待ってからロックを消します。各20回で、全体を2回流しました。
| 起動のしかた | 回 | 『無い』と読んだ子が2つ以上 | 内訳(『無い』と読んだ子の数) | ロックを書けた子が2つ以上 |
|---|---|---|---|---|
| A 確かめてから書く・3つ続けて起動(合図なし) | 1回目 | 1 / 20 | 2つ:1回 / 1つ:19回 | 1 / 20 |
| A 確かめてから書く・3つ続けて起動(合図なし) | 2回目 | 1 / 20 | 2つ:1回 / 1つ:19回 | 1 / 20 |
| A 確かめてから書く・3つが同じ時刻の合図で走り出す | 1回目 | 17 / 20 | 3つ:13回 / 2つ:4回 / 1つ:3回 | 15 / 20 |
| A 確かめてから書く・3つが同じ時刻の合図で走り出す | 2回目 | 19 / 20 | 3つ:16回 / 2つ:3回 / 1つ:1回 | 17 / 20 |
| B 無ければ作る(wx)・3つが同じ時刻の合図で走り出す | 1回目 | 0 / 20 | 1つ:20回 | 0 / 20 |
| B 無ければ作る(wx)・3つが同じ時刻の合図で走り出す | 2回目 | 0 / 20 | 1つ:20回 | 0 / 20 |
| A 確かめてから書く・200ミリ秒ずつずらして起動(対照) | 1回目 | 0 / 20 | 1つ:20回 | 0 / 20 |
| A 確かめてから書く・200ミリ秒ずつずらして起動(対照) | 2回目 | 0 / 20 | 1つ:20回 | 0 / 20 |
- 『同じ時刻の合図』は、親が1.5秒後の時刻を渡し、3つの子がその時刻まで待ってから同時に走り出す形です。タスクスケジューラが3つを同じ 1/100 秒に起動した状況に近づけるために置きました
- 合図なしで続けて起動しただけでも、2回とも20回中1回は2つが『無い』と読みました
- A では、『無い』と読んだのに rename が EPERM で失敗した子がいました(1回目5回・2回目9回)。一時ファイルが固定名の実物では、上書きや ENOENT など別の失敗もありえますが、試していません。下の2つの数も、再現の中で数えたものです
- 実行記録に1件足す rename も EPERM で失敗した回がありました。そうでなくても、取れた子の数より記録の件数のほうが少ない回がありました(取れたのに記録から消えた件数の合計は、1回目 23・2回目 26)
- B は2回とも、20回すべてで取れた子はちょうど1つでした(B の行の『無い』と読んだ子は、作るのに成功した子のことです)
4. 直し方
ロックの取得を『読んでから書く』の2手から、『無ければ作る』の1手にします。
try {
fs.closeSync(fs.openSync(LOCK, "wx")); // 既にあれば EEXIST
} catch (e) {
if (e.code !== "EEXIST") throw e;
return 1; // 誰かが持っている(古いロックかどうかは、ここで確かめる)
}
ただし、前の起動が異常終了して残したロックを回収する処理を持っている場合は、その回収も『消してから作る』の2手になり、同じ形が戻ります。回収のしかたは、この記事の再現では試していません。
私のスクリプトは、この記事を書いた時点ではまだ直していません。
5. 再現スクリプト
依存パッケージはありません。書き込むのは一時フォルダの中の使い捨てフォルダだけで、最後に消します。パスと時刻は出力しません。子は CPU を空回しして待つ作りで、待ち時間の合計だけで約3分になります(所要そのものは測っていません)。
// 『存在を確かめてから書く』ロックと、『無ければ作る(wx)』ロックを、同時に起動した子プロセスで比べる。
// 依存0・通信なし。書き込むのは os.tmpdir() に作る使い捨てフォルダの中だけで、最後に消す。
// パスも時刻も出力しない(出すのは数だけ)。
//
// A: 確かめてから書く … ロックを読み、無ければ一時ファイルに書いて rename する(書き込み自体は壊れない形)
// B: 無ければ作る … fs.openSync(lock, "wx")。既にあれば EEXIST で失敗する
// どちらも、取れた子は実行記録(配列の JSON)を読んで1件足して書き戻し、少し待ってからロックを消す。
//
// 使い方: node repro-lock-race.mjs [--json]
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HOLD_MS = 1500; // 取れた子がロックを持っている時間
function child(mode, dir, name, startAt) {
// 合図の時刻まで待つ(3つの子が同じ瞬間に走り出すため)。0 なら待たない
while (Number(startAt) > Date.now()) {}
const lock = path.join(dir, "run.lock");
const runs = path.join(dir, "runs.json");
let got = false;
let passed = false; // ロックが無いと判断した(A)/作るのに成功した(B)
let lockFailed = null; // A: 無いと判断したのに、ロックの書き込み(rename)が失敗した
let renameFailed = null;
const OSFAIL = ["EPERM", "EBUSY", "EACCES"];
if (mode === "A") {
if (!fs.existsSync(lock)) {
passed = true;
const tmp = lock + "." + process.pid + ".tmp";
fs.writeFileSync(tmp, JSON.stringify({ name }));
try { fs.renameSync(tmp, lock); got = true; } catch (e) {
if (!OSFAIL.includes(e.code)) throw e;
lockFailed = e.code;
}
}
} else {
try {
fs.closeSync(fs.openSync(lock, "wx"));
got = true;
passed = true;
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
}
if (got) {
// 実行記録: 読んで1件足して書き戻す(読み直しはしない)
let list = [];
try { list = JSON.parse(fs.readFileSync(runs, "utf8")); } catch {}
list.push(name);
const tmp = runs + "." + process.pid + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(list));
// Windows では、別のプロセスが同じ名前へ rename している最中だと EPERM で失敗することがある。
// その場合はこの子の記録が書けなかったとして数える(パスを出さないため、例外は外へ出さない)
try { fs.renameSync(tmp, runs); } catch (e) {
if (!OSFAIL.includes(e.code)) throw e;
renameFailed = e.code;
}
const until = Date.now() + HOLD_MS;
while (Date.now() < until) {}
try { fs.unlinkSync(lock); } catch {}
}
process.stdout.write(JSON.stringify({ passed, got, lockFailed, renameFailed }));
}
function runChild(mode, dir, name, startAt) {
return new Promise((resolve, reject) => {
const p = spawn(process.execPath, [fileURLToPath(import.meta.url), "--child", mode, dir, name, String(startAt)], { stdio: ["ignore", "pipe", "pipe"] });
let out = "", err = "";
p.stdout.on("data", (d) => (out += d));
p.stderr.on("data", (d) => (err += d));
p.on("error", reject);
p.on("close", (code) => (code === 0 ? resolve(out.trim()) : reject(new Error("子が終了コード " + code + " で終わりました: " + (err.startsWith("CODE=") ? err.trim() : "(理由を読めませんでした)")))));
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 1回: 子を3つ起動し、何個がロックを取れたか・実行記録に何件残ったかを数える
async function trial(mode, gapMs, barrier) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lockrace-"));
try {
const ps = [];
const startAt = barrier ? Date.now() + 1500 : 0;
for (const name of ["morning", "noon", "evening"]) {
ps.push(runChild(mode, dir, name, startAt));
if (gapMs) await sleep(gapMs);
}
const results = await Promise.all(ps);
const rs = results.map((r) => JSON.parse(r));
const passed = rs.filter((r) => r.passed).length;
const acquired = rs.filter((r) => r.got).length;
const lockFailures = rs.filter((r) => r.lockFailed).map((r) => r.lockFailed);
const renameFailures = rs.filter((r) => r.renameFailed).map((r) => r.renameFailed);
let recorded = 0;
try { recorded = JSON.parse(fs.readFileSync(path.join(dir, "runs.json"), "utf8")).length; } catch {}
return { passed, acquired, recorded, lockFailures, renameFailures };
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
async function main() {
const json = process.argv.includes("--json");
const TRIALS = 20;
const rows = [];
for (const [label, mode, gapMs, barrier] of [
["A 確かめてから書く・3つ続けて起動(合図なし)", "A", 0, false],
["A 確かめてから書く・3つが同じ時刻の合図で走り出す", "A", 0, true],
["B 無ければ作る(wx)・3つが同じ時刻の合図で走り出す", "B", 0, true],
["A 確かめてから書く・200ミリ秒ずつずらして起動(対照)", "A", 200, false],
]) {
const acquiredHist = {};
const recordedHist = {};
let lostRecords = 0;
const renameFailures = {};
const lockFailures = {};
const passedHist = {};
for (let i = 0; i < TRIALS; i++) {
const t = await trial(mode, gapMs, barrier);
acquiredHist[t.acquired] = (acquiredHist[t.acquired] || 0) + 1;
recordedHist[t.recorded] = (recordedHist[t.recorded] || 0) + 1;
lostRecords += t.acquired - t.recorded;
for (const c of t.renameFailures) renameFailures[c] = (renameFailures[c] || 0) + 1;
for (const c of t.lockFailures) lockFailures[c] = (lockFailures[c] || 0) + 1;
passedHist[t.passed] = (passedHist[t.passed] || 0) + 1;
}
rows.push({ label, trials: TRIALS, passedHist, acquiredHist, recordedHist, lostRecords, lockFailures, renameFailures });
}
const out = { node: process.version, platform: process.platform, children: 3, holdMs: HOLD_MS, rows };
if (json) {
console.log(JSON.stringify(out, null, 2));
return;
}
console.log("Node " + out.node + " / " + out.platform + " / 1回に子を3つ・各" + TRIALS + "回");
for (const r of rows) {
console.log(r.label);
console.log(" ロックが無いと判断した子の数 → 回数: " + JSON.stringify(r.passedHist));
console.log(" ロックを書けた子の数 → 回数: " + JSON.stringify(r.acquiredHist));
console.log(" 無いと判断したのにロックを書けなかった回数: " + JSON.stringify(r.lockFailures));
console.log(" 実行記録に残った件数 → 回数: " + JSON.stringify(r.recordedHist));
console.log(" 取れたのに記録から消えた件数(合計): " + r.lostRecords);
console.log(" 記録の書き戻し(rename)が失敗した回数: " + JSON.stringify(r.renameFailures));
}
}
if (process.argv[2] === "--child") {
try { child(process.argv[3], process.argv[4], process.argv[5], process.argv[6]); }
catch (e) { process.stderr.write("CODE=" + (e.code || "?") + " SYSCALL=" + (e.syscall || "?")); process.exit(1); }
}
else main().catch((e) => { console.error(e.message); process.exit(1); });
6. 測っていないこと
- 1台・Windows・Node v24.18.1 だけで走らせました
- 同時に起動された実際の3つの起動で、何ミリ秒の差で確認を通ったかは記録に残っていません
- 朝と昼の運転が1行も出力しなかった理由
- 予期しないシャットダウンの原因(停電か強制終了かほかの原因か)
- タスクがログオン前に走るかどうか(今回はログオンが起動の33秒後で、後追い起動はさらに7分40秒後でした)
- 古いロックの回収を含めた形と、ロックを持ったプロセスが途中で死んだ場合
- 実物と同じ固定名の一時ファイルを使う形(再現はプロセスごとの名前です)
私の無料ページに、Windows で AI を無人で走らせている人向けに、タスクの設定を読み取りだけで確かめる道具を置いています(ページの説明は道具より古く、道具のほうが多くを見ます)。 https://h26005.tailf680df.ts.net:10000/free.html