使用 Promise 互斥锁在 JavaScript 中序列化异步任务
使用 Promise 互斥锁在 JavaScript 中序列化重叠的异步任务:AsyncLock 以先进先出方式处理请求,从而保持 UI 状态的有序性,无需线程锁。
来源: Concurrency management in JavaScript: the mutex (mutual exclusion) lock pattern
并发控制存在于每一个成熟的代码库中,而 JavaScript 使得这一话题显得更为突出,因为该语言并未提供类似操作系统的多线程锁机制。单个线程会按顺序执行栈上的操作。异步任务仍然以交错方式运行:Promise 会依次触发、作为微任务执行,待处理完成后再通过事件循环重新进入执行流程。Promise 排队后的回调顺序有明确规定,但端到端的执行时间却无法确定——外部延迟决定了哪个网络请求会先完成。在 Promise 解决或被拒绝之前,其 .then、.catch 和 .finally 处理函数不会进入执行循环。正是这个间隙导致了竞态条件的出现,也使得一些特定的设计模式变得十分有用。
以银行风格的用户界面为例。客户期望在存取款后看到的账户余额与界面显示的一致。服务器仍是数据来源,但客户端仍需发起更新,并且必须以合理的顺序呈现响应。
如果没有并发控制,就会出现一种常见的故障情况。有人打开应用,看到支票账户里有100美元,又用支票存入100美元,于是期望账户里有200美元。在存款请求尚未处理完成时,他们就发起转账操作,却假设支票账户的余额已经是200美元。如果数据中心的处理速度较慢,导致转账在存款确认之前就写入存储系统,那么即使用户的意图是正确的,转账也会因资金不足而失败。
客户端框架很少提供类似 pthread 的原始接口,因此应用程序代码必须在承诺机制之上自行实现顺序控制。目前有几种方法,包括乐观锁和悲观锁。另一种方案是通过互斥锁进行序列化处理:将关键代码段排成队列,确保一次只有一段能够执行。下面的 TypeScript 示例即为存款/转账场景实现了这一思路:
class AsyncLock {
private disableNext: Promise<void>;
constructor() {
this.disableNext = Promise.resolve();
}
// force async task to wait in line
async acquire(): Promise<() => void> {
let release!: () => void;
const currentLock = this.disableNext; // assign private reference to the most recent promise created
this.disableNext = new Promise<void>(resolve => { // create a new promise for the next request in queue
release = resolve;
});
await currentLock;
return release;
}
}
// how to use AsyncLock
const lock = new AsyncLock();
let balance = 100;
type TransactionType = "withdraw" | "deposit";
async function updateBalance(amount: number, transactionType: TransactionType): Promise<void> {
const release = await lock.acquire(); // wait in line
try {
console.log(`Reading balance... Current: ${balance}`);
await new Promise(r => setTimeout(r, 50)); // simulate database delay
if (transactionType === "withdraw" && balance >= amount) { // withdraw
balance -= amount;
console.log(`Withdraw: ${amount}. Current: ${balance}`);
} else if (transactionType === "deposit") { // deposit
balance += amount;
console.log(`Deposit: ${amount}. Current: ${balance}`);
} else {
console.log("NOP");
}
} finally {
release(); // let the next task run by calling the resolve method on the current promise
}
}
updateBalance(100, "deposit"); // deposit $100 into checking
updateBalance(200, "withdraw"); // transfer (withdraw) $200 to savings
锁链的工作原理
调用 new AsyncLock() 会创建一个全新的锁对象。通过设置 this.disableNext = Promise.resolve(),可以为链式调用提供已解决的承诺,从而使第一个调用者能够立即执行。每次 lock.acquire() 都需要观察该实例上的共享链:它将之前的承诺保存为 currentLock,然后用一个新的待处理承诺替换 this.disableNext。由于 this 指向的是长期存在的锁,后续的 acquire() 调用会看到已更新的承诺头并在此之后等待。当当前的关键代码段执行完毕后,release() 会解决正在处理的承诺,从而解除后续调用的阻塞状态。
其效果相当于一个可控制的 FIFO 队列。后续请求无法跳过前面的请求,因此用户界面始终按照处理顺序显示。任意数量的重叠调用都可以依次处理,而无需创建额外的线程。通过使用类字段 disableNext 将该共享状态保存在同一个对象上,这就是为什么互斥锁模式适用于那些需要在不冻结页面其他部分的情况下对共享客户端状态进行有序处理的 JavaScript 应用程序。
请保持临界区尽可能短:获取锁、执行会影响平衡的请求、根据响应更新本地状态,然后释放锁。过长的临界区会因等待无关的 UI 操作而使共享同一锁实例的其他操作停滞,从而违背设计初衷。