如何在访问用户脚本存储时实现原子性(及更通用的并发安全性)?
如何在访问用户脚本存储时实现原子性(及更通用的并发安全性)?
太懂你这种头疼的情况了——就像你那个计数器脚本,开两个标签页跑START,结果最后拿到64而不是预期的100,完全是异步存储操作的竞态搞的鬼。现代用户脚本管理器的GM存储API都是异步的,多个脚本实例(或同脚本多标签页)并发读写时,很容易打破我们默认的原子性假设。下面我给你几个实用的解决方案,覆盖你提到的CAS、重试更新和临界区需求,优先用Greasemonkey原生API,也会提其他管理器的专属方案。
一、实现Compare-and-Swap(CAS)原语
CAS是很多并发控制的基础,核心逻辑是:只有当存储的当前值和我们预期的值一致时,才写入新值。我们可以用Greasemonkey的GM.getValue和GM.setValue组合实现这个逻辑:
/** * 基于GM API的CAS操作 * @param {string} key 存储键名 * @param {any} expectedValue 预期的当前值 * @param {any} newValue 要写入的新值 * @returns {Promise<boolean>} 是否成功完成CAS操作 */ async function gmCompareAndSwap(key, expectedValue, newValue) { // GM.getValue本身是原子性的,先获取当前值 const currentValue = await GM.getValue(key); // 用JSON.stringify处理引用类型的内容对比,基础类型可直接用=== if (JSON.stringify(currentValue) === JSON.stringify(expectedValue)) { await GM.setValue(key, newValue); return true; } return false; }
二、带重试的“弱更新”原语
有了CAS,我们可以实现自动重试的更新逻辑——当检测到竞态(CAS失败)时,重新获取当前值再尝试更新,直到成功或达到重试上限:
/** * 带重试的原子更新操作 * @param {string} key 存储键名 * @param {function(any): any} updateFn 接收当前值,返回新值的函数 * @param {number} maxRetries 最大重试次数,默认10 * @returns {Promise<any>} 更新后的最终值 */ async function gmAtomicUpdate(key, updateFn, maxRetries = 10) { let retries = 0; while (retries < maxRetries) { const currentValue = await GM.getValue(key) ?? 0; const newValue = updateFn(currentValue); const success = await gmCompareAndSwap(key, currentValue, newValue); if (success) { return newValue; } retries++; // 加微小延迟避免频繁重试 await new Promise(resolve => setTimeout(resolve, 10)); } throw new Error(`原子更新失败,已达到最大重试次数${maxRetries}`); }
用这个方法改造你的计数器逻辑,就能彻底避免竞态:
const KEY = 'counter'; GM.registerMenuCommand("[COUNTER] START", () => { (async () => { const x0 = await GM.getValue(KEY) ?? 0; const iters = 50; for (let i = 0; i < iters; ++i) { await gmAtomicUpdate(KEY, (current) => { console.info(`[COUNTER] iteration ${i}: ${current}`); return current + 1; }); } const x1 = await GM.getValue(KEY); alert(`[COUNTER] ran from ${x0} to ${x1} in ${iters} iterations`); })().catch(err => { console.error(`[COUNTER]`, err); }); });
三、临界区(Mutex/互斥锁)原语
如果你需要一整块代码的互斥执行(而非单个更新操作),可以基于CAS实现跨标签页生效的互斥锁:
const LOCK_KEY = "__gm_mutex_lock__"; /** * 获取互斥锁 * @param {number} timeout 超时时间(毫秒),默认5000 * @returns {Promise<function()>} 释放锁的函数 */ async function gmLock(timeout = 5000) { const startTime = Date.now(); while (Date.now() - startTime < timeout) { // 尝试获取锁:仅当LOCK_KEY未被占用时设置为true const locked = await gmCompareAndSwap(LOCK_KEY, undefined, true); if (locked) { // 返回释放锁的函数,确保锁能被正确释放 return async () => { await GM.setValue(LOCK_KEY, undefined); }; } // 未拿到锁则等待后重试 await new Promise(resolve => setTimeout(resolve, 50)); } throw new Error("获取锁超时"); }
使用时一定要用try/finally确保锁被释放(即使代码出错):
GM.registerMenuCommand("[COUNTER] START WITH LOCK", () => { (async () => { const releaseLock = await gmLock(); try { const x0 = await GM.getValue(KEY) ?? 0; const iters = 50; for (let i = 0; i < iters; ++i) { const x = await GM.getValue(KEY) ?? 0; console.info(`[COUNTER] iteration ${i}: ${x}`); await GM.setValue(KEY, 1 + x); } const x1 = await GM.getValue(KEY); alert(`[COUNTER] ran from ${x0} to ${x1} in ${iters} iterations`); } finally { // 无论成功失败都释放锁 await releaseLock(); } })().catch(err => { console.error(`[COUNTER]`, err); }); });
四、其他用户脚本管理器的专属方案
如果你用的是Tampermonkey或Violentmonkey,有更直接的原生支持:
- Tampermonkey:提供
GM.addValueChangeListener监听存储变化,部分版本支持GM.transaction实现原子读写事务。 - Violentmonkey:默认保证
GM.setValue的原子性,还提供GM.scriptHandler管理脚本实例并发。
关键注意事项
- 所有方案都考虑了脚本突然终止的情况:用
try/finally确保锁释放,CAS和重试逻辑本身是幂等的,中途中断不会破坏数据一致性。 - GM存储是跨域的,这些方案天然支持跨域名的并发控制,无需额外处理同源问题。
内容来源于stack exchange
相关产品推荐
相关产品推荐

