死锁的四个条件:破坏其中任意一个就够了
互斥、持有并等待、不可抢占、循环等待——四个同时成立才会死锁。工程上最容易破坏的是循环等待:全局给锁定一个顺序。
死锁不是偶发 bug,是四个条件同时满足的必然结果。理解这一点,修法就变得机械:破坏任意一个条件。
四个条件
| 条件 | 含义 |
|---|---|
| 互斥 | 资源同一时刻只能被一个持有者占用 |
| 持有并等待 | 拿着一个锁,还去申请另一个 |
| 不可抢占 | 锁不能被强行夺走 |
| 循环等待 | 存在 A 等 B、B 等 A 的环 |
前三条通常改不掉:锁的本质就是互斥,抢占会破坏临界区假设。能动手的基本只有第四条。
破坏循环等待:定序
给所有锁分配一个全局顺序,只允许按序加锁:
// 每个资源有稳定的 id
const order = (a: Account, b: Account) => (a.id < b.id ? [a, b] : [b, a]);
async function transfer(from: Account, to: Account, amount: number) {
const [first, second] = order(from, to);
await first.lock();
try {
await second.lock();
try {
// 此刻两个锁都已按全局顺序持有,不可能成环
first.balance -= amount;
second.balance += amount;
} finally { second.unlock(); }
} finally { first.unlock(); }
}
转账是最经典的例子:不排序时 A→B 与 B→A 并发就是教科书级死锁。排序后环不可能存在。
破坏持有并等待:一次申请全部
要么全部拿到,要么一把不拿。代价是降低并发度,且需要能原子获取多个锁的机制。
破坏不可抢占:加超时
if (!await lock.tryAcquire({ timeoutMs: 500 })) {
// 放弃已持有的,回退重试
}
这不是消除死锁,而是把「永久卡死」变成「可重试的失败」。必须有回退路径,否则只是把死锁变成了报错。
检测而不是预防
数据库普遍选这条路:允许死锁发生,检测到环就回滚其中一个事务。所以生产代码必须能处理「事务被回滚并重试」:
for (let attempt = 0; attempt < 3; attempt++) {
try { return await db.transaction(work); }
catch (e) { if (!isDeadlock(e) || attempt === 2) throw e; }
}
最容易忽略的一种
异步代码里的隐式锁。 在持锁期间 await 一个可能长时间挂起的操作,等于把锁持有时间无限拉长,把「概率性死锁」变成「必然」。
先给锁排序。这一条能解决工程中绝大多数死锁,而且不需要任何运行时配合。

评论
…