C++是否保证两个线程同时访问数组相邻元素是安全的?

8

就C++标准而言(我猜是C++11和以后的版本,因为之前没有考虑线程),同时写入不同的、可能相邻的数组元素是否安全?

例如:

#include <iostream>
#include <thread>

int array[10];

void func(int i) {
   array[i] = 42;
}

int main() 
{
   for(int i = 0; i < 10; ++i) {
      // spawn func(i) on a separate thread
      // (e.g. with std::async, let me skip the details)
   }
   // join

   for(int i = 0; i < 10; ++i) {
      std::cout << array[i] << std::endl; // prints 42?
   }

   return 0;
}

在这种情况下,语言是否保证数组不同元素的写入不会导致竞态条件?并且对于任何类型都有保证,还是有安全要求?

只要数组存在且线程访问它,就应该没问题。如果A线程创建了一个自动存储期的数组,而B线程在A线程终止后访问该数组的任何元素,那么这将是不安全的。此外,如果您引用一个动态分配的数组,在任一线程访问它时,该数组不能被调整大小(例如,一个线程不能安全地调整它,而另一个线程正在访问它)。该逻辑不适用于int类型中相邻位(例如使用位调整操作将int作为8位数组处理)。 - Peter
4个回答

4

是的。

来自https://en.cppreference.com/w/cpp/language/memory_model:

当表达式的评估写入到一个内存位置并且另一个评估读取或修改同一内存位置时,这些表达式被称为冲突。如果一个程序具有两个冲突的评估,则存在数据竞争,除非[...]

然后:

内存位置

  • 标量类型的对象(算术类型、指针类型、枚举类型或std::nullptr_t)
  • 或最大的连续位域序列,长度不为零

因此,如果数组的元素存储在不同的内存位置,则没有冲突的评估。

数组是:

形式为T a[N];的声明将a声明为由T类型的N个连续分配的对象组成的数组对象。

由于两个不同的对象不能具有相同的地址,它们及其组成部分也不能具有相同的内存位置。这保证了先前要求的满足。

此外,对象可以由多个内存位置组成,因此您甚至可以使两个线程操作同一对象的不同成员!

请注意,为了使您的示例正确,join 也必须编写正确,但它与数组的相邻元素无关,而是对同一个数组进行操作,所以我想它超出了问题的范围。


个人注释:顺带一提,如果这不被保证的话,标准库中的并行计算将会严重受限甚至无法使用。

数组的不同元素不能存储在同一内存位置,恐怕这个回答完全离题了。 - 463035818_is_not_a_number
@user463035818 这就是关键所在。它说“由N个连续分配的对象组成”,这怎么会离题呢?我必须明确地写出数组元素不能具有相同的内存位置吗? - luk32
啊,抱歉,我重新读了一遍,现在我明白了。我刚刚在“所以如果数组的元素存储在不同的内存位置…”这个措辞上遇到了困难,我想这只是你论证中的一个中间步骤。 - 463035818_is_not_a_number
我并不是很理解“只要同步(连接)写得正确”的意思。你指的是什么? - 463035818_is_not_a_number
如果OP根本不写join语句,那么如果另一个线程读取相同的索引,则后续循环可能会出现数据竞争。人们还可能错误地连接同一线程对象,这是未定义行为等等。虽然没有实际的代码,但有可能做错事情。我已经编写了澄清说明,希望更加清晰易懂。 - luk32
1
@user463035818 我已经记录下来了,由于问题会涉及到相同的内存位置,相邻的元素并不相关,所以这已经超出了问题的范围。 - luk32

2
数据竞争只会发生在同一内存位置上,即仅当“&x == &y”时,两个glvalue xy之间才会存在数据竞争。
引用自:[intro.races]/2 如果一个表达式修改了一个内存位置,而另一个表达式读取或修改了相同的内存位置,则这两个表达式的计算将冲突。
引用自:[intro.races]/21 如果程序包含两个潜在并发的冲突操作,则该程序的执行将包含数据竞争。
剩下的内容不适用于此处。所以,在您的数组中没有数据竞争。原始答案翻译成“最初的回答”。

1

是的,但“好”并不意味着这样做是明智的。

有几个问题需要考虑,也许最重要的是CPU缓存。例如,在x86上,缓存行长度为64字节,因此每个线程应该在数组的一块上工作,以匹配缓存行长度,以避免虚假共享等问题。

这里是一个例子:false sharing SO question/answer


1

同时在不同线程上访问连续元素是安全的,但如果发生频繁,它可能会导致代码性能问题。这是由于现代CPU并行性的基本限制所致。

对于大多数程序来说,内存访问是一个主要瓶颈。代码中性能关键的部分必须小心编写,以避免过多的缓存未命中。缓存有多个级别,每个级别都比前一个级别更快。然而,当数据不在缓存中或者数据可能已被另一个CPU更改时,它必须重新加载到缓存中。

CPU无法跟踪每个单独字节的状态,因此它跟踪称为缓存行的字节块。如果任何一个缓存行中的字节被另一个CPU更改,为了保证同步,它必须重新加载。

如果不同线程在同一个缓存行上访问不同的字节,则仅会导致此重新加载。并且由于缓存行是连续的,从不同线程访问连续元素通常会导致内存必须重新加载到缓存中。 如果性能是一个问题,则应避免使用此技术,这称为误用共享。

话虽如此,如果这种情况很少发生,那么可能没什么问题,您应该在优化代码之前进行基准测试和测试。


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