繁体   English   中英

C++20 标准::原子<float> - 标准::原子<double> .specializations</double></float>

[英]C++20 std::atomic<float>- std::atomic<double>.specializations

C++20 包括atomic<float>atomic<double>的特化。 这里的任何人都可以解释这应该有什么实际用途吗? 我能想象的唯一目的是当我有一个线程在随机点异步更改原子双精度或浮点数,而其他线程异步读取此值(但 volatile double 或浮点数实际上应该在大多数平台上做同样的事情)。 但这种需求应该是极其罕见的。 我认为这种罕见的情况不能证明将其纳入 C++20 标准是合理的。

编辑:添加 Ulrich Eckhardt 的评论以澄清: '让我尝试改写一下:即使在一个特定平台/环境/编译器上的 volatile 与 atomic<> 做同样的事情,直到生成的机器代码,然后 atomic<> 仍然是它的保证更具表现力,此外,它保证是可移植的。 此外,当您可以编写自记录代码时,您应该这样做。

挥发性有时具有以下两种效果:

  1. 防止编译器将值缓存在寄存器中。
  2. 当您的程序的 POV 看起来不必要时,防止优化对该值的访问。

另请参阅了解 c++ 中的 volatile 关键字

TLDR;

明确说明你想要什么。

  • 不要依赖 'volatile' 做你想做的事,如果 'what' 不是 volatile 的最初目的,例如启用外部传感器或 DMA 来更改 memory 地址,而无需编译器干扰。
  • 如果你想要一个原子,使用 std::atomic。
  • 如果你想禁用严格的别名优化,请像 Linux kernel 一样,并在例如 gcc 上禁用严格的别名优化。
  • 如果要禁用其他类型的编译器优化,请使用编译器内在函数或代码显式汇编,例如 ARM 或 x86_64。
  • 如果您想要 C 中的“限制”关键字语义,请在编译器上使用 C++ 中相应的限制内在函数(如果可用)。
  • 简而言之,如果标准提供的结构更清晰、更可移植,则不要依赖编译器和 CPU 系列相关的行为。 如果您认为您的“hack”比正确的方法更有效,请使用例如 godbolt.org 来比较汇编程序 output。

来自std::memory_order

与volatile的关系

在一个执行线程中,通过 volatile glvalues 的访问(读取和写入)不能重新排序,超过同一线程中先排序或后排序的可观察副作用(包括其他易失访问),但不能保证此顺序被另一个线程观察到,因为 volatile 访问不会建立线程间同步。

此外,易失性访问不是原子的(并发读写是数据竞争),并且不排序 memory(非易失性 memory 访问可以围绕易失性访问自由重新排序)。

一个值得注意的例外是 Visual Studio,在默认设置下,每个 volatile 写入都具有释放语义,而每个 volatile 读取都具有获取语义 (MSDN),因此 volatile 可用于线程间同步。 标准 volatile 语义不适用于多线程编程,尽管当应用于 sig_atomic_t 变量时,它们足以与在同一线程中运行的 std::signal 处理程序进行通信。

作为最后的咆哮:实际上,构建操作系统 kernel 的唯一可行语言通常是 C 和 C++。 鉴于此,我希望在 2 个标准中规定“告诉编译器退出”,即能够明确告诉编译器不要更改代码的“意图”。 其目的是使用 C 或 C++ 作为便携式汇编器,其程度比现在更大。

An somewhat silly code example is worth compiling on eg godbolt.org for ARM and x86_64, both gcc, to see that in the ARM case, the compiler generates two __sync_synchronize (HW CPU barrier) operations for the atomic, but not for the volatile variant的代码(取消注释你想要的)。 关键是使用 atomic 可以提供可预测的可移植行为。

#include <inttypes.h>
#include <atomic>

std::atomic<uint32_t> sensorval;
//volatile uint32_t sensorval;

uint32_t foo()
{
    uint32_t retval = sensorval;
    return retval;
}
int main()
{
    return (int)foo();
}

Godbolt output 用于 ARM gcc 8.3.1:

foo():
  push {r4, lr}
  ldr r4, .L4
  bl __sync_synchronize
  ldr r4, [r4]
  bl __sync_synchronize
  mov r0, r4
  pop {r4, lr}
  bx lr
.L4:
  .word .LANCHOR0

对于那些想要 X86 示例的人,我的一位同事 Angus Lepper 慷慨地贡献了这个示例: godbolt example of bad volatile use on x86_64

