线程和简单死锁解决方案

10

在处理线程(特别是在C++中)时,使用互斥锁和信号量,有没有简单的经验法则可以避免死锁,并实现良好的同步?

9个回答

17
一个好的简单原则是,在应用程序中无论从哪里获取你的锁,都要以一致可预测的顺序获取。例如,如果你的资源有名称,请始终按字母顺序锁定它们。如果它们有数字ID,请始终按从最低到最高的顺序进行锁定。确切的顺序或标准是任意的,关键是要保持一致性。那样就永远不会发生死锁情况。例如:
  1. 线程1锁定资源A
  2. 线程2锁定资源B
  3. 线程1等待获取B的锁
  4. 线程2等待获取A的锁
  5. 死锁
如果你遵循上述原则,则上述情况永远不会发生。有关更详细的讨论,请参见维基百科上的哲学家进餐问题条目

8
  1. 尽可能设计您的代码,使您永远不必同时锁定多个互斥/信号量。
  2. 如果不可能,确保始终以相同的顺序锁定多个互斥/信号量。因此,如果代码的一部分锁定了互斥A,然后获取了信号量B,请确保代码的其他部分不会获取信号量B,然后锁定互斥A。

+1 正是我要给出的答案。这是我听过的最好的并发编程建议。 - oz10

2
尽量避免获取一个锁并尝试获取另一个锁。这可能导致循环依赖并引起死锁。 如果无法避免,则至少获取锁的顺序应该是可预测的。
使用RAII(确保在异常情况下锁得到适当释放)

1
这听起来不太实际。有很多情况下你需要同时持有多个锁,特别是如果你使用细粒度锁而不是一个全局锁。 - Steve Rowe
1
让这更实用的一种方法是使用API,允许您同时获取一组锁。如果您知道需要锁A、B和C,请调用lock(A,B,C)函数,该函数将尝试锁定所有这些锁,并按正确的顺序执行。您可以自己编写这样的函数,以集中代码以正确的顺序获取锁。 - jalf
为什么不切实际?在进入另一个组件之前释放互斥调用更安全(从而尝试获取另一个锁)。 “持有和等待”是死锁的基本原因之一。 - aJ.
2
aJ: 许多问题需要您获取多个锁。每次仅持有一把锁是不切实际的。 - jalf
设计成那样确实很困难,但真的不切实际吗? - aJ.
1
设计成这样并保持高性能是不切实际的。虽然可能有我不知道的方法,但通常减少锁的数量却做更多事情的方法是使锁更广泛。这很好,直到你有很多线程,然后它们会经常阻塞,性能就会下降。全局锁是死锁的解决方案,但对性能来说是致命的。 - Steve Rowe

1

没有简单的死锁解决方案。

按约定顺序获取锁:如果所有调用都按照 A->B->C 的顺序获取锁,则不会发生死锁。只有当两个线程之间的锁定顺序不同时(一个获取 A->B,第二个获取 B->A)才会发生死锁。

在实践中,很难选择内存中任意对象的顺序。在一个简单的琐碎项目上是可能的,但在许多个人贡献的大型项目上非常困难。部分解决方案是通过对锁进行排名来创建层次结构。模块 A 中的所有锁都具有等级 1,模块 B 中的所有锁都具有等级 2。当持有等级 1 的锁时,可以获取等级 2 的锁,但反之则不行。当然,您需要一个围绕锁定原语的框架来跟踪和验证排名。


在C++中,选择顺序非常简单。只需按内存地址排序即可。在C#或Java中,这更加棘手,因为对象的地址不会暴露给程序员,并且可能会在对象的生命周期内发生变化。 - jalf
我希望事情会变得如此简单...如果您处于代码具有用于锁定a、b、c对象列表的罕见位置,那么您可以比较地址。但大多数情况下,a在函数f()中被锁定,b在g()中被锁定,c在h()中被锁定,而这些函数都不知道其他函数的情况。在应用程序中假设所有锁定都将在块中获取,并知道所有锁定以及对每个锁定的引用是有点不现实的。 - Remus Rusanu

0

阅读死锁:问题和解决方案。

避免死锁的常见建议是始终以相同的顺序锁定两个互斥量:如果您总是在互斥量A之前锁定互斥量B,则永远不会发生死锁。有时这很简单,因为互斥量用于不同的目的,但其他情况则不是那么简单,例如当互斥量分别保护同一类的不同实例时。


0

