在使用互斥锁和信号量处理线程(特别是在 C++ 中)时,是否有一个简单的经验法则可以避免死锁并进行干净的同步?
9 回答
一个很好的简单经验法则是始终以一致的可预测顺序从应用程序的任何位置获取锁。例如,如果您的资源有名称,请始终按字母顺序锁定它们。如果他们有数字 id,请始终从最低到最高锁定。确切的顺序或标准是任意的。关键是要保持一致。这样你就永远不会陷入僵局。例如。
- 线程 1 锁定资源 A
- 线程 2 锁定资源 B
- 线程 1 等待获得 B 上的锁
- 线程 2 等待获取 A 上的锁
- 僵局
如果您遵循上述经验法则,上述情况永远不会发生。有关更详细的讨论,请参阅关于餐饮哲学家问题的 Wikipedia 条目。
- 如果可能的话,设计你的代码,这样你就不必一次锁定超过一个互斥量/信号量。
- 如果这不可能,请确保始终以相同的顺序锁定多个互斥体/信号量。因此,如果代码的一部分锁定互斥量 A 然后获取信号量 B,请确保代码的其他部分没有获取信号量 B 然后锁定互斥量 A。
尽量避免获取一个锁并尝试获取另一个锁。这可能导致循环依赖并导致死锁。如果这是不可避免的,那么至少获取锁的顺序应该是可预测的。
使用RAII(以确保在出现异常时也能正确释放锁)
没有简单的解决僵局的方法。
按约定的顺序获取锁:如果所有调用都获取 A->B->C,则不会发生死锁。仅当两个线程之间的锁定顺序不同时才会发生死锁(一个获得 A->B,第二个获得 B->A)。
在实践中,很难在内存中的任意对象之间选择顺序。在一个简单的琐碎项目上是可能的,但在有许多个人贡献者的大型项目上是非常困难的。部分解决方案是通过对锁进行排名来创建层次结构。模块 A 中的所有锁的等级为 1,模块 B 中的所有锁的等级为 2。当持有等级 1 的锁时,可以获得等级为 2 的锁,反之则不行。当然,您需要一个围绕锁定原语的框架来跟踪和验证排名。
“避免死锁的常见建议是始终以相同的顺序锁定两个互斥锁:如果您始终在互斥锁 B 之前锁定互斥锁 A,那么您将永远不会死锁。有时这很简单,因为互斥锁用于不同的目的,但是其他时候就不是那么简单了,例如当互斥锁每个都保护同一类的单独实例时”。
确保其他人讨论过的顺序的一种方法是按照他们的内存地址定义的顺序获取锁。如果在任何时候,您尝试获取本应在序列中较早的锁,则释放所有锁并重新开始。
只需一点工作,就可以使用围绕系统原语的一些包装类几乎自动完成此操作。
没有实用的治疗方法。具体来说,没有办法简单地测试代码是否同步正确,或者让你的程序员遵守带绿色 V 的绅士规则。
无法正确测试多线程代码,因为程序逻辑可能取决于锁获取的时间,因此,执行与执行之间存在差异,从而以某种方式使 QA 的概念无效。
我会说
- 更喜欢仅将线程用作多核机器的性能优化
- 仅当您确定需要此性能时才优化性能
- 您可以使用线程来简化程序逻辑,但前提是您绝对确定自己在做什么。要格外小心,所有锁都仅限于一小段代码。不要让任何新手靠近此类代码。
- 切勿在关键任务系统中使用线程,例如驾驶飞机或操作危险机器
- 在所有情况下,由于调试和 QA 成本较高,线程很少具有成本效益
如果您决定做线程或维护现有代码库:
- 将所有锁限制在小而简单的代码片段上,这些代码对原语进行操作
- 避免函数调用或使程序流到无法立即看到在锁定下执行的事实的地方。此功能将由未来的作者更改,在您无法控制的情况下扩大您的锁定范围。
- 获取对象内部的锁以减少锁定范围,使用您自己的线程安全接口包装非线程安全的第 3 方对象。
- 在锁定执行时从不发送同步通知(回调)
- 仅使用 RAII 锁,以减少在思考“我们如何才能从这里退出”时的认知负荷,例如异常等。
关于如何避免多线程的几句话。
单线程设计通常涉及程序组件提供的一些心跳功能,并在循环中调用(称为心跳循环),当被调用时,所有组件都有机会完成下一项工作并交出控制权再次。算法学家喜欢将组件内的“循环”视为组件内的“循环”,它们将变成状态机,以识别调用时应该做的下一件事是什么。状态最好作为各个对象的成员数据进行维护。
有很多简单的“死锁疗法”。但没有一个是容易应用和普遍工作的。
当然,最简单的是“永远不会有多个线程”。
假设您有一个多线程应用程序,仍然有许多解决方案:
您可以尝试最小化共享状态和同步。两个并行运行且从不交互的线程永远不会死锁。死锁仅在多个线程尝试访问同一资源时发生。他们为什么这样做?可以避免吗?是否可以对资源进行重组或划分,例如,一个线程可以写入它,而其他线程异步传递他们需要的数据?
也许可以复制资源,让每个线程都有自己的私有副本可以使用?
正如所有其他答案已经提到的那样,如果以及当您尝试获取锁时,请以全局一致的顺序进行。为了简化这一点,您应该尝试确保线程将需要的所有锁都作为单个操作获取。如果一个线程需要获取锁 A、B 和 C,它不应该lock()
在不同的时间和从不同的地方进行 3 次调用。你会感到困惑,你将无法跟踪线程持有哪些锁,以及它尚未获取哪些锁,然后你会弄乱顺序。如果您可以一次获取所有需要的锁,那么您可以将其分解为一个单独的函数调用,该函数调用获取 N 个锁,并以正确的顺序执行以避免死锁。
然后是更雄心勃勃的方法:像CSP这样的技术使线程非常简单,并且很容易证明是正确的,即使有数千个并发线程。但它要求您构建与您习惯的程序截然不同的程序。
事务内存是另一种有前途的选择,它可能更容易集成到传统程序中。但是生产质量的实施仍然非常罕见。
如果你想攻击死锁的可能性,你必须攻击死锁存在的 4 个关键条件之一。
死锁的 4 个条件是: 1. 互斥 - 一次只有一个线程可以进入临界区。2. Hold and Wait——线程不会释放他获得的资源,只要他没有完成他的工作,即使其他资源不可用。3. 无抢占 - 一个线程不具有高于其他线程的优先级。4. 资源循环——必须有一个线程循环链来等待来自其他线程的资源。
最容易攻击的条件是资源循环,确保没有循环是可能的。