竞赛条件和解锁写入

Race condition and unlocked write

本文关键字:解锁 条件 竞赛      更新时间:2023-10-16

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

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

这就构成了比赛条件。但是,这些对象是常量,不会被更改。因此,如果不同的线程计算要缓存的值,在我的用例中,它们保证是相同的。在不锁定的情况下写入这些值是否安全?或者,从更广泛的角度来看,从不同的线程将相同的内容写入内存而不锁定是否安全?

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

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

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

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

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

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

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

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

在"不可移植"领域中,您可能希望研究比较和交换,大多数处理器都允许您在指针大小的实体上进行比较和交换。为了访问它,您可以使用内联汇编(在x86上,这些是lock cmpxchg指令),或者GCC同步扩展。当看到一个未初始化的值时,每个线程都可以急切地初始化,并发出一个比较和交换来尝试设置一个值。如果比较和交换失败,这意味着另一个线程已经击败了你

不过,最终使用该操作通常相当于实现了一个自旋锁,您可能希望避免这种情况。。。

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

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

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

如果值相同,则不需要保护不同线程对POD变量的写入。不过,如果涉及到指针,那么您肯定应该进行互锁交换。

更新:为了澄清,对于您的情况,缓存和优化不会有任何不利影响,因为您在所有线程上都编写了完全相同的值。出于同样的原因,您不需要使变量volatile。唯一可能成为问题的是,如果您的变量与机器的单词大小不一致。看见https://stackoverflow.com/a/54242/677131了解更多详细信息。默认情况下,变量会自动对齐,但您可以显式更改对齐方式。

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