【问题标题】:Results of doing += on a double from multiple threads对来自多个线程的双精度执行 += 的结果
【发布时间】:2020-04-24 17:29:49
【问题描述】:

考虑以下代码:

void add(double& a, double b) {
    a += b;
}

根据godbolt 在 Skylake 上编译为:

add(double&, double):
  vaddsd xmm0, xmm0, QWORD PTR [rdi]
  vmovsd QWORD PTR [rdi], xmm0
  ret

如果我从不同的线程调用add(a, 1.23)add(a, 2.34)(对于同一个变量a),a 肯定会以a+1.23、a+2.34 或a+1.23+2.34 结束吗?

也就是说,考虑到这个程序集,这些结果之一肯定会发生,a 不会以其他状态结束吗?

【问题讨论】:

  • 由多个线程写入非原子变量是未定义的行为,除非您使用同步。你得到的任何结果都是“有效的”
  • @NathanOliver 但是有一个结果程序集。所以 C++ 规范在这里并不重要,不是吗?
  • 是的,我特别包含了程序集,因为这就是行为 - 从 C++ 的角度来看,它可能是未定义的,但这是这个架构上的定义,带有这个 GCC 版本、标志等
  • @ThomasJohnson AFAIK,同步需要一直到 CPU。我听说 CPU 可以重新排序指令,只要它们知道它们不相互依赖。在不生成线程安全代码的情况下,您允许 CPU 破坏变量并且垃圾是有效的结果。
  • 如果您不关心“C++”规范的内容,那么您应该从您的问题中删除 [C++] 标签。

标签: multithreading assembly x86-64 atomic data-race


【解决方案1】:

这是一个与我相关的问题:

CPU 是否在单个操作中获取您正在处理的单词?

某些处理器可能允许内存访问碰巧在内存中未对齐的变量,方法是一个接一个地执行两次提取 - 当然是非原子的。

在这种情况下,如果另一个线程插入该内存区域的写入,而第一个线程已经获取了单词的第一部分,然后在另一个线程已经修改了单词时获取了第二部分,则会出现问题。

thread 1 fetches first part of a XXXX
thread 1 fetches second part of a YYYY
thread 2 fetches first part of a XXXX
thread 1 increments double represented as XXXXYYYY that becomes ZZZZWWWW by adding b
thread 1 writes back in memory ZZZZ
thread 1 writes back in memory WWWW
thread 2 fetches second part of a that is now WWWW
thread 2 increments double represented as XXXXWWWW that becomes VVVVPPPP by adding b
thread 2 writes back in memory VVVV
thread 2 writes back in memory PPPP

为了保持简洁,我使用了一个字符来表示 8 位。

现在XXXXWWWWVVVVPPPP 将代表完全不同于您预期的浮点值。那是因为您最终混合了双变量的两个不同二进制表示 (IEEE-754) 的两个部分。

也就是说,我知道在某些基于 ARM 的架构中不允许数据访问(这会导致生成陷阱),但我怀疑英特尔处理器确实允许这样做。

因此,如果您的变量a 是对齐的,您的结果可以是任何一个

a+1.23、a+2.34、a+1.23+2.34

如果您的变量可能未对齐(即地址不是 8 的倍数),您的结果可以是任何一个

a+1.23、a+2.34、a+1.23+2.34 或垃圾值


作为进一步的说明,请记住,即使您的环境alignof(double) == 8 不一定足以断定您不会有错位问题。一切都取决于您的特定变量来自何处。考虑以下(或运行它here):

#pragma push()
#pragma pack(1)
struct Packet
{
    unsigned char val1;
    unsigned char val2;
    double val3;
    unsigned char val4;
    unsigned char val5;
};
#pragma pop()


int main()
{
    static_assert(alignof(double) == 8);

    double d;
    add(d,1.23);       // your a parameter is aligned

    Packet p;
    add(p.val3,1.23);  // your a parameter is now NOT aligned

    return 0;
}

因此断言alignof() 并不一定保证您的变量是对齐的。如果你的变量没有参与任何打包,那么你应该没问题。

对于正在阅读此答案的其他人,请允许我免责声明:在这些情况下使用 std::atomic<double> 是实现线程安全的实现工作和性能方面的最佳折衷方案。有些 CPU 架构具有特殊的高效指令来处理原子变量,而无需注入繁重的栅栏。这可能最终已经满足了您的性能要求。

【讨论】:

  • x86-64 ABI(x86-64 SystemV 和 Windows)都有 alignof(double) = 8,所以我们可以假设 double& a 是对齐的。否则它将是未定义的行为。在 asm 中,我们可以假设它是一个 8 字节的原子加载和单独的 8 字节原子存储,除非调用者是违反 ABI 的手写 asm。但如果它是 32 位代码,那么您可能会有一个未对齐的 double; 32 位 ABI 较旧,只有 alignof(double)=4。当然,如果这个函数内联到它的调用者中,那么独立的 asm 定义就无关紧要了。
  • 内联透视图,虽然一般来说考虑到指令的相对(重新)排序可能是一个很好的考虑因素,但在这种特殊情况下没有任何区别。
  • 我说的是内联到循环,或者其他重要的情况。例如godbolt.org/z/oA4kd8 展示了优化将负载提升到循环之外的情况,因此它要么变为无限,要么什么都没有。或者将存储从循环中删除,这样它就不会一路更新共享变量。这些是数据竞争 UB 的常见后果,对于未能使用 atomic<double> 的新手来说,这比实际缺乏原子性更常见 - Multithreading program stuck in optimized mode but runs normally in -O0
  • 是的,我同意你的观点,即内联、缓存和管道重新排序在处理多线程时发挥着重要作用。但是这里的问题是具体的。在那种情况下,即使调用是内联的,它也不会在任何给定时刻内存位置假定的可能值范围内发挥任何作用。对于非内联替代方案,他们可能在不同时刻假设了不同的值,但假设的值仍然是上述三个中的任何一个,并且只有这三个(在对齐内存位置的情况下)。
  • 如果一个 store 陷入循环,你可能在内存中仍然拥有原始的 a 值比你预期的要长很多。除此之外,是的,我明白你的意思。我将问题扩大到还有什么可能出错的地方,以强调它不仅仅是缺乏原子性。 (部分是因为最近的问答like this one 有人认为测试没有撕裂(在调试模式下)意味着他们不需要atomic<T>。所以当某些事情总体上仍然不安全时,SO 让我小心翼翼地弄清楚,例如这个)
猜你喜欢
  • 2018-08-11
  • 2021-10-28
  • 1970-01-01
  • 2013-10-03
  • 2018-12-30
  • 1970-01-01
  • 2023-03-17
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多