atomic<float>atomic<double>自 C++11 以来一直存在。 atomic<T>模板适用于任意可简单复制的T 您可以使用带有std::memory_order_relaxed的 C++11 atomic<double>来完成旧 C++11 之前使用volatile共享变量的所有操作。

在 C++20 之前不存在的是原子 RMW 操作,例如x.fetch_add(3.14); 或简称x += 3.14 为什么不完全实现 atomic double不知道为什么)。 这些成员函数仅在atomic integer 特化中可用,因此您只能在floatdouble上加载、存储、交换和 CAS,就像 class 类型的任意T一样。

See Atomic double floating point or SSE/AVX vector load/store on x86_64 for details on how to roll your own with compare_exchange_weak , and how that (and pure load, pure store, and exchange) compiles in practice with GCC and clang for x86. (并非总是最优的,gcc 不必要地反弹到 integer regs。)另外有关缺乏atomic<__m128i>加载/存储的详细信息,因为供应商不会发布真正的保证让我们利用(以面向未来的方式)当前的硬件可以。

这些新的特化可能提供了一些效率(在非 x86 上)和fetch_addfetch_sub (以及等效的+=-=重载)的便利。 仅支持这 2 个操作,不支持fetch_mul或其他任何操作。 请参阅31.8.3 Specializations for floating-point types 的当前草案和 cppreference std::atomic

委员会并没有竭尽全力引入新的与 FP 相关的原子 RMW 成员函数fetch_mul 、 min 、 max ,甚至是绝对值或否定,具有讽刺意味的是,这在 asm 中更容易,只需按位 AND 或 XOR 来清除或翻转符号位,可以使用 x86 lock and来完成,如果不需要旧值。 实际上,由于 MSB 的进位无关紧要,64 位lock xadd可以实现fetch_xor1ULL<<63 当然假设 IEEE754 风格的符号/幅度 FP。 在可以执行 4 字节或 8 字节 fetch_xor 的 LL/SC 机器上同样容易,它们可以轻松地将旧值保存在寄存器中。

因此,在 x86 asm 中比在没有联合黑客(FP 位模式上的原子按位操作)的便携式C++ 中可以更有效地完成的一件事仍然没有被 ISO ZF6F87C9FDCF8B3C3F07F93F1C1 公开。

integer 专业化没有fetch_mul是有道理的:integer add 要便宜得多,通常为 1 个周期延迟,与原子 CAS 的复杂程度相同。 但是对于浮点,乘法和加法都非常复杂,并且通常具有相似的延迟 此外,如果原子 RMW fetch_add对任何事情都有用,我会假设fetch_mul也会有用。 再次与 integer 不同,其中无锁算法通常添加/删除,但很少需要从 CAS 构建原子移位或 mul。 x86 没有内存目标乘法,因此没有对lock imul的直接硬件支持。

看起来这更像是将atomic<double>提升到您可能天真期望的级别(支持.fetch_add和 sub 之类的整数),而不是提供一个严肃的原子 RMW FP 操作库。 也许这使得编写不必检查整数类型的模板变得更容易,只需检查数字类型吗?

这里的任何人都可以解释这应该有什么实际用途吗?

对于纯存储/纯加载,可能是您希望能够通过简单存储发布到所有线程的一些全局比例因子? 读者在每个工作单元或其他东西之前加载它。 或者只是作为无锁队列或double堆栈的一部分。

直到 C++20 才有人说“我们应该为atomic<double>提供 fetch_add 以防万一有人想要它”,这并非巧合。

