JavaScript 中的死锁(Deadlock):两个异步任务互相等待资源的场景与解法

JavaScript 中的死锁:两个异步任务互相等待资源的场景与解法

大家好,我是你们的技术讲师。今天我们来深入探讨一个在现代前端开发中看似“不常见”,实则可能悄悄埋下隐患的问题——JavaScript 中的死锁(Deadlock)

很多人会说:“JavaScript 是单线程的,怎么可能出现死锁?”
这没错,但问题在于:我们常把“单线程”误解为“不会并发冲突”。而实际上,在异步编程模型中,尤其是涉及 Promise、async/await 和共享状态时,死锁依然可能发生,而且更隐蔽。


一、什么是死锁?从经典场景说起

首先明确概念:

死锁(Deadlock)是指两个或多个进程/任务因为相互等待对方释放资源而永远无法继续执行的状态。

在传统多线程语言如 Java 或 C++ 中,死锁很常见,比如:

  • 线程 A 拿到锁1,试图获取锁2;
  • 线程 B 拿到锁2,试图获取锁1;
    → 两者都卡住,形成死循环。

但在 JavaScript 中,由于没有真正的并行线程(除了 Web Workers),我们通常认为不会发生这种经典的“互斥锁死锁”。

然而!当我们在使用异步操作(如 setTimeoutfetchPromise)和共享变量时,如果逻辑设计不当,也会出现类似死锁的现象——只是表现形式不同:不是线程阻塞,而是任务永远挂起,无法推进。


二、JavaScript 死锁的经典案例:两个异步任务互相等待资源

让我们看一个典型的例子:

let resourceA = false;
let resourceB = false;

async function taskA() {
  console.log("Task A: waiting for resource A");
  while (!resourceA) {
    await new Promise(resolve => setTimeout(resolve, 10));
  }
  console.log("Task A: got resource A");

  // 尝试获取资源B(此时它被taskB持有)
  console.log("Task A: waiting for resource B");
  while (!resourceB) {
    await new Promise(resolve => setTimeout(resolve, 10));
  }
  console.log("Task A: got resource B - done!");
}

async function taskB() {
  console.log("Task B: waiting for resource B");
  while (!resourceB) {
    await new Promise(resolve => setTimeout(resolve, 10));
  }
  console.log("Task B: got resource B");

  // 尝试获取资源A(此时它被taskA持有)
  console.log("Task B: waiting for resource A");
  while (!resourceA) {
    await new Promise(resolve => setTimeout(resolve, 10));
  }
  console.log("Task B: got resource A - done!");
}

现在运行这两个函数:

taskA();
taskB();

你会发现什么?

✅ 控制台输出如下:

Task A: waiting for resource A
Task B: waiting for resource B

然后程序就卡住了!不再有任何输出!

🔍 分析原因:

  • taskA 进入第一个 while 循环,等待 resourceA 变成 true
  • 同时 taskB 进入自己的 while 循环,等待 resourceB 变成 true
  • 但是这两个任务都没有机会去设置 resourceAresourceBtrue —— 因为它们都在等对方先完成!

这就是典型的 异步死锁:两个异步任务互相等待彼此释放的资源,但由于没有任何一方能前进,整个流程陷入僵局。

⚠️ 注意:这不是 JavaScript 引擎的问题,而是开发者对异步协作机制理解不足导致的设计缺陷。


三、为什么这个场景容易被忽略?

原因 说明
单线程假象 JS 是单线程,让人误以为不会有竞争条件或死锁
异步非阻塞 await 不阻塞主线程,看起来像“无影响”,实则可能造成逻辑死循环
缺乏显式同步机制 没有 lock/unlock 显式控制,死锁隐藏更深
调试困难 控制台没报错,只是“静默挂起”,难以定位

这类 bug 在复杂项目中尤其危险,比如:

  • 数据库连接池分配失败;
  • API 请求链路互相等待响应;
  • 用户交互触发多个 async 函数,其中某个依赖另一个未完成的任务。

四、如何避免?三种实用解法

✅ 解法一:使用信号量(Semaphore)模式 + Promise.resolve()

核心思想:用一个统一的“许可”机制代替手动轮询。

// 定义一个信号量管理器
class Semaphore {
  constructor(initialCount = 1) {
    this.count = initialCount;
    this.waiting = []; // 存储等待的 promise
  }

  acquire() {
    return new Promise((resolve) => {
      if (this.count > 0) {
        this.count--;
        resolve();
      } else {
        this.waiting.push(resolve);
      }
    });
  }

  release() {
    if (this.waiting.length > 0) {
      const next = this.waiting.shift();
      next();
    } else {
      this.count++;
    }
  }
}

// 使用示例
const sem = new Semaphore(1);

async function taskA() {
  console.log("Task A: acquiring semaphore...");
  await sem.acquire();
  console.log("Task A: acquired semaphore");

  // 模拟耗时操作
  await new Promise(r => setTimeout(r, 500));

  console.log("Task A: releasing semaphore...");
  sem.release();
}

async function taskB() {
  console.log("Task B: acquiring semaphore...");
  await sem.acquire();
  console.log("Task B: acquired semaphore");

  await new Promise(r => setTimeout(r, 500));

  console.log("Task B: releasing semaphore...");
  sem.release();
}

// 执行
taskA();
taskB();

✅ 输出:

