CodeGym /课程 /C# SELF /防止与解决 deadlock

防止与解决 deadlock

C# SELF
第 57 级, 课程 2
可用

1. 经典的 Coffman 条件

1971 年,计算机科学家 爱德华·科夫曼 (Edward G. Coffman Jr.) 描述了四个条件,缺少其一 deadlock 就不可能发生。这些规则早已成为并发面试和多线程课程里的“基础知识”。理解它们比死记更重要:

  1. 互斥 (Mutual Exclusion): 至少有一个资源一次只能被一个线程占用。
  2. 保持并等待 (Hold and Wait): 已获取某个资源的线程可能会等待其他资源。
  3. 不可抢占 (No Preemption): 资源不能被强行从线程手中夺走——只能由线程自己释放。
  4. 循环等待 (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:锁持有时间过长。
把锁持有得比需要的时间长,或者在临界区里调用外部方法,是引发阻塞和相互阻塞的常见原因。

2
任务
C# SELF, 第 57 级, 课程 2
已锁定
实现死锁检测时的自动回滚
实现死锁检测时的自动回滚
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION