🔒 JavaScript 不是单线程吗?怎么还能上“锁”?!

在 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 啊,这么简单的问题!"。

javascript 复制代码
await withdraw(30);
await withdraw(80);

但是,这位同学请你先别急,在同一个作用域下使用 await 确实可以轻松解决问题,可是很多时候情况并不是这么简单。 假设有一个取款按钮,用户在第一次操作还没有完成时又请求了第二次,这样的连续调用是不是就不能使用 await 处理了呢?

javascript 复制代码
async 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 函数是幂等的,多次调用不会产生副作用。

重要提示: 如果未调用 releasemutex 会一直保持锁定状态,应用可能会死锁 。请确保在任何情况下都调用 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"));

注意:取消操作只会影响等待中的锁请求,不会强制释放当前已经持有的锁。因此,即使调用了 cancelmutex 也可能依旧不可用。

等待 mutex 可用

如果只是想等待 mutex 可用(而不是立刻获取锁),可以调用 waitForUnlock,它会返回一个 Promise,当 mutex 再次可获取时 resolve。

但是,这个操作本身不会锁定 mutex,因此一旦经过异步边界,不保证 mutex 依然可用。

  • Promise
javascript 复制代码
mutex.waitForUnlock().then(() => {
  // ...
});
  • async / await
javascript 复制代码
await mutex.waitForUnlock();

// ...

限制等待 mutex 的时间

有时需要限制程序等待 mutex 可用的时间,可以使用 withTimeout 装饰器,它会修改 acquirerunExclusive 的行为。

javascript 复制代码
import { withTimeout, E_TIMEOUT } from "async-mutex";

const mutexWithTimeout = withTimeout(new Mutex(), 100);

被装饰后的 mutex API 不变。

withTimeout 的第二个参数是超时时间(毫秒),超过时间后,acquirerunExclusive 返回的 Promise 会被 reject,错误为 E_TIMEOUT。 在超时的情况下,runExclusive 不会执行回调。

withTimeout 的第三个参数可选,可以自定义抛出的错误。

javascript 复制代码
const mutexWithTimeout = withTimeout(new Mutex(), 100, new Error("new fancy error"));

如果 mutex 不可用则立即失败

如果不希望等待锁,可以使用 tryAcquire 装饰器。 它会修改 acquirerunExclusive 的行为,在 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,关注我了解更多实用的开源插件!

相关推荐
鹏多多1 分钟前
H5开发避坑!解决Safari浏览器的video会覆盖z-index:1的绝对定位元素
前端·javascript·vue.js
qq_2546744125 分钟前
SSL/TLS
网络协议·github·ssl
一只小阿乐30 分钟前
vue3封装alert 提示组件 仿element-plus
前端·javascript·vue.js·vue3
华洛1 小时前
解读麦肯锡报告:Agent落地的六大经验教训
前端·javascript·产品经理
艾小码1 小时前
还在重复造轮子?掌握这7个原则,让你的Vue组件复用性飙升!
前端·javascript·vue.js
探索宇宙真理.1 小时前
React Native Community CLI命令执行 | CVE-2025-11953 复现&研究
javascript·经验分享·react native·react.js·安全漏洞
QT 小鲜肉1 小时前
【Git、GitHub、Gitee】GitLab的概念、注册流程、远程仓库操作以及高级功能详解(超详细)
git·qt·gitee·gitlab·github
. . . . .2 小时前
React底层原理
javascript·react.js
CozyOct13 小时前
⚡️2025-11-10GitHub日榜Top5|AI黑客漏洞发现工具
github
木易 士心3 小时前
Vue 3 Props 响应式深度解析:从原理到最佳实践
前端·javascript·vue.js