Task A: acquiring semaphore...
Task A: acquired semaphore
Task A: releasing semaphore...
Task B: acquiring semaphore...
Task B: acquired semaphore
Task B: releasing semaphore...

✔️ 这样就不会死锁了!因为只有一个任务可以拿到信号量,另一个必须排队等待。

📌 优点:简单清晰,适合大多数场景;
📌 缺点:只能保证一个任务访问资源,不适合需要多个并发访问的情况(可扩展为多信号量)。


✅ 解法二:使用事件驱动 + Promise.allSettled(推荐用于复杂场景)

如果你的任务之间不是完全互斥,而是可以按顺序执行或有条件地并行,可以用事件驱动的方式替代硬性等待。

// 模拟两个任务都需要某个资源
let resourceReady = false;

function waitForResource() {
  return new Promise((resolve) => {
    const interval = setInterval(() => {
      if (resourceReady) {
        clearInterval(interval);
        resolve();
      }
    }, 10);
  });
}

async function taskA() {
  console.log("Task A: waiting for resource");
  await waitForResource();
  console.log("Task A: got resource");
}

async function taskB() {
  console.log("Task B: waiting for resource");
  await waitForResource();
  console.log("Task B: got resource");
}

// 启动资源准备
setTimeout(() => {
  resourceReady = true;
  console.log("Resource is ready!");
}, 1000);

// 并发启动两个任务
Promise.allSettled([taskA(), taskB()])
  .then(results => {
    console.log("All tasks completed:", results.map(r => r.status));
  });

✅ 输出:

Task A: waiting for resource
Task B: waiting for resource
Resource is ready!
Task A: got resource
Task B: got resource
All tasks completed: ['fulfilled', 'fulfilled']

📌 关键点:不再是“无限等待”,而是通过外部事件通知资源可用,从而打破死锁。


✅ 解法三:超时机制 + 错误处理(防御性编程)

有时你无法完全控制资源何时可用,这时应加入超时保护,防止任务永远卡住。

async function safeWaitForResource(timeoutMs = 3000) {
  return Promise.race([
    new Promise(resolve => setTimeout(resolve, timeoutMs)),
    new Promise((_, reject) => {
      let attempts = 0;
      const checkInterval = setInterval(() => {
        attempts++;
        if (attempts > 100) { // 最多尝试100次(约1秒)
          clearInterval(checkInterval);
          reject(new Error("Timeout: Resource not available"));
        }
        if (resourceReady) {
          clearInterval(checkInterval);
          resolve();
        }
      }, 10);
    })
  ]);
}

async function taskA() {
  try {
    console.log("Task A: waiting for resource with timeout");
    await safeWaitForResource(2000);
    console.log("Task A: got resource");
  } catch (err) {
    console.error("Task A failed:", err.message);
  }
}

📌 这种方式虽然不能彻底避免死锁,但能让你的系统具备容错能力,而不是“永久挂起”。


五、对比总结:三种解法适用场景

解法 适用场景 是否防死锁 复杂度 推荐指数
信号量(Semaphore) 多个任务需互斥访问共享资源 ✅ 是 ★☆☆ ⭐⭐⭐⭐
事件驱动 + Promise.allSettled 资源由外部事件触发 ✅ 是 ★★☆ ⭐⭐⭐⭐
超时机制 不可控资源或调试阶段 ❌ 部分有效 ★☆☆ ⭐⭐⭐

💡 实际项目建议组合使用:例如主流程用信号量控制关键资源,次要任务用事件驱动,同时加上全局超时兜底。


六、进阶思考:Web Workers 与死锁的关系?

有人可能会问:“既然 JS 是单线程,那 Web Workers 不就是多线程吗?会不会更安全?”

答案是:不一定!

Web Workers 之间也可以发生死锁,特别是当你在 Worker 间传递消息并等待响应时:

// workerA.js
onmessage = async (e) => {
  postMessage('ready');
  const response = await new Promise(r => setTimeout(r, 1000)); // 模拟等待
  postMessage('done');
};

// main.js
const workerA = new Worker('workerA.js');
workerA.postMessage('start');

workerA.onmessage = async (e) => {
  if (e.data === 'ready') {
    // 如果这里又想让 workerA 做其他事,但 workerA 已经在忙...
    // 就可能造成“伪死锁”
  }
};

⚠️ 即使用了 Web Workers,也必须小心跨线程通信的调度顺序。否则同样可能出现“互相等待”的情况。


七、结语:死锁不是不可能,而是我们低估了它的存在形式

JavaScript 的异步特性带来了极大的灵活性,但也隐藏了潜在的风险。死锁不再是 C/C++ 或 Java 的专利,它已经悄然出现在我们的回调地狱、Promise 链和 async/await 中。

记住三点:

  1. 不要盲目相信“单线程=安全”
  2. 轮询等待(while + await)极易引发死锁
  3. 合理的资源管理(信号量、事件通知、超时)才是王道

下次你在写异步代码时,请问问自己:“这段逻辑会不会变成两个任务互相等对方?”
如果有疑问,那就用上面的方法之一重构它。

这才是真正专业的开发者思维。


希望这篇文章能帮你理解 JavaScript 中的死锁本质,并在实际开发中避开这些坑。如果你觉得有用,欢迎收藏、转发,或者留言讨论你的实战经验!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注