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

时间:2019-11-03 14:05:12

标签: c++ multithreading floating-point memory-model stdatomic

C ++ 20包括atomic<float>atomic<double>的专业化。这里有人可以解释出于什么实际目的应该是有益的吗?我能想象的唯一目的是当我有一个线程在随机点异步更改原子double或float且其他线程异步读取此值时(但在大多数平台上,volatile double或float实际上应该执行相同的操作)。但是对此的需求应该非常少。我认为这种罕见的情况不能证明将其包含在C ++ 20标准中。

3 个答案:

答案 0 :(得分:2)

编辑:添加Ulrich Eckhardt的注释以澄清: '让我尝试改写一下:即使在一个特定平台/环境/编译器上的volatile与atomic <>所做的相同,直到生成的机器代码,atomic <>在其保证方面仍然更具表现力而且,它保证是便携式的。此外,当您可以编写自文档代码时,就应该这样做。'

易失性有时会产生以下两种影响:

  1. 防止编译器将值缓存在寄存器中。
  2. 防止在程序的POV中不必要地优化对该值的访问。

另请参阅 Understanding volatile keyword in c++

TLDR;

明确说明您想要什么。

  • 如果'what'不是volatile的初衷,请不要依赖'volatile'做您想要的事情,例如使外部传感器或DMA可以更改内存地址,而不会影响编译器。
  • 如果要使用原子,请使用std :: atomic。
  • 如果您要禁用严格的别名优化,请像Linux内核一样,并在例如上禁用严格的别名优化。海湾合作委员会。
  • 如果要禁用其他类型的编译器优化,请对ARM或x86_64等使用编译器内部函数或代码显式汇编。
  • 如果您希望像C中那样使用'restrict'关键字语义,请在编译器上使用C ++中相应的限制内在函数(如果有)。
  • 简而言之,如果标准提供的结构更清晰,更可移植,则不要依赖于依赖于编译器和CPU系列的行为。使用例如如果您认为自己的“ hack”比正确的方式更有效,那就可以使用godbolt.org比较汇编程序的输出。

来自std::memory_order

  

与挥发物的关系

     

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

     

此外,易失性访问不是原子的(并发读写是数据竞争),并且不对内存进行排序(非易失性存储器访问可以在易失性访问周围自由地重新排序)。

     

Visual Studio是一个值得注意的例外,在默认设置下,每个易失性写入均具有发布语义,而每个易失性读取均具有获取语义(MSDN),因此可将volatiles用于线程间同步。标准易失性语义不适用于多线程编程,尽管它们对于例如与应用于sig_atomic_t变量的std :: signal处理程序进行通信。

最后要讲的是:实际上,构建OS内核的唯一可行语言通常是C和C ++。鉴于此,我希望这2个标准中的条款``告诉编译器退出'',即能够明确告诉编译器不要更改代码的``意图''。目的是将C或C ++用作可移植的汇编程序,其程度要比今天更大。

一个有点愚蠢的代码示例值得在例如适用于ARM和x86_64(均为gcc)的godbolt.org,可以看到在ARM的情况下,编译器为原子生成了两个__sync_synchronize(硬件CPU屏障)操作,但没有为代码的volatile变量生成(取消注释所需的注释) 。关键是使用原子可以产生可预测的可移植行为。

#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();
}

ARM gcc 8.3.1的Godbolt输出:

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

答案 1 :(得分:2)

atomic<float>atomic<double>自C ++ 11起就已经存在。 atomic<T>模板适用于任意可平凡复制的T您可以使用在C ++ 11之前的旧版中使用volatile共享变量来破解的所有内容C ++ 11 atomic<double>std::memory_order_relaxed