合理的用例:手动对数组的总和进行多线程处理(而不是使用#pragma omp parallel for simd reduction(+:my_sum_variable)或标准<algorithm>std::accumulate和 C++17 并行执行策略)。

父线程可能以atomic<double> total = 0; 并通过引用传递给每个线程。 然后线程执行*totalptr += sum_region(array+TID*size, size)来累积结果。 而不是为每个线程使用单独的 output 变量并将结果收集到一个调用者中。 除非所有线程几乎同时完成,否则争用并不坏。 (这并非不可能,但至少是一个合理的场景。)


如果您只希望像volatile那样单独加载和单独存储原子性,那么您已经有了 C++11。

不要将volatile用于线程:将atomic<T>mo_relaxed一起使用

请参阅何时将 volatile 与多线程一起使用? 有关多线程的 mo_relaxed atomic 与 legacy volatile的详细信息。 volatile数据竞争是 UB,但它在实践中确实可以作为支持它的编译器上滚动你自己的原子的一部分,如果你想要任何排序 wrt,则需要内联 asm。 其他操作,或者如果您想要 RMW 原子性而不是单独加载/ALU/单独存储。 所有主流 CPU 都有一致的缓存/共享 memory。 但是使用 C++11 没有理由这样做: std::atomic<>已过时的手动volatile共享变量。

至少在理论上。 在实践中,即使只是简单的加载和存储,一些编译器(如 GCC)仍然对atomic<double> / atomic<float>进行了优化。 (并且尚未在 Godbolt 上实现 C++20 新的重载)。 atomic<integer>很好,并且确实优化了 volatile 或普通 integer + memory 屏障。

在某些 ABI(如 32 位 x86)中, alignof(double)仅为 4。编译器通常将其对齐 8,但在结构内部,它们必须遵循 ABI 的结构打包规则,因此可能出现对齐不足的volatile double 如果分割缓存行边界,或者在某些 AMD 上分割 8 字节边界,则在实践中可能会发生撕裂。 atomic<double>而不是volatile在某些真实平台上可能对正确性很重要,即使您不需要原子 RMW。 例如,这个 G++ 错误已通过在std::atomic<>实现中增加使用alignas()对小到无锁的对象进行修复。

(当然,有些平台的 8 字节存储不是自然原子的,因此为了避免撕裂,您需要回退到锁。如果您关心此类平台,偶尔发布的 model 应该使用手动 SeqLock 或atomic<float>如果atomic<double>不是always_lock_free 。)


您可以使用 mo_relaxed 从atomic<T>获得与使用volatile相同的高效代码生成(无需额外的屏障指令)。 不幸的是,在实践中,并非所有编译器都具有高效的atomic<double> 例如,用于 x86-64 的 GCC9 从 XMM 复制到通用 integer 寄存器。

#include <atomic>

volatile double vx;
std::atomic<double> ax;
double px; // plain x

void FP_non_RMW_increment() {
    px += 1.0;
    vx += 1.0;     // equivalent to vx = vx + 1.0
    ax.store( ax.load(std::memory_order_relaxed) + 1.0, std::memory_order_relaxed);
}

#if __cplusplus > 201703L    // is there a number for C++2a yet?
// C++20 only, not yet supported by libstdc++ or libc++
void atomic_RMW_increment() {
    ax += 1.0;           // seq_cst
    ax.fetch_add(1.0, std::memory_order_relaxed);   
}
#endif

用于 x86-64 的Godbolt GCC9,gcc -O3。 (还包括一个 integer 版本)

FP_non_RMW_increment():
        movsd   xmm0, QWORD PTR .LC0[rip]   # xmm0 = double 1.0 

        movsd   xmm1, QWORD PTR px[rip]        # load
        addsd   xmm1, xmm0                     # plain x += 1.0
        movsd   QWORD PTR px[rip], xmm1        # store

        movsd   xmm1, QWORD PTR vx[rip]
        addsd   xmm1, xmm0                     # volatile x += 1.0
        movsd   QWORD PTR vx[rip], xmm1

        mov     rax, QWORD PTR ax[rip]      # integer load
        movq    xmm2, rax                   # copy to FP register
        addsd   xmm0, xmm2                     # atomic x += 1.0
        movq    rax, xmm0                   # copy back to integer
        mov     QWORD PTR ax[rip], rax      # store

        ret

clang 高效地编译它,对于axvxpx具有相同的 move-scalar-double 加载和存储。

有趣的事实:C++20 显然弃用vx += 1.0 也许这是为了帮助避免像 vx = vx + 1.0 与原子 RMW 这样的单独加载和存储之间的混淆? 为了清楚起见,该语句中有 2 个单独的 volatile 访问?

<source>: In function 'void FP_non_RMW_increment()':
<source>:9:8: warning: compound assignment with 'volatile'-qualified left operand is deprecated [-Wvolatile]
    9 |     vx += 1.0;     // equivalent to vx = vx + 1.0
      |     ~~~^~~~~~


请注意, x = x + 1atomic<T> xx += 1不同:前者加载到临时文件中,添加,然后存储。 (两者都具有顺序一致性)。

我能想象的唯一目的是当我有一个线程在随机点异步更改原子双精度或浮点数,而其他线程异步读取此值

是的,无论实际类型如何,这是原子的唯一目的。 可能是原子boolcharintlong或其他。

无论您对type有什么用途, std::atomic<type>都是它的线程安全版本。 无论您对floatdouble有什么用途,都可以用线程安全的方式写入、读取或比较std::atomic<float/double>

std::atomic<float/double>只有罕见的用法实际上是说float/double有罕见的用法。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM