🔒 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,关注我了解更多实用的开源插件!

相关推荐
摸着石头过河的石头3 小时前
JavaScript继承的多种实现方式详解
前端·javascript
Ashley的成长之路3 小时前
NativeScript-Vue 开发指南:直接使用 Vue构建原生移动应用
前端·javascript·vue.js
软件技术NINI4 小时前
MATLAB疑难诊疗:从调试到优化的全攻略
javascript·css·python·html
知识分享小能手5 小时前
uni-app 入门学习教程,从入门到精通,uni-app组件 —— 知识点详解与实战案例(4)
前端·javascript·学习·微信小程序·小程序·前端框架·uni-app
苏打水com5 小时前
从 HTML/CSS/JS 到 React:前端进阶的平滑过渡指南
前端·javascript·html
一枚前端小能手5 小时前
🔐 单点登录还在手动跳转?这几个SSO实现技巧让你的用户体验飞起来
前端·javascript
全栈小55 小时前
【代码管理】在本地使用github和gitee之后,可能存在冲突,导致再次提交代码时提示Couldn‘t connect to server
gitee·github·代码管理工具
tianchang5 小时前
深入理解 JavaScript 异步机制:从语言语义到事件循环的全景图
前端·javascript
NocoBase5 小时前
11 个在 GitHub 上最受欢迎的开源无代码 AI 工具
低代码·ai·开源·github·无代码·ai agent·airtable·内部工具·app builder