当用户同时打开多个标签页,或者页面与 Service Worker 并行运行时,它们很可能同时读写同一份共享资源,比如 IndexedDB 中的数据、通过 BroadcastChannel 传递的状态,甚至是一次只允许发起一个的网络请求。如果缺少协调机制,竞态条件几乎不可避免,轻则数据不一致,重则操作被覆盖丢失。
在传统多线程编程中,「锁」正是解决这类问题的经典手段:访问共享资源之前先获取锁,拿到锁的一方独占访问权,其他竞争者排队等待,访问结束后释放锁,排队者依次获得机会。Web Locks API 将这一思路带到了浏览器环境,为标签页与 Worker 之间的资源协调提供了一套原生的锁机制。不过这里的「之间」有一个隐含前提,它们必须同源。浏览器的同源策略要求协议、域名、端口三者完全一致才算同源,localStorage、IndexedDB 等存储都遵循这一规则,Web Locks 也不例外:同源的所有标签页和 Worker 共享一个锁空间,不同源之间的锁互不可见。
基本概念
请求锁的语法如下:
navigator.locks.request(lockName [,options], callback)
通过 request 来请求一个锁,接收三个参数:
-
锁名称,在同一锁空间下,通过相同锁名称请求锁时,只有一个请求能获取到锁,其它的请求会进入到请求队列,等到这个锁被释放时,从请求队列选择一个请求让其获取到锁。
一个典型的请求锁的生命周期如下:
请求锁 → [等待队列] → 获得锁 → 执行回调 → 回调结束 → 释放锁 -
一个可选的配置项,可以对锁进行更高级的设置
-
回调函数,当请求获取到锁时,该回调函数将会被执行。回调函数接收一个
Lock对象作为参数,包含以下属性:name:锁的名称,即请求时传入的lockNamemode:锁的模式,值为"exclusive"或"shared",见下文「锁模式章节」
这个回调函数可以是异步的,当这个函数返回的
Promiseresolved或者rejected的时候,获取的锁会被自动释放当持有锁的上下文被销毁,比如标签页关闭,Worker 终止的时候,锁也会被自动释放。这一点在 Service Worker(以下简称 SW)中尤其值得注意。浏览器会在 SW 空闲后自动终止它以节省资源,这意味着 SW 持有的锁可能因上下文被销毁而意外释放,导致其他标签页提前获取到锁。如果你的协调逻辑依赖 SW 长时间持有某把锁,就需要格外小心这种时序问题。
反过来,这个特性也催生了一种「保活」技巧:在 SW 内部发起一个
exclusive锁请求,回调中返回一个永远不 resolve 的Promise,浏览器会认为 SW 仍有活跃的异步任务而推迟终止。// service-worker.js // 利用 Web Locks 保持 Service Worker 存活 async function keepAlive() { await navigator.locks.request('sw-keep-alive', () => { // 返回一个永远不 resolve 的 Promise,阻止锁释放 return new Promise(() => {}) }) } self.addEventListener('activate', (event) => { event.waitUntil(keepAlive()) })这种保活方式会持续占用浏览器资源,应仅在确实需要 SW 长期运行的场景下使用(如维持 WebSocket 连接、后台定时同步等),滥用会影响设备性能和电量。
request 函数本身也返回一个 Promise,其结果与 callback 的执行结果绑定:
-
正常返回:若
callback正常执行并返回一个值(无论同步值还是异步resolve的值),request的Promise会以该值resolve。// 同步返回值 const result = await navigator.locks.request('resource', () => { return 'success'; }); console.log(result); // 'success' // 异步 resolve(回调返回 Promise) const num = await navigator.locks.request('resource', async () => { await doSomething(); return 42; }); console.log(num); // 42 -
异常抛出:若
callback同步抛出异常,或返回的Promise被reject,request的Promise会以同样的错误reject。// 同步抛出异常 await navigator.locks.request('resource', () => { throw new Error('出错了'); }); // Promise 以 Error('出错了') reject // 异步 reject(回调内调用的异步操作失败) await navigator.locks.request('resource', async () => { const response = await fetch('/api/data'); if (!response.ok) { throw new Error('请求失败'); // async 函数中抛出等价于 reject } return response.json(); }); // fetch 失败时,request 的 Promise 会以同样的错误 reject
通过一个简单的场景看看该 API 的具体使用,多个 worker 访问同一变量,并且对其计数,然后写回,首先看下无锁的场景:
const sleep = (ms) => {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
}
let counter = 0
const readCounter = async () => {
await sleep(Math.random() * 10)
return counter
}
const writeCounter = async (value) => {
await sleep(Math.random() * 10)
counter = value
}
const workers = Array.from({ length: 10 })
await Promise.all(workers.map(async () => {
const value = await readCounter()
await writeCounter(value + 1) // 可能覆盖了别人写入的值
}))
console.log('counter', counter) // 可能是 1-10 之间的任何一个数
上述代码同时发起了 10 个任务,每个任务读取 counter 并将其加一后写回。我们预期最后的结果是 10,但实际结果可能是 1-10 之间的任何一个数。因为读和写都是异步操作,读取然后写入并非原子操作,写入时会覆盖别人已经写入的值。可以认为最后一个写入的值就是最终值,而这个值取决于它读取时拿到的值。
为了保证对公共资源的有序访问,我们可以使用锁来进行访问控制。因为同一时刻只有一个请求能获取到锁,因此它们获取到的是最新值,写入时也不会覆盖其他 worker 已写入的值。
await Promise.all(workers.map(async () => {
await navigator.locks.request('counter', async () => {
const value = await readCounter()
await writeCounter(value + 1)
})
}))
console.log('counter', counter) // 10
配置项
上面的示例只用到了 request 最基本的用法,实际上第二个参数 options 提供了不少实用的配置项,可以对锁的行为做更精细的控制。
锁模式
锁有两种模式,独占锁和共享锁,通过 mode 选项来配置锁的模式:
-
exclusive:独占锁,在同一时刻只能有一个请求能持有这把锁,当一个exclusive的请求持有锁时,其它任意模式的请求都会被阻塞。当不指定mode选项的值时,默认的请求模式是独占锁。navigator.locks.request('my-resource', { mode: 'exclusive' }, async () => { console.log('写者 A 开始'); await sleep(1000); console.log('写者 A 结束'); }); navigator.locks.request('my-resource', { mode: 'exclusive' }, async () => { console.log('写者 B 开始'); // 等待写者 A 释放锁后才能运行 await sleep(1000); console.log('写者 B 结束'); }); -
shared:共享锁,在同一时刻,允许有多个shared请求同时持有同一个锁,互不阻塞。当已有shared请求持有锁时,后续到达的shared请求同样可以获取到锁。// 这两个调用会同时运行,互不等待 navigator.locks.request('my-resource', { mode: 'shared' }, async () => { console.log('读者 A 开始'); await sleep(1000); console.log('读者 A 结束'); }); navigator.locks.request('my-resource', { mode: 'shared' }, async () => { console.log('读者 B 开始'); // 和读者 A 同时打印 await sleep(1000); console.log('读者 B 结束'); });但是如果此时有
exclusive的请求尝试获取锁,那么这个请求会被阻塞,如果在此exclusive的请求后有shared的请求到达,也会被exclusive阻塞掉无法获取到锁。
基于以上,我们可以创建一个读写锁模式,当读取资源时我们使用 shared 模式请求锁,当写入资源时,我们使用 exclusive 模式来请求锁:
class ReadWriteLock {
constructor(resourceName) {
this.resourceName = resourceName
}
// 获取读锁(共享锁)
async read(callback) {
return navigator.locks.request(
this.resourceName,
{ mode: 'shared' },
callback
)
}
// 获取写锁(独占锁)
async write(callback) {
return navigator.locks.request(
this.resourceName,
{ mode: 'exclusive' },
callback
)
}
}
下面是使用示例:
const rwLock = new ReadWriteLock('counter')
// 多个读者可以同时读取
const readers = Array.from({ length: 5 }, (_, i) => i + 1)
await Promise.all(readers.map(async (id) => {
await rwLock.read(async () => {
console.log(`读者 ${id} 开始读取`)
const value = await readCounter()
console.log(`读者 ${id} 读取到值: ${value}`)
await sleep(100)
console.log(`读者 ${id} 读取结束`)
})
}))
// 写者需要独占访问
const writers = Array.from({ length: 3 }, (_, i) => i + 1)
await Promise.all(writers.map(async (id) => {
await rwLock.write(async () => {
console.log(`写者 ${id} 开始写入`)
const value = await readCounter()
await writeCounter(value + 1)
console.log(`写者 ${id} 写入完成,新值: ${value + 1}`)
})
}))
// 混合读写场景
await Promise.all([
// 读者 A 和 B 可以同时读取
rwLock.read(async () => {
console.log('读者 A 读取:', await readCounter())
await sleep(50)
}),
rwLock.read(async () => {
console.log('读者 B 读取:', await readCounter())
await sleep(50)
}),
// 写者 C 需要等待所有读者完成后才能写入
rwLock.write(async () => {
const value = await readCounter()
await writeCounter(value + 10)
console.log('写者 C 写入:', value + 10)
}),
// 读者 D 需要等待写者 C 完成后才能读取
rwLock.read(async () => {
console.log('读者 D 读取:', await readCounter())
}),
])
非阻塞尝试
默认情况下,请求锁时如果锁不可用,请求会进入等待队列直到获取到锁。但有时候我们并不想等待,而是希望「试一试,拿不到就算了」。ifAvailable 属性就是用来控制这一行为的,它是一个布尔值:
true:没有获取到锁就直接返回,此时执行的回调函数中,传递的锁对象lock就是null,表示没有获取到锁false:默认值,没获取到锁就进入请求队列,等待锁被释放
有一些场景适合使用非阻塞的尝试:
-
信息同步
假如你在后台每隔 30s 进行一次信息同步,如果上一次 30s 内没同步完,那么就放弃这次同步,这种情况就可以尝试以非阻塞的形式请求锁,如果没有请求到锁,就说明上一次的同步任务没有执行完成,锁没有释放,这个时候这次直接放弃同步就好了
setInterval(() => { navigator.locks.request('background-sync', { ifAvailable: true }, async (lock) => { if (lock === null) { // 上一次任务没有执行完成,直接返回 return } // 获取到锁,执行同步任务 await syncToServer() }) }, 30_000) -
Leader 选举与广播同步
ifAvailable: true天然适合用来做轻量级的 Leader 选举:当多个标签页或 Worker 同时需要获取一份远程数据时,只有一个实例能抢到锁成为 Leader,由它负责去服务器拉取数据并更新缓存;其余拿不到锁的 Follower 通过BroadcastChannel等待 Leader 的更新通知,从而拿到同一份最新数据。// 跨 Tab 广播通道 const bc = new BroadcastChannel('data_sync'); async function getData() { return navigator.locks.request('data_sync', { ifAvailable: true }, async (lock) => { if (!lock) { // 未抢到锁,当前有 Leader 在更新,等待广播 return waitForLeaderBroadcast(); } // 成为 Leader,负责回源 try { const freshData = await fetchFromServer(); await updateCache(freshData); // 广播给所有 Follower bc.postMessage({ type: 'sync', data: freshData }); return freshData; } catch (err) { // 回源失败,广播错误让 Follower 各自兜底 bc.postMessage({ type: 'error', error: err.message }); throw err; } }); } // Follower 等待 Leader 广播,超时后回退到本地缓存 function waitForLeaderBroadcast(timeout = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { bc.removeEventListener('message', handler); getCachedData().then(resolve).catch(reject); }, timeout); const handler = (event) => { if (event.data.type === 'sync') { clearTimeout(timer); bc.removeEventListener('message', handler); resolve(event.data.data); } if (event.data.type === 'error') { clearTimeout(timer); bc.removeEventListener('message', handler); getCachedData().then(resolve).catch(reject); } } bc.addEventListener('message', handler); }); }
强制抢占
ifAvailable 拿不到锁会优雅地放弃,但有些极端场景下我们需要更激进的手段。通过配置 steal: true 可以强制抢占锁,它会产生以下行为:
- 该请求立刻获得锁,不管是否有人持有锁
- 如果当前有请求正在持有锁,原持有者的回调并不会被打断,它会继续执行到结束,但锁已经被转移给了抢占者。这意味着原持有者和抢占者的回调实际上在并行操作同一资源,「失去锁的保护」指的正是这种并行带来的数据不一致风险
- 如果存在请求在队列中等待锁分配,这些请求全部会被终止并从队列中清除,它们会收到一个
AbortError
steal 的破坏性很大,不到迫不得已的时候不要使用,以下是一些可能会使用的场景
-
页面卸载前需要进行一些清理工作
window.addEventListener('beforeunload', async (event) => { // 强制抢占锁以执行清理工作 await navigator.locks.request( 'database-connection', { steal: true }, async () => { // 关闭数据库连接 await closeDatabaseConnection() // 保存未持久化的数据 await flushPendingWrites() console.log('清理工作完成,页面可以安全卸载') } ) }) -
检测到僵尸进程,页面卡死导致锁无法被释放
// 定期检测某个锁是否被长时间占用 async function detectAndRecoverZombieLock(lockName, timeoutMs = 30_000) { const startTime = Date.now() try { // 尝试非阻塞方式获取锁,检查状态 await navigator.locks.request(lockName, { ifAvailable: true }, async (lock) => { if (lock) { // 锁是空闲的,说明没有僵尸进程 return } // 锁被占用,开始轮询等待 while (Date.now() - startTime < timeoutMs) { await sleep(1000) // 再次尝试获取锁 const acquired = await navigator.locks.request( lockName, { ifAvailable: true }, async (l) => !!l ) if (acquired) { console.log('锁已正常释放,无需强制抢占') return } } // 超过超时时间仍未释放,强制抢占 console.warn('检测到僵尸锁,执行强制抢占') await navigator.locks.request(lockName, { steal: true }, async () => { // 重置资源状态,清理不一致的数据 await resetResourceState() console.log('资源状态已重置,锁已被强制释放') }) }) } catch (error) { console.error('锁恢复失败:', error) } }
steal 与 ifAvailable 不能同时使用,会抛出 NotSupportedError
try {
// 以下调用会直接抛出 NotSupportedError
await navigator.locks.request('my-resource', {
steal: true,
ifAvailable: true
}, async () => {
console.log('这段代码不会执行')
})
} catch (error) {
// NotSupportedError
console.error(error.name)
// Failed to execute 'request' on 'LockManager': The 'steal' and 'ifAvailable' options cannot be used together.
console.error(error.message)
}
超时与取消
无论是阻塞等待还是非阻塞尝试,前面的方案都缺少一个能力:在等待过程中主动放弃。配置项中的 signal 参数填补了这一空白,它的值是一个 AbortSignal,允许在请求还在队列中等待锁的时候取消这次请求。被取消的请求会从等待队列中移除,request 返回的 Promise 会以 AbortError 拒绝。
signal 只能取消正在等待的请求。如果回调已经开始执行(即锁已经被获取),signal 的中止不会打断回调,锁仍然会在回调结束后正常释放。
基本用法如下:
const controller = new AbortController()
navigator.locks.request('my-resource', { signal: controller.signal }, async () => {
// 获取到锁后执行的操作
await doWork()
}).catch(error => {
if (error.name === 'AbortError') {
console.log('请求在等待锁时被取消')
}
})
// 在其他地方取消这次请求
controller.abort()
结合 AbortSignal.timeout() 可以很方便地实现超时控制,避免请求在队列中无限等待:
// 如果 2 秒内没有获取到锁,自动取消请求
navigator.locks.request(
'my-resource',
{ signal: AbortSignal.timeout(2000) },
async () => {
await doWork()
}
).catch(error => {
if (error.name === 'TimeoutError') {
console.log('等待锁超时,已自动取消')
}
})
以下是一些适合使用 signal 的场景:
-
用户触发的操作取消
用户发起了一个需要获取锁的操作,但在等待期间用户点击了取消按钮或者切换了页面,此时应该放弃等待,避免后续执行已经过时的操作
function startTask() { const controller = new AbortController() // 将 abort 绑定到取消按钮 cancelButton.addEventListener('click', () => controller.abort(), { once: true }) navigator.locks.request( 'heavy-task', { signal: controller.signal }, async () => { await performHeavyTask() } ).catch(error => { if (error.name === 'AbortError') { showToast('任务已取消') } }) } -
带超时的资源竞争
多个标签页竞争同一资源时,如果某个标签页长时间拿不到锁,与其一直等待,不如超时后降级到本地处理,避免用户体验受损
async function syncWithTimeout(data) { try { await navigator.locks.request( 'cloud-sync', { signal: AbortSignal.timeout(5000) }, async () => { await syncToCloud(data) } ) } catch (error) { if (error.name === 'TimeoutError') { // 超时降级:先写入本地,后续再同步 await saveToLocalCache(data) console.log('同步超时,数据已暂存本地') } } }
死锁
了解了各种配置项的用法后,还需要警惕一个重要问题:死锁。Web Locks API 的锁不可重入,且浏览器不会自动检测或打破死锁,一旦代码逻辑不当,相关请求就会陷入永久等待。下面介绍几种典型的死锁场景。
同锁重入
Web Locks API 的锁是不可重入的,即使是同一个上下文重复请求已持有的锁,也会被阻塞。
同一个上下文已经持有了某把锁,回调内部又去请求同名的锁,由于锁不可重入,内层请求会永远等待外层释放,而外层又在等内层回调结束,形成自死锁。
// 错误示范:同锁重入导致永久阻塞
await navigator.locks.request('resource', async () => {
console.log('外层获取到锁')
// 试图再次获取同一个锁,会在这里永远等待
await navigator.locks.request('resource', async () => {
console.log('内层永远执行不到')
})
})
锁升级死锁
如果一个上下文先以 shared 模式持有某把锁,随后又在同一把锁上请求 exclusive 模式,由于独占锁需要等待所有共享锁释放,而这个共享锁正是自己持有的,于是陷入死锁。
// 错误示范:共享锁内升级为独占锁
await navigator.locks.request('resource', { mode: 'shared' }, async () => {
console.log('已持有 shared 锁')
// 试图升级为 exclusive,会永远等待自己释放 shared 锁
await navigator.locks.request('resource', { mode: 'exclusive' }, async () => {
console.log('永远执行不到')
})
})
这和同锁重入本质相同,但因为涉及模式变化,在实际使用读写锁时更容易被忽略。
循环等待
两个(或多个)上下文各自持有一把锁,又都试图获取对方持有的那把锁,形成循环依赖,彼此永远等待。
// 标签页 A
await navigator.locks.request('lock-x', async () => {
console.log('A 获取到 lock-x')
await sleep(100)
// 试图获取 lock-y,但此时 lock-y 被 B 持有
await navigator.locks.request('lock-y', async () => {
console.log('A 永远执行不到这里')
})
})
// 标签页 B
await navigator.locks.request('lock-y', async () => {
console.log('B 获取到 lock-y')
await sleep(100)
// 试图获取 lock-x,但此时 lock-x 被 A 持有
await navigator.locks.request('lock-x', async () => {
console.log('B 永远执行不到这里')
})
})
两个标签页都打印完第一句后双双卡住,谁也无法继续。这种死锁不限于两个标签页,也可能发生在 Tab 与 Worker 之间,甚至多个 Worker 之间。
避免死锁最直接的方式是 统一加锁顺序,如果所有上下文都按相同的先后顺序请求多把锁(比如先 X 后 Y),循环等待就不会发生。
此外,尽量减少同时持有多个锁的场景,必要时可以通过 ifAvailable 非阻塞尝试来避免无限等待。
查询锁
当遇到死锁或资源竞争问题时,首先需要了解当前锁的状态。Web Locks API 提供了 navigator.locks.query() 方法,用来查看当前锁空间下所有锁的状态。这在调试死锁、监控资源竞争、构建开发者工具面板等场景下非常有用。
const state = await navigator.locks.query()
query() 不接受任何参数,返回一个 Promise,resolve 的值是一个 LockManagerSnapshot 对象,包含两个属性:
-
held:一个数组,列出当前所有正在被持有的锁。每个元素是一个对象,包含以下字段:name:锁的名称mode:锁的模式,"exclusive"或"shared"clientId:持有该锁的上下文标识(标签页或 Worker 的唯一 ID)
-
pending:一个数组,列出当前所有正在等待队列中排队的锁请求,每个元素的字段与held相同
通过这两个数组,可以清晰地看到哪些锁正在被占用、被谁占用,以及有哪些请求正在排队等待。
query() 返回的是调用时刻的快照,不会实时更新。如果需要持续监控,需要定期轮询调用。
下面是一个实际的调试示例,展示如何利用 query() 来观察锁的状态变化:
// 模拟一个长时间持有锁的任务
navigator.locks.request('db-write', async () => {
console.log('任务 A:获取到 db-write 锁,开始执行...')
await sleep(5000) // 模拟耗时操作
console.log('任务 A:执行完毕,释放锁')
})
// 再发起两个请求,它们会进入等待队列
navigator.locks.request('db-write', async () => {
console.log('任务 B:获取到锁')
})
navigator.locks.request('db-write', async () => {
console.log('任务 C:获取到锁')
})
// 等待一小段时间,确保任务 A 已持有锁,B 和 C 进入队列
await sleep(100)
// 查询当前锁状态
const state = await navigator.locks.query()
console.log('当前被持有的锁:', state.held)
// [{ name: "db-write", mode: "exclusive", clientId: "xxx" }]
console.log('等待队列中的请求:', state.pending)
// [
// { name: "db-write", mode: "exclusive", clientId: "xxx" },
// { name: "db-write", mode: "exclusive", clientId: "xxx" }
// ]
最佳实践
前面分章节已充分讨论了 Web Locks API 的各项能力,这里将一些在实际项目中容易踩坑或值得养成习惯的点汇总在一起,方便在编码时快速对照。
缩短持锁时间
锁的持有时间直接决定了其他请求的等待时长。回调中应该只放必须在锁保护下执行的最小操作,其余逻辑放到锁外面。
// 不推荐:在锁内做了过多非必要操作
await navigator.locks.request('user-profile', async () => {
const profile = await fetchProfile()
await updateProfile(profile)
// 发送通知并不需要锁的保护,却延长了持锁时间
await sendNotification(profile)
await logAnalytics(profile)
})
// 推荐:只在锁内完成必须互斥的操作,其余移到锁外
const profile = await navigator.locks.request('user-profile', async () => {
const p = await fetchProfile()
await updateProfile(p)
return p
})
// 锁已释放,后续操作不会阻塞其他请求
await sendNotification(profile)
await logAnalytics(profile)
锁命名规范
随着项目规模增长,锁名称容易冲突。建议用「模块:资源」的命名方式,让锁的归属一目了然,也方便通过 query() 排查问题。
// 不推荐:含义模糊,容易与其他模块冲突
navigator.locks.request('sync', callback)
navigator.locks.request('data', callback)
// 推荐:带模块前缀,职责清晰
navigator.locks.request('editor:auto-save', callback)
navigator.locks.request('sync:cloud-push', callback)
navigator.locks.request('cache:invalidation', callback)
统一错误处理
request 可能产生多种错误,散落在各处处理容易遗漏。建议封装一个通用的 withLock 工具函数,在一个地方统一处理所有锁相关的异常。
Web Locks API 中常见的错误类型:
AbortError:请求在等待队列中被signal取消,或者被其他steal请求清除NotSupportedError:使用了非法的配置项组合(如同时设置steal和ifAvailable)- 回调内部抛出的业务异常:会通过
request的Promise向外传播
async function withLock(name, callback, options = {}) {
const { timeout, onTimeout, lockOptions = {} } = options
// 如果指定了超时,自动注入 signal
if (timeout && !lockOptions.signal) {
lockOptions.signal = AbortSignal.timeout(timeout)
}
try {
return await navigator.locks.request(name, lockOptions, callback)
} catch (error) {
// 超时:signal 为 AbortSignal.timeout 时抛出 TimeoutError
if (error.name === 'TimeoutError') {
console.warn(`[withLock] 获取锁 "${name}" 超时 (${timeout}ms)`)
if (onTimeout) return onTimeout()
throw error
}
// 主动取消或被 steal 清除
if (error.name === 'AbortError') {
console.warn(`[withLock] 锁请求 "${name}" 被取消`)
throw error
}
// 非法配置项组合
if (error.name === 'NotSupportedError') {
console.error(`[withLock] 锁 "${name}" 配置项不合法:`, error.message)
throw error
}
// 回调内部的业务异常,原样向外抛出
throw error
}
}
使用示例:
// 带 5s 超时和降级逻辑
const data = await withLock(
'sync:cloud-push',
async () => {
return await pushToCloud()
},
{
timeout: 5000,
onTimeout: () => {
console.log('同步超时,使用本地缓存')
return getCachedData()
}
}
)
// 配合 ifAvailable 使用
const result = await withLock(
'background:cleanup',
async (lock) => {
if (!lock) return null // 没拿到锁,跳过
await performCleanup()
return 'done'
},
{ lockOptions: { ifAvailable: true } }
)
兜底超时
没有超时的锁请求一旦遇到异常情况(持有者页面卡死、意外死锁),就会永远挂起。建议对所有生产环境中的锁请求都设置一个合理的超时上限,即使你认为「正常情况下不会等太久」。
// 不推荐:无超时保护
await navigator.locks.request('db-write', async () => {
await writeToDatabase(data)
})
// 推荐:始终带上超时兜底
await withLock('db-write', async () => {
await writeToDatabase(data)
}, { timeout: 10_000 })
总结
回头看,Web Locks API 做的事情其实很简单:给浏览器里的多标签页和 Worker 提供了一套正经的锁。以前要协调共享资源,要么靠 localStorage 事件轮询,要么硬上 SharedArrayBuffer + Atomics,写起来又绕又容易出 bug。现在一个 request 调用就搞定了,声明你要哪个资源、用什么模式,排队和释放的事情浏览器帮你管。锁的生命周期跟着回调函数走,回调结束锁就自动释放,从根本上杜绝了「忘记释放锁」这个经典坑。
几个配置项也给得恰到好处。ifAvailable 负责「试一下,拿不到就算了」;signal 让等待可以随时喊停,不管是用户主动取消还是超时降级都能干净退出;steal 是最后的杀手锏,专门留给僵尸锁这种极端情况。这几个选项组合一下,日常能遇到的并发协调场景基本都能覆盖。
要说最需要小心的,就是锁不可重入这件事。同锁重入、锁升级、循环等待,这几种死锁模式原理都不复杂,但业务代码一层套一层的时候真的很容易踩进去。养成两个习惯就好:多把锁永远按固定顺序加,持锁回调里别再请求同一把锁。做到这两点,大部分坑都能避开。
目前 Web Locks API 已在主流浏览器中获得广泛支持(Chrome 69+、Edge 79+、Safari 15.4+、Firefox 96+ 等),可放心在生产环境使用。