在 JavaScript 日常开发中,经常会遇到这样的情况:多个异步任务需要同时访问或修改同一个资源。这个资源可能很简单,比如内存中的一个变量;也可能复杂一些,比如一份配置文件、一条数据库记录或者是某个外部服务的接口调用,只要它是共享的,就有可能被不同的任务同时操作。
问题在于,异步任务不像同步代码那样一行一行地按顺序执行,而是可能同时进行。如果没有任何机制来协调它们的先后顺序,就会出现混乱:有的任务可能会覆盖掉别的任务的修改,有的任务可能会在读取旧数据的基础上做出错误的决策,最终导致整个系统状态不一致。
举一个生活中常见的例子,假设有一个简单的余额变量 balance
和一个取款函数 withdraw
,如下所示:
javascript
let balance = 100;
async function withdraw(amount) {
const current = balance;
// 模拟异步延迟,比如数据库写入
await sleep(300);
if (current >= amount) {
balance = current - amount;
console.log(`取款 ${amount} 成功,剩余余额: ${balance}`);
} else {
console.log(`取款 ${amount} 失败,余额不足`);
}
}
// 同时触发两个取款操作
withdraw(30);
withdraw(80);
执行结果如下:
取款 30 成功,剩余余额: 70
取款 80 成功,剩余余额: 20
可以看到结果明显不正确,问题在于两次操作几乎同时读到 balance
为 100。
- 第 1 次操作,余额 100 扣 30 → balance = 70
- 第 2 次操作,程序也以为余额有 100 扣 80 → balance = 20
看到这里,有的同学可能会说:"你用
await
啊,这么简单的问题!"。
javascriptawait withdraw(30); await withdraw(80);
但是,这位同学请你先别急,在同一个作用域下使用
await
确实可以轻松解决问题,可是很多时候情况并不是这么简单。 假设有一个取款按钮,用户在第一次操作还没有完成时又请求了第二次,这样的连续调用是不是就不能使用await
处理了呢?
javascriptasync function onWithdrawClick() { // 即使添加了 await,不同的点击事件仍会并发执行各自的 withdraw 调用 await withdraw(); }
看到点击事件,又有的同学可能会说:"那你加个防抖啊,这不是有手就行吗?"
这位同学请你也先别急,请注意我们两次调用
withdraw
的参数并不相同,难道说要舍弃最后一次之前的调用吗? 假设使用防抖,用户先取款 30 元再取款 80 元,最终的结果是 80 元取款成功,30 元的取款却没有响应,这显然不符合预期。而且有可能并不是在同一个终端发起的
withdraw
,所以暂不考虑以上两种方案。简单起见,后续代码中也是直接连续两次调用
withdraw
模拟操作,就不再赘述原因。
那么这个问题应该如何解决呢?
大家都知道,在支持多线程的语言中,如果有多个线程共享一个变量,通常使用同步锁 (synchronized)来保证同一时间只有一个线程可以修改共享变量。以 Java 为例,对 withdraw
进行改造如下:
java
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class BankAccount {
private final Lock lock = new ReentrantLock(true);
private int balance = 100;
public void withdraw(int amount) {
// 获取锁,保证临界区安全
lock.lock();
try {
// 读取余额
int current = balance;
// 模拟异步操作的延迟
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (current >= amount) {
balance = current - amount;
System.out.println("取款 " + amount + " 成功,剩余余额: " + balance);
} else {
System.out.println("取款 " + amount + " 失败,余额不足");
}
} finally {
// 释放锁
lock.unlock();
}
}
}
public class Main {
public static void main(String[] args) {
BankAccount account = new BankAccount();
// 两个线程几乎同时执行取款操作
new Thread(() -> account.withdraw(30)).start();
new Thread(() -> account.withdraw(80)).start();
}
}
执行结果如下:
取款 30 成功,剩余余额: 70
取款 80 失败,余额不足
可以发现执行结果稳定正确,达到了预期的效果。
可是在 JavaScript 中,并没有像 Java 那样的 ReentrantLock
类,那么能不能编写一个类似于 ReentrantLock
的类来实现这么一个互斥锁的功能呢?
答案是肯定的,借助 Promise 的特性,完全可以自己实现一个轻量级的、可用于异步任务的互斥锁(目前只考虑类公平锁)。
接下来,就使用 Promise 来实现一个迷你版的 Mutex,从而在异步环境下安全地操作共享资源。
javascript
class Mutex {
_locked = false;
_queue = [];
lock() {
if (this._locked) {
return new Promise((resolve) => {
this._queue.push(resolve);
});
}
this._locked = true;
return Promise.resolve();
}
unlock() {
if (this._queue.length > 0) {
const next = this._queue.shift();
if (next) {
next();
}
return;
}
this._locked = false;
}
}
_locked
标记锁是否被占用,_queue
存储等待锁的任务。
lock
方法尝试获取锁,如果锁被占用就返回一个 Promise 并加入队列等待,否则立即获得锁。
unlock
方法释放锁,如果队列中有等待任务,就依次唤醒下一个,否则将锁标记为空闲,从而保证异步操作按顺序执行临界区代码。
现在使用这个迷你版的 Mutex 来改造代码:
javascript
const mutex = new Mutex();
let balance = 100;
async function withdraw(amount) {
await mutex.lock();
try {
const current = balance;
await sleep(300);
if (current >= amount) {
balance = current - amount;
console.log(`取款 ${amount} 成功,剩余余额: ${balance}`);
} else {
console.log(`取款 ${amount} 失败,余额不足`);
}
} finally {
mutex.unlock();
}
}
withdraw(30);
withdraw(80);
执行结果如下:
取款 30 成功,剩余余额: 70
取款 80 失败,余额不足
结果正确!
即使在多个异步操作几乎同时执行的情况下,这个迷你版的 Mutex 也能够保证共享资源的访问是互斥的,避免了数据竞争和状态混乱。
当然,这个迷你版的 Mutex 只是一个最小实现,仅用于验证思路的可行性。在实际的生产环境中,通常不建议使用自制的迷你版 Mutex,因为它缺乏完整的边界检查、等待超时、错误处理以及更复杂场景下的健壮性保证。
推荐使用社区成熟的库,例如 async-mutex
,它封装了完整的互斥锁功能,提供了可靠的方法来保证异步任务安全访问共享资源,同时代码简洁、易读,并经过社区验证,能够应对更复杂的并发场景。
使用 async-mutex
改造示例如下:
javascript
import { Mutex } from "async-mutex";
const mutex = new Mutex();
let balance = 100;
async function withdraw(amount) {
await mutex.acquire();
try {
const current = balance;
await sleep(300);
if (current >= amount) {
balance = current - amount;
console.log(`取款 ${amount} 成功,剩余余额: ${balance}`);
} else {
console.log(`取款 ${amount} 失败,余额不足`);
}
} finally {
mutex.release();
}
}
async-mutex
Github: github.com/DirtyHairy/...
Mutex 通常指的是一种用于同步并发进程的数据结构。
例如,在访问一个非线程安全资源 之前,线程会先锁定 mutex
,这会确保该线程在其他线程持有锁期间被阻塞,从而强制实现对资源的独占访问。操作完成后,线程释放锁,其他线程才可以继续获取锁并访问该资源。
尽管 JavaScript 严格来说是单线程 的,但由于它的异步执行模型 ,依然可能出现需要同步原语的竞态条件。
比如,与 Web Worker 通信时,需要连续发送多个消息才能完成某个任务。在消息异步交换的过程中,完全可能再次调用。如果在异步过程中状态处理不当,就会出现难以修复、甚至更难追踪的竞态问题。
这个库的作用就是将 Mutex 的概念引入到 JavaScript 中,锁定 mutex
会返回一个 Promise,该 Promise 会在 mutex
可用时才 resolve。当异步流程完成后(通常会经历多次事件循环),调用者需要调用释放函数来释放 mutex
,以便下一个等待中的任务继续执行。
安装
shell
# npm
npm install async-mutex
# pnpm
pnpm add async-mutex
# yarn
yarn add async-mutex
该库由 TypeScript 编写,可以在任何支持 ES5、ES6 Promise 和 Array.isArray
的环境中使用。在较老的浏览器中,可以使用 shim 支持(例如 core-js)。
创建 mutex
javascript
import { Mutex } from "async-mutex";
const mutex = new Mutex();
同步执行代码
- Promise
javascript
mutex.runExclusive(() => {
// ...
}).then((result) => {
// ...
});
- async / await
javascript
await mutex.runExclusive(async () => {
// ...
});
runExclusive
会在 mutex
解锁后执行提供的回调函数,该函数可以返回一个 Promise,当 Promise 被 resolve 或 reject(或者是一个普通函数执行完成)后 mutex
会被释放。
runExclusive
返回一个 Promise,其状态与回调函数的返回结果一致。如果在回调函数执行过程中抛出异常,mutex
也会被释放,并且返回结果会被 reject。
手动加锁 / 释放
- Promise
javascript
mutex.acquire().then((release) => {
// ...
release();
});
- async / await
javascript
const release = await mutex.acquire();
try {
// ...
} finally {
release();
}
acquire
会返回一个 Promise,当 mutex
可用时 resolve。该 Promise 会解析为一个 release
函数,必须调用该函数来释放锁。
release
函数是幂等的,多次调用不会产生副作用。
重要提示: 如果未调用 release
,mutex
会一直保持锁定状态,应用可能会死锁 。请确保在任何情况下都调用 release
,并妥善处理异常。
无作用域的释放
除了调用 acquire
返回的 release
回调,还可以直接在 mutex
上调用 release
。
javascript
mutex.release();
检查 mutex 是否被锁定
javascript
mutex.isLocked();
取消等待中的锁请求
可以调用 cancel
来取消所有等待中的锁请求,被取消的请求会以 E_CANCELED
reject。
- Promise
javascript
import { E_CANCELED } from "async-mutex";
mutex.runExclusive(() => {
// ...
}).then(() => {
// ...
}).catch(e => {
if (e === E_CANCELED) {
// ...
}
});
- async / await
javascript
import { E_CANCELED } from "async-mutex";
try {
await mutex.runExclusive(() => {
// ...
});
} catch (e) {
if (e === E_CANCELED) {
// ...
}
}
这对 acquire
同样适用,如果 acquire
被取消,返回的 Promise 也会被 reject,错误为 E_CANCELED
。
你可以通过向 Mutex 构造函数传入自定义错误来修改抛出的错误。
javascript
const mutex = new Mutex(new Error("fancy custom error"));
注意:取消操作只会影响等待中的锁请求,不会强制释放当前已经持有的锁。因此,即使调用了 cancel
,mutex
也可能依旧不可用。
等待 mutex 可用
如果只是想等待 mutex
可用(而不是立刻获取锁),可以调用 waitForUnlock
,它会返回一个 Promise,当 mutex
再次可获取时 resolve。
但是,这个操作本身不会锁定 mutex
,因此一旦经过异步边界,不保证 mutex
依然可用。
- Promise
javascript
mutex.waitForUnlock().then(() => {
// ...
});
- async / await
javascript
await mutex.waitForUnlock();
// ...
限制等待 mutex 的时间
有时需要限制程序等待 mutex
可用的时间,可以使用 withTimeout
装饰器,它会修改 acquire
和 runExclusive
的行为。
javascript
import { withTimeout, E_TIMEOUT } from "async-mutex";
const mutexWithTimeout = withTimeout(new Mutex(), 100);
被装饰后的 mutex
API 不变。
withTimeout
的第二个参数是超时时间(毫秒),超过时间后,acquire
和 runExclusive
返回的 Promise 会被 reject,错误为 E_TIMEOUT
。 在超时的情况下,runExclusive
不会执行回调。
withTimeout
的第三个参数可选,可以自定义抛出的错误。
javascript
const mutexWithTimeout = withTimeout(new Mutex(), 100, new Error("new fancy error"));
如果 mutex 不可用则立即失败
如果不希望等待锁,可以使用 tryAcquire
装饰器。 它会修改 acquire
和 runExclusive
的行为,在 mutex
不可用时立即抛出 E_ALREADY_LOCKED
。
- Promise
javascript
import { tryAcquire, E_ALREADY_LOCKED } from "async-mutex";
tryAcquire(mutex).runExclusive(() => {
// ...
}).then(() => {
// ...
}).catch(e => {
if (e === E_ALREADY_LOCKED) {
// ...
}
});
- async / await
javascript
import { tryAcquire, E_ALREADY_LOCKED } from "async-mutex";
try {
await tryAcquire(mutex).runExclusive(() => {
// ...
});
} catch (e) {
if (e === E_ALREADY_LOCKED) {
// ...
}
}
同样,你可以传入自定义错误作为第二个参数。
javascript
tryAcquire(mutex, new Error("new fancy error"))
.runExclusive(() => {
// ...
});
结语
本次插件分享到此结束,感谢你的阅读,如果对你有帮助请给我一个 👍 支持吧!
🙂 我是 xiaohe0601,关注我了解更多实用的开源插件!