1. 经典的 Coffman 条件
1971 年,计算机科学家 爱德华·科夫曼 (Edward G. Coffman Jr.) 描述了四个条件,缺少其一 deadlock 就不可能发生。这些规则早已成为并发面试和多线程课程里的“基础知识”。理解它们比死记更重要:
- 互斥 (Mutual Exclusion): 至少有一个资源一次只能被一个线程占用。
- 保持并等待 (Hold and Wait): 已获取某个资源的线程可能会等待其他资源。
- 不可抢占 (No Preemption): 资源不能被强行从线程手中夺走——只能由线程自己释放。
- 循环等待 (Circular Wait): 存在一个线程链,每个线程等待下一个线程持有的资源。
如果所有四个条件都满足,deadlock 就有可能发生。只要打破其中任意一个条件,循环等待的可能性就会被降低或消除。
快速决策流程
flowchart TD
A[需要锁定多个资源吗?] -- 否 --> B[标准 lock]
A -- 是 --> C[能否总是按同一顺序获取?]
C -- 是 --> D[总是按同一顺序获取]
C -- 否 --> E[避免嵌套 lock]
D --> F[把锁内时间最小化]
E --> F
B --> F
F --> G{还是担心吗?}
G -- 是 --> H[加超时和重试]
G -- 否 --> I[放心睡觉!]
2. 防止 Deadlock 的主要策略
既然 deadlock 是并发世界的一部分,我们的目标是学会和它共存。各种策略可以帮忙:从简单到高级不等。
始终按同一顺序加锁
原理很简单:如果所有线程总是按相同顺序获取资源,循环等待就不会形成。这个技巧常被叫做 ordered locking ——“有序加锁”。
示例
危险的代码:
// 线程 1
lock (resA)
{
lock (resB)
{
// ...
}
}
// 线程 2
lock (resB)
{
lock (resA)
{
// ...
}
}
这里可能出现 deadlock:第一个线程持有 resA 等待 resB,第二个线程相反。两者都会挂起,永远不会释放锁。
正确的代码:
// 始终:先 resA,然后 resB(绝不相反)
lock (resA)
{
lock (resB)
{
// ...
}
}
现在顺序统一了,deadlock 就不可能发生。无论你有多少资源或它们叫什么名字,关键是所有线程要以同样的顺序获取锁。
常见错误: 代码里只要有一处打破了顺序,deadlock 风险就回来了。所以要特别小心!
避免同时锁定多个资源
这是最简单的策略:能不同时拿两个以上的锁就别拿!嵌套锁越多,被卡住的概率越高。可以把需要的数据复制到临时变量里,释放锁后再去处理其他资源。
在获取锁时使用超时
如果确实需要获取多个锁,超时会派上用场。比如不要只用普通的 lock,改用可以“尝试”获取锁的接口,如果没拿到就优雅回退,比如用 Monitor.TryEnter。
object resourceA = new object();
object resourceB = new object();
bool successA = false, successB = false;
try
{
// 尝试在最多 2 秒内获取 resourceA 和 resourceB
successA = Monitor.TryEnter(resourceA, TimeSpan.FromSeconds(2));
if (!successA) return; // 获取失败,退出
successB = Monitor.TryEnter(resourceB, TimeSpan.FromSeconds(2));
if (!successB) return; // 获取失败,退出
// 临界区
}
finally
{
if (successB) Monitor.Exit(resourceB);
if (successA) Monitor.Exit(resourceA);
}
如果第二个锁在 2 秒内拿不到,我们释放第一个锁并返回。这样就不会发生 deadlock,只会有一些线程回滚它们的尝试。
把锁内代码量最小化
锁住的代码越少,其他线程等待的时间就越短。不要在临界区做复杂计算、网络调用或磁盘操作。拿到锁做必要的共享状态修改,然后尽快释放。其他工作放在外面做。
分离状态 —— 尽量避免共享资源
有时候与其纠结锁,不如避免共享状态:
- 使用不可变对象 (immutable)。
- 使用副本或局部变量替代全局状态。
- 通过消息传递来传递数据(Actor Model、消息队列)。
- 使用线程安全集合:ConcurrentDictionary、ConcurrentQueue 等,来自 System.Collections.Concurrent。
3. 如果已经发生 Deadlock,如何处理
手动杀掉并重启(不是最佳方案)
最简单粗暴的方法是终止所有被挂起的进程。但这会有数据丢失风险,应该作为最后的手段。
在运行时检测 Deadlock
有些系统能自动检测 deadlock。如果线程太久不能拿到锁,可以记录日志、触发监控告警并发起恢复流程。
if (!Monitor.TryEnter(resource, TimeSpan.FromSeconds(10)))
{
Console.WriteLine("看起来我们遇到 deadlock,或者有人把锁持有太久!");
// 可以触发恢复,输出 dump 或通知用户
}
在分布式系统中会用专门的检测器分析等待图,并强制回滚或释放被卡住的事务。
自动回滚并重试
一个实用的做法是“放弃并重试”:如果在合理时间内没拿到所有锁,就回退,等待一段时间(通常是随机退避以错开重试),然后再试。
for (int attempt = 0; attempt < 3; attempt++)
{
bool gotRes1 = Monitor.TryEnter(res1, TimeSpan.FromSeconds(2));
bool gotRes2 = false;
try
{
if (gotRes1)
{
gotRes2 = Monitor.TryEnter(res2, TimeSpan.FromSeconds(2));
if (gotRes2)
{
// 临界区
break;
}
}
}
finally
{
if (gotRes2) Monitor.Exit(res2);
if (gotRes1) Monitor.Exit(res1);
}
// 没成功 — 等一会儿再试
Thread.Sleep(500 + new Random().Next(500));
}
4. 最佳实践和建议
- 分析锁的获取顺序。 收集入口点并检查顺序。
- 尽可能使用来自 System.Collections.Concurrent 的集合。
- 使用代码分析工具。 IDE 比如 Rider 或 Visual Studio 能帮你发现嵌套锁。
- 记录长时间的锁获取尝试日志。
- 对多线程组件做压力测试。
- 记录并发锁的约定。 注释能避免顺序被破坏。
5. 实践示例
示例:数据库
在数据库里 deadlock 很常见:两个事务以不同顺序更新相同表(A→B 和 B→A)。大多数数据库能检测到这种情况并“干掉”其中一个事务。
示例:异步方法
在 .NET 中,如果把异步和锁混在一起,可能会出互相阻塞的情况:await 会让持有锁的线程被挂起,而另一个线程在等同一个资源。设计临界区边界时要小心,避免在临界区内等待异步操作。
6. 使用锁时的常见错误
错误 №1:锁定字符串。
lock (myString) — 这是个坏主意。在 .NET 中字符串会被驻留(intern),所以你实际上在锁住一个全局的字符串表。
错误 №2:不同地方用不同顺序加锁。
如果程序的不同位置以不同顺序获取锁,deadlock 的概率会急剧上升。
错误 №3:锁持有时间过长。
把锁持有得比需要的时间长,或者在临界区里调用外部方法,是引发阻塞和相互阻塞的常见原因。
GO TO FULL VERSION