Web Locks API

14 阅读9分钟

当用户同时打开多个标签页,或者页面与 Service Worker 并行运行时,它们很可能同时读写同一份共享资源,比如 IndexedDB 中的数据、通过 BroadcastChannel 传递的状态,甚至是一次只允许发起一个的网络请求。如果缺少协调机制,竞态条件几乎不可避免,轻则数据不一致,重则操作被覆盖丢失。

在传统多线程编程中,「锁」正是解决这类问题的经典手段:访问共享资源之前先获取锁,拿到锁的一方独占访问权,其他竞争者排队等待,访问结束后释放锁,排队者依次获得机会。Web Locks API 将这一思路带到了浏览器环境,为标签页与 Worker 之间的资源协调提供了一套原生的锁机制。不过这里的「之间」有一个隐含前提,它们必须​同源。浏览器的同源策略要求协议、域名、端口三者完全一致才算同源,localStorageIndexedDB 等存储都遵循这一规则,Web Locks 也不例外:同源的所有标签页和 Worker 共享一个锁空间,不同源之间的锁互不可见。

基本概念

请求锁的语法如下:

navigator.locks.request(lockName [,options], callback)

通过 request 来请求一个锁,接收三个参数:

  • 锁名称,在同一锁空间下,通过相同锁名称请求锁时,只有一个请求能获取到锁,其它的请求会进入到请求队列,等到这个锁被释放时,从请求队列选择一个请求让其获取到锁。

    一个典型的请求锁的生命周期如下:

    请求锁 → [等待队列] → 获得锁 → 执行回调 → 回调结束 → 释放锁
    
  • 一个可选的配置项,可以对锁进行更高级的设置

  • 回调函数,当请求获取到锁时,该回调函数将会被执行。回调函数接收一个 Lock 对象作为参数,包含以下属性:

    • name:锁的名称,即请求时传入的 lockName
    • mode:锁的模式,值为 "exclusive""shared",见下文「锁模式章节」

    这个回调函数可以是异步的,当这个函数返回的 Promise resolved 或者 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 的值),requestPromise 会以该值 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 同步抛出异常,或返回的 PromiserejectrequestPromise 会以同样的错误 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)
      }
    }
    

stealifAvailable 不能同时使用,会抛出 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:使用了非法的配置项组合(如同时设置 stealifAvailable
  • 回调内部抛出的业务异常:会通过 requestPromise 向外传播
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+ 等),可放心在生产环境使用。

Web Locks API 浏览器兼容性

参考资料