Node.js檔案鎖GPUClaude CodeAI Agent自動化

好幾個 Claude 搶同一張顯卡:用一個檔案寫跨程式渲染鎖

· 9 分鐘閱讀
目錄
  1. 先定粒度:鎖一支片,不鎖一整輪
  2. 原子建立:`openSync(path, 'wx')`
  3. 拿不到:排隊,不跳過
  4. 心跳:讓長工人不被誤判成死鎖
  5. 清死鎖:先改名,再確認是同一把
  6. 釋放:只刪自己那把,所有出口都放
  7. 上線之後
  8. 什麼時候不要用這個

我們的影片產線有一張顯卡:RTX 3080,20 GB 顯存。本機配音、生圖、渲染都要用它。

10 月 12 日之前,同時要用這張卡的有三種程式:短片自動駕駛、新做的頻道自動駕駛,以及各個 Claude 視窗手動派出去跑實驗的工人。短片自動駕駛自己有一條程式內的佇列,同一個程式裡的重活會排隊;但它管不到別的程式。頻道自動駕駛一上線,兩邊就有機會在同一秒各自叫起一支渲染,顯存不夠時,可能兩支都失敗。

那天晚上我們寫了一把跨程式的渲染鎖,183 行,沒有任何外部套件。

先定粒度:鎖一支片,不鎖一整輪

最省事的做法是自動駕駛跑整輪時都拿著鎖。但頻道的出片工人一支的逾時上限是 100 分鐘,短片那邊一輪有好幾支,整輪鎖下去,另一邊要等好幾個小時。

所以粒度定成「一支片的重活」:

  • 短片自動駕駛:每個重活(生圖、渲染、重錄加重渲)各拿一次。
  • 頻道自動駕駛:每題在父程式拿,出片工人結束才放。審查只看圖、不用顯卡,不拿。

兩邊輪流進,誰也不會被餓死。

原子建立:`openSync(path, 'wx')`

Windows 沒有 flock。Node 最簡單的原子操作是用 'wx' 開檔:檔案已存在就丟 EEXIST,檢查和建立是同一個動作,不會有兩個程式同時判斷「沒鎖」然後同時建立。

function tryCreate(p, owner, slug) {
  const token = crypto.randomBytes(8).toString('hex')
  let fd
  try { fd = fs.openSync(p, 'wx') } catch (e) { if (e.code === 'EEXIST') return null; throw e }
  try {
    fs.writeSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString(), owner, slug, token }))
  } finally { fs.closeSync(fd) }
  return token
}

鎖檔內容寫 pid、拿鎖時間、誰(reels 或 channels)、哪一支片,以及一個隨機 token。token 是後面「只刪自己那把」的依據。

拿不到:排隊,不跳過

拿不到鎖時有兩個選擇:跳過這支片,或等。

跳過的後果是那支片這週就不見了,週報上只多一行,很容易沒人看到。我們寫過一篇靜默失敗,這種「程式活著但沒做事」最難抓。所以選排隊:每 20 秒重試一次,每 5 分鐘在 log 印一行「等渲染鎖,持有者 X 自 HH:MM」,人看 log 就知道是誰卡住。

排隊也要有上限,預設 150 分鐘。逾時的話,短片那支淘汰,頻道那支和後面的標成延後。

心跳:讓長工人不被誤判成死鎖

死鎖的判斷之一是「鎖檔很久沒動」。但頻道的出片工人可以合法地持鎖跑到 100 分鐘,如果單看拿鎖時間,它會被別人當成死鎖清掉。

解法是心跳:持有中每 5 分鐘用 utimesSync 更新鎖檔的修改時間。判斷死鎖看的是修改時間,不是拿鎖時間:

  • 鎖檔超過 90 分鐘沒心跳,或
  • 鎖檔裡的 pid 已經不存在。

心跳的計時器記得 unref(),不然它會讓程式在工作做完之後還不肯結束。

查 pid 用 process.kill(pid, 0):不送訊號,只檢查。回 ESRCH 是不存在;回 EPERM 代表程式存在但你沒權限,要當成活著,不然會把別人的鎖清掉。

清死鎖:先改名,再確認是同一把

最容易出錯的是清死鎖這一步。情境:A 判斷鎖已死,準備刪掉;就在這個空檔,原本的鎖剛好被正常釋放,B 拿到一把新鎖。A 這時候刪,刪的是 B 的新鎖。

所以不直接刪,先改名移走,再讀移走的內容,確認 token 和 pid 跟剛才判斷的是同一把:

