好幾個 Claude 搶同一張顯卡:用一個檔案寫跨程式渲染鎖
目錄
我們的影片產線有一張顯卡: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 分鐘印一行目前的持有者,人看得出是誰卡住。