确保其他人所谈论的顺序的一种方法是按照它们的内存地址定义锁的顺序来获取锁。如果在任何时候,您尝试获取应该早于序列中的先前锁定的锁,则会释放所有锁定并重新开始。

通过一些包装系统原语的包装器类,几乎可以自动完成这项工作。


0

没有实际的解决方法。具体来说,没有简单地测试代码是否同步正确的方法,也没有让程序员遵守绿色V字绅士规则的方法。

无法正确测试多线程代码,因为程序逻辑可能依赖于锁获取的时间,因此从执行到执行可能会有所不同,从而使QA的概念失效。

我建议:

  • 仅在多核机器上进行性能优化时使用线程
  • 只有在确定需要这种性能时才进行性能优化
  • 可以使用线程简化程序逻辑,但只有在确信自己知道在做什么时才这样做。要非常小心,所有锁都限制在非常小的代码片段中。不要让新手接近这样的代码。
  • 永远不要在关键任务系统中使用线程,例如驾驶飞机或操作危险机器
  • 在所有情况下,线程很少是划算的,因为调试和QA成本更高

如果您决定使用线程或维护现有的代码库:

  • 将所有锁限制在小而简单的代码片段中,这些代码片段操作原语。
  • 避免函数调用或将程序流程移开到执行锁定的事实不立即可见的地方。此功能将由未来的作者更改,扩大您的锁定范围而不受您的控制。
  • 在对象内获取锁以减少锁定范围,使用自己的线程安全接口包装非线程安全的第三方对象。
  • 在执行锁定时,永远不要发送同步通知(回调)。
  • 仅使用RAII锁定,以减少思考“我们还可以从这里退出吗”的认知负荷,例如异常等。

关于如何避免多线程的几点建议。

单线程设计通常涉及由程序组件提供的一些心跳函数,并在循环中调用(称为心跳循环),当调用时,所有组件都有机会做下一步工作并再次放弃控制权。算法家喜欢将组件内部的“循环”视为状态机,以确定下一个应该执行的任务。状态最好作为相应对象的成员数据进行维护。


试图在应用程序编写完成后添加多线程是失败的配方。如果你要使用线程,请正确地使用,并从第一天开始考虑它们融入应用程序的架构中。否则,你将会遇到所有令人讨厌的死锁和竞争条件。说实话,除非你还停留在80年代,任何假装具有一定CPU密集度(或者只需要响应)的应用程序都无法真正避免线程。 - jalf

0

有很多简单的“死锁解决方案”。但没有一种易于应用且普遍有效的。

当然,最简单的方法是“永远不要有多个线程”。

假设您有一个多线程应用程序,仍然有许多解决方案:

您可以尝试最小化共享状态和同步。两个仅并行运行且从未交互的线程永远不会发生死锁。只有当多个线程尝试访问相同资源时才会发生死锁。为什么他们这样做?是否可以避免这种情况?资源是否可以重组或分割,以便例如一个线程可以写入它,并且其他线程异步传递所需的数据?

也许可以复制资源,使每个线程都有自己的私有副本来处理?

正如其他答案已经提到的那样,如果你尝试获取锁,应该以全局一致的顺序执行。为了简化此过程,您应该尝试确保线程将需要的所有锁都作为单个操作获得。如果一个线程需要获取锁 A、B 和 C,则不应该在不同的时间和地点进行三个 lock() 调用。这会使您感到困惑,无法跟踪线程持有哪些锁,哪些锁尚未获取,然后会混乱。如果您可以一次性获取所有所需的锁,则可以将其分解为单独的函数调用,该函数获得 N 个锁,并按正确的顺序避免死锁。

然后还有更雄心勃勃的方法:像 CSP 这样的技术使得处理线程非常简单,并且即使有数千个并发线程也很容易证明正确性。但它要求您以与您习惯的程序结构非常不同的方式构建程序。

事务性内存是另一个有前途的选择,可能更容易集成到传统程序中。但是生产质量的实现仍然非常罕见。


0

如果您想要避免死锁的可能性,您必须攻击死锁存在的4个关键条件之一。

死锁的4个条件是: 1. 互斥 - 只有一个线程可以进入临界区。 2. 持有和等待 - 线程在完成任务之前不会释放已获取的资源,即使其他资源不可用。 3. 不可抢占 - 线程没有优先级。 4. 资源循环 - 必须存在一个线程的循环链,该链从其他线程那里等待资源。

最容易攻击的条件是资源循环,通过确保不可能出现循环来实现。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接