在C ++ 20是{strong>像x.fetch_add(3.14); 之类的原子RMW操作或简称为x += 3.14之前不存在的内容。 (Why isn't atomic double fully implemented想知道为什么不这样做)。 那些成员函数仅在atomic整数专业化中可用,因此您只能在floatdouble上加载,存储,交换和CAS,就像像类类型一样的任意T

有关如何使用compare_exchange_weak进行滚动以及如何在GCC和clang for x86中实际编译(以及纯负载,纯存储和交换)的详细信息,请参见Atomic double floating point or SSE/AVX vector load/store on x86_64。 (并非总是最佳状态,gcc不必要地反弹到整数regs。)另外,有关缺少atomic<__m128i>加载/存储的详细信息,因为供应商不会发布真正的保证来让我们(以面向未来的方式)利用什么当前的硬件。

这些新的专业化可能提供一些效率(在非x86上)和fetch_addfetch_sub(以及等效的+=-=重载)和便利性。仅支持这2个操作,不支持fetch_mul或其他任何操作。请参见the current draft of 31.8.3 Specializations for floating-point types和cppreference std::atomic

这并不是委员会竭尽全力引入与FP相关的新原子RMW成员函数fetch_mul,最小值,最大值甚至是绝对值或取反,这在asm中更容易讽刺< / strong>,只需按位AND或XOR即可清除或翻转符号位,如果不需要旧值,可以使用x86 lock and完成。实际上,由于MSB的进位无关紧要,因此64位lock xadd可以与fetch_xor一起实现1ULL<<63。当然假设IEEE754样式符号/幅度FP。在可以执行4字节或8字节fetch_xor的LL / SC机器上,同样容易,并且可以轻松地将旧值保存在寄存器中。

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

整数专长没有fetch_mul是有道理的:整数加法便宜得多,通常只有1个周期的等待时间,其复杂程度与原子CAS相同。但是对于浮点,乘和加为both quite complex and typically have similar latency。而且,如果原子RMW fetch_add对任何事情都有用,我假设fetch_mul也会有用。再次与整数不同,在整数中,无锁算法通常会加/减,但很少需要进行原子移位或从CAS中删除。 x86没有内存目标乘法,因此没有lock imul的直接硬件支持。

这似乎是将atomic<double>提升到您可能天真期望的水平(支持.fetch_add和类似整数的子对象)的问题,而不是提供严肃的原子RMW FP操作库。也许这使得编写不必检查整数(仅数字类型)类型的模板更加容易?

  

这里有人可以解释出于什么实际目的应该是有益的吗?

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

在C ++ 20之前,任何人都说“我们应该为atomic<double>提供fetch_add以防万一,如果有人愿意的话。”

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

父线程可能以atomic<double> total = 0;开头,并通过引用将其传递给每个线程。然后线程执行*totalptr += sum_region(array+TID*size, size)来累积结果。而不是为每个线程都具有单独的输出变量并将结果收集在一个调用程序中。除非所有线程几乎同时完成,否则争用还不错。 (这不太可能,但这至少是一个合理的情况。)


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

请勿使用volatile进行线程化:将atomic<T>mo_relaxed一起使用

有关多线程的mo_relaxed原子与传统volatile的详细信息,请参见When to use volatile with multi threading?volatile数据竞争是UB,但实际上在支持它的编译器中它确实是劳力士原子的一部分,如果需要任何订购权,则需要内联asm。其他操作,或者如果您想要RMW原子性而不是单独的负载/ ALU /单独的存储。所有主流CPU都具有一致的缓存/共享内存。但是对于C ++ 11,没有理由这样做:std::atomic<>已废弃的手动滚动volatile共享变量。

至少在理论上。 实际上,即使只是简单的加载和存储,某些编译器(如GCC)仍然对atomic<double> / atomic<float>进行了优化遗漏。(而且C ++ 20新的重载对象尚未在Godbolt上实施)。 atomic<integer>很好,并且可以优化易失性或纯整数+内存屏障。

在某些ABI(例如32位x86)中,alignof(double)仅4。编译器通常按8对齐它,但是在内部结构中,它们必须遵循ABI的结构打包规则,因此未对齐的{{1} }是可能的。在实践中,如果它拆分了缓存行边界,或者在某些AMD上是8字节边界,则可能会撕裂。在某些实际平台上, volatile double而不是atomic<double>可能对正确性很重要,即使您不需要原子RMW时也是如此。例如this G++ bug通过在volatile实现中使用alignas()来增加,以解决小到可以锁定的对象的问题。

(当然,在某些平台上,8字节存储并不是天生的原子,因此为了避免撕裂,您需要使用锁的后备。如果您关心这样的平台,则偶尔发布的模型应使用手动操作-如果std::atomic<>不是atomic<float>,则滚动SeqLock或atomic<double>。)


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

atomic<double>

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

#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

clang可以高效地对其进行编译,并为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 ax进行相同的移动标量双倍加载和存储。{p1

有趣的事实:C ++ 20显然不赞成vx。也许这是为了避免在单独的负载和存储之间混淆,例如vx = vx + 1.0与原子RMW?为了清楚起见,该语句中有2个单独的易失性访问?

px

请注意,vx += 1.0<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 | ~~~^~~~~~ 的{​​{1}}是不同的:前者装入临时文件,然后添加然后存储。 (两者都具有顺序一致性)。

答案 2 :(得分:1)

  

我能想象的唯一目的是当我有一个线程改变了   在随机点和其他位置异步地进行原子double或float浮动   线程异步读取该值

是的,这是原子的唯一目的,而与实际类型无关。可能是原子的boolcharintlong或其他任何东西。

type的任何用法,std::atomic<type>都是它的线程安全版本。 无论您使用float还是doublestd::atomic<float/double>都可以通过线程安全的方式进行写入,读取或比较。

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