竞争条件和解锁写入

Posted

技术标签:

【中文标题】竞争条件和解锁写入【英文标题】:Race condition and unlocked write 【发布时间】:2012-04-17 09:40:41 【问题描述】:

我有一个关于竞争条件和同时写入的问题。

我有一个从不同线程访问对象的类。我想仅按需计算一些值并缓存结果。出于性能原因,我宁愿不使用锁(在有人问之前 - 是的,这与我的情况有关)。

这构成了竞争条件。但是,对象是 const 并且不会更改。因此,如果不同的线程计算要缓存的值,它们在我的用例中保证是相同的。在不锁定的情况下写入这些值是否安全?或者,更广泛地说,从不同线程将相同内容写入内存而不加锁是否安全?

写入的值是 bool 和 double 类型,所讨论的架构可能是 x86 和 ARM。

编辑:感谢大家的意见。我终于决定找到一种不涉及缓存的方法。这种方法看起来确实很像“hack”,并且使用标志变量存在问题。

【问题讨论】:

我怀疑 C++ 能保证这一点,但至少对于现代硬件来说,很难想象会出现什么问题。 对原语的每个写入操作都是原子的,因此如果多个线程同时写入同一组字段,这应该不会有什么不同。 “所以如果不同的线程计算要缓存的值,它们在我的用例中保证是相同的”......这很奇怪。如果可以保证写入的值是相同的,为什么要同时写入呢?或者更好,为什么要多次编写它们? @Paul Michalik:它们是经过计算的,因此是按需写入(存储)的。因为需求可能发生在不同的线程中,所以只写一次锁定是必需的。 我明白了。因此,然后使用一个条件变量来表示是否已经计算了一个数据。这可以使用互锁的“CompareExchange”在某种自旋锁内部进行原子操作,但是这是为您选择的平台实现的。我认为这是您可以获得的最小同步开销...... 【参考方案1】:

如果值相同,则无需防止从不同线程写入 POD 变量。但是,如果您有指针,则绝对应该进行互锁交换。

更新:澄清一下,对于您的情况,缓存和优化不会产生任何不利影响,因为您在所有线程上写入完全相同的值。出于同样的原因,您不需要创建变量volatile。唯一可能成为问题的事情是,如果您的变量未与机器的字长对齐。有关详细信息,请参阅https://***.com/a/54242/677131。默认情况下,变量会自动对齐,但您可以显式更改对齐方式。

有一种替代方法可以完全避免这个问题。由于变量将具有相同的值,要么在并发执行开始之前预先计算它们,要么让每个线程都有自己的副本。后者的优势在于在 NUMA 机器上提供更好的性能。

【讨论】:

【参考方案2】:

首先我必须说,使用锁定通常是正确的做法,但是...

即使数据大于处理器字长,从多个线程写入同一个变量也不会是不安全的。不存在变量可能被破坏的过渡状态,因为至少有一个线程将完成写入值。其他线程不会通过扭曲相同的值来更改它。

因此,如果保证无论哪个线程的计算结果总是相同的,那么多线程这样做是没有危险的。在进行计算之前只需检查一个标志(“已经计算了吗?”)。多个线程将进入值计算代码,但一旦完成,当然没有其他线程会再这样做了。显然,做同样的事情 n 次是浪费时间。这里的问题是,使用锁会节省您的任何时间还是相反?只有性能测试才能给你答案。除非有其他原因不使用锁。

【讨论】:

【参考方案3】:

正如你所说,这是一个竞争条件。在 C++11 下,它在技术上是一个数据竞赛,并且是未定义的行为。值是否相同并不重要。

如果您的编译器支持它(例如,最近的 gcc、gcc 或 MSVC 与我的 Just::Thread 库),那么您可以使用 std::atomic<some_pod_struct> 为您的数据提供一个原子包装器(假设它 一个 POD 结构 --- 如果不是,那么你有更大的问题)。如果它足够小,那么编译器将使其无锁,并使用适当的原子操作。对于较大的结构,库将使用锁。

在没有原子操作或锁的情况下这样做的问题是可见性。虽然在 x86 或 ARM 上的处理器级别上从多个线程/处理器将相同的数据(假设它确实是逐字节相同)写入同一内​​存没有问题,但鉴于这是一个缓存,我希望如果它已经被写入,你会想要读取这些数据而不是重新计算它。因此,您需要某种标志来指示完成情况。除非您使用原子操作、锁或合适的内存屏障指令,否则“就绪”标志可能对另一个处理器可见数据之前。这会真的搞砸了,因为第二个处理器现在读取了一组不完整的数据。

您可以使用非原子操作写入数据,然后为标志使用原子数据类型。在 C++11 下,这将生成合适的内存屏障和同步,以确保数据对任何看到标志集的线程都是可见的。两个线程写入数据仍然是未定义的行为,但在实践中可能没问题。

或者,将数据存储在由执行计算的每个线程分配的堆内存块中,并使用比较和交换操作来设置原子指针变量。如果比较和交换失败,那么另一个线程首先到达那里,所以释放数据。

【讨论】:

被选为指出标志变量问题和需要内存屏障的最佳答案。 @Anthony,+1 表示 C++ 中数据竞争的技术意义与竞争条件的一般概念。 C++ 仅指定什么是数据竞争以及发生时的 UB。因此,如果共享变量是 C++11 原子类型,则可以保证没有数据竞争,即使标记为 w/ memory_order_relaxed ordering。该标准没有提到一般的竞争条件概念,也没有说发生这种情况时存在 UB。它完全取决于程序员添加适当的同步原语以确保正确的语义。你同意吗? @Anthony,还有一些人认为,即使共享数据是原子类型且排序松散,C++11 中仍然存在数据竞争。我很难向他们解释这些概念(在语言和您的书中正确定义)。您能否看看this post 并与我们分享您的意见?谢谢。【参考方案4】:

最终答案可能取决于您的数据结构。

在“非便携”领域,您可能想查看compare and swap,大多数处理器允许您在指针大小的实体上执行此操作。要访问它,您可以使用内联汇编(在 x86 上,这些是 lock cmpxchg 指令),或者可能是 GCC 同步扩展。在看到未初始化的值时,每个线程都可以急切地初始化,并发出比较和交换以尝试设置值。如果比较和交换失败,则意味着另一个线程已经击败了您。

不过,该操作的最终使用通常等同于实现自旋锁,您可能希望避免这种情况......

【讨论】:

部分数据结构将是(32 位)ARM 上的 8 字节双精度,因此它们大于“指针大小的实体”。所以这不是一个选择。 @GabrielSchreiber - 这并不意味着它不是一个选项。例如,您可以初始化指向结构的指针而不是结构本身。或者你可以有一个“伴随”词,它基本上用作初始化的锁——以确定它是否之前被初始化过。但是,如果它们依赖于一次编写多个单词,我通常会鼓励您小心地将其称为“原子”。 @GabrielSchreiber - 我必须强调,如果您希望读取-修改-写入操作是原子的,比较和交换(或特定于平台的等效项;“加载链接/存储条件”作为 RISC 版本)几乎是唯一的出路。

以上是关于竞争条件和解锁写入的主要内容,如果未能解决你的问题,请参考以下文章

MySQL锁表和解锁操作

Oracle 锁表查询 和解锁表

如何避免 VxWorks 中条件变量中的竞争条件

使用单个命令锁定和解锁资源

oracle查询锁表与解锁情况提供解决方案

oracle查询资源占用,锁表解锁