function removeStale(p, judged, log) {
  const tmp = `${p}.stale-${process.pid}-${crypto.randomBytes(3).toString('hex')}`
  try { fs.renameSync(p, tmp) } catch { return false }
  let got = null
  try { got = JSON.parse(fs.readFileSync(tmp, 'utf8')) } catch {}
  const same = judged.info && got && got.token === judged.info.token && got.pid === judged.info.pid
  if (!same) { /* 搶到別人剛建的新鎖:放回去 */ ... return false }
  fs.unlinkSync(tmp)
  log(`渲染鎖死鎖自清:${judged.why}`)
  return true
}

搬錯了就放回去。實際程式還處理了「鎖檔剛建好、內容還沒寫進去」的情況,判斷時把讀不到內容也算進來。

釋放:只刪自己那把,所有出口都放

釋放時先讀鎖檔,pid 和 token 都對得上才刪。這樣就算程式晚了一步、鎖已經被當死鎖清掉又被別人拿走,它也不會誤刪。

出口有好幾個:正常呼叫 release()、程式結束、例外、Ctrl+C(SIGINT)、SIGTERM。全部掛在 process.on('exit') 和兩個訊號處理上。唯一跑不到鉤子的是 taskkill /F 這種強制結束,那就交給下一個人用「pid 不存在」自清。

最後補一個小工具:render-lock.cjs status 印出目前誰持鎖、心跳幾分鐘前;clear 只清死鎖,持有者活著要加 --force。手動出片之前先跑 status,是我們給每個 Claude 視窗的規矩。

上線之後

鎖是 10/12 晚上 20:18 提交的,有 9 項測試,全部在暫存目錄跑,不碰真的鎖檔。

同一晚第一次真跑頻道出片,第一支片第一輪被審查打回,第二次重渲時另一個工人正在收尾、持著鎖,重渲在佇列裡等了 9 分鐘才拿到,沒有跳過。

隔天早上同時有三件事要用顯卡:配音實驗、頻道出片、模型盲測。配音實驗的工人發現 CLI 只有 status 和 clear,沒有「拿鎖跑一個指令」,就自己包了一層呼叫同一個 acquire();兩次都 0 分鐘拿到,剛好跟頻道出片錯開。盲測那邊的出片則用 withLock 一次一支,前後一共排了 14 分鐘。

三種程式、不同的 Claude 視窗,這幾次都是排隊拿到,沒有一支被跳過。

什麼時候不要用這個

這把鎖適合「同一台機器、少數幾個程式、每次持有幾分鐘到一兩小時」。跨機器要用真的協調服務;每秒要拿很多次的場景,每 20 秒重試的排隊就太慢了。我們的情況是一張顯卡、每次持有幾分鐘到一兩小時,一個檔案就夠。

這個系列的前兩篇:agents 自己出片的 token 帳本、三個降成本實驗。


我們開源的東西也走同一個路線:小、零依賴、能直接讀懂。ultraprobe 是 0 依賴的 CLI 與 SDK,掃系統提示詞的 25 項防禦向量;也可以直接在 UltraProbe 網頁版 貼上試。

常見問題

Node.js 在 Windows 上要怎麼做跨程式的互斥鎖?

用 fs.openSync(path, 'wx') 建鎖檔。'wx' 代表檔案已存在就失敗,建立和檢查是同一個原子動作,兩個程式不會同時成功。拿不到就隔一段時間重試,拿到的程式把 pid 和隨機 token 寫進檔案,釋放時只刪內容對得上的那把。Windows 沒有 flock,這是最少依賴的做法。

持有鎖的程式當掉了,鎖怎麼釋放?

兩條判斷:鎖檔裡的 pid 已經不存在,或鎖檔超過 90 分鐘沒有心跳(持有者每 5 分鐘更新一次檔案修改時間)。任一成立就當死鎖,先把鎖檔改名移走、確認移走的就是剛才判定的那把,再重新拿。正常結束、例外、Ctrl+C 都會由 exit 鉤子釋放;被強制結束的程式跑不到鉤子,就靠 pid 不存在自清。

拿不到鎖的時候,應該跳過還是排隊?

我們選排隊。跳過會讓那一支片這週不見,而且沒人發現;排隊頂多晚一點。排隊有上限(預設 150 分鐘),逾時才把那支片標成延後,並在 log 每 5 分鐘印一行目前的持有者,人看得出是誰卡住。

每週 AI 自動化實戰筆記

不廢話,只有能直接用的東西。Prompt 模板、自動化 SOP、技術拆解。

加入一人公司實驗室

免費資源包、每日建造日誌、可以對話的 AI Agent。一群用 AI 武裝自己的獨立開發者社群。

想自己動手試試?

UltraProbe 免費、免註冊,掃一次網站就知道 Google 與 AI 找不找得到你。