JavaScript 中的死锁:两个异步任务互相等待资源的场景与解法
大家好,我是你们的技术讲师。今天我们来深入探讨一个在现代前端开发中看似“不常见”,实则可能悄悄埋下隐患的问题——JavaScript 中的死锁(Deadlock)。
很多人会说:“JavaScript 是单线程的,怎么可能出现死锁?”
这没错,但问题在于:我们常把“单线程”误解为“不会并发冲突”。而实际上,在异步编程模型中,尤其是涉及 Promise、async/await 和共享状态时,死锁依然可能发生,而且更隐蔽。
一、什么是死锁?从经典场景说起
首先明确概念:
死锁(Deadlock)是指两个或多个进程/任务因为相互等待对方释放资源而永远无法继续执行的状态。
在传统多线程语言如 Java 或 C++ 中,死锁很常见,比如:
- 线程 A 拿到锁1,试图获取锁2;
- 线程 B 拿到锁2,试图获取锁1;
→ 两者都卡住,形成死循环。
但在 JavaScript 中,由于没有真正的并行线程(除了 Web Workers),我们通常认为不会发生这种经典的“互斥锁死锁”。
然而!当我们在使用异步操作(如 setTimeout、fetch、Promise)和共享变量时,如果逻辑设计不当,也会出现类似死锁的现象——只是表现形式不同:不是线程阻塞,而是任务永远挂起,无法推进。
二、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; - 但是这两个任务都没有机会去设置
resourceA或resourceB成true—— 因为它们都在等对方先完成!
这就是典型的 异步死锁:两个异步任务互相等待彼此释放的资源,但由于没有任何一方能前进,整个流程陷入僵局。
⚠️ 注意:这不是 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 中。
记住三点:
- 不要盲目相信“单线程=安全”;
- 轮询等待(while + await)极易引发死锁;
- 合理的资源管理(信号量、事件通知、超时)才是王道。
下次你在写异步代码时,请问问自己:“这段逻辑会不会变成两个任务互相等对方?”
如果有疑问,那就用上面的方法之一重构它。
这才是真正专业的开发者思维。
希望这篇文章能帮你理解 JavaScript 中的死锁本质,并在实际开发中避开这些坑。如果你觉得有用,欢迎收藏、转发,或者留言讨论你的实战经验!