28

在回答这个问题时,出现了一个关于 OP 情况的进一步问题,我不确定:这主要是一个处理器架构问题,但也有一个关于 C++ 11 内存模型的连锁问题。

基本上,由于以下代码(为简单起见稍作修改),OP 的代码在更高的优化级别无限循环:

while (true) {
    uint8_t ov = bits_; // bits_ is some "uint8_t" non-local variable
    if (ov & MASK) {
        continue;
    }
    if (ov == __sync_val_compare_and_swap(&bits_, ov, ov | MASK)) {
        break;
    }
}

__sync_val_compare_and_swap()GCC 的原子 CAS 内置在哪里。bits_ & mask在进入循环之前检测到的情况下,GCC(合理地)将其优化为无限循环,true完全跳过 CAS 操作,因此我建议进行以下更改(可行):

while (true) {
    uint8_t ov = bits_; // bits_ is some "uint8_t" non-local variable
    if (ov & MASK) {
        __sync_synchronize();
        continue;
    }
    if (ov == __sync_val_compare_and_swap(&bits_, ov, ov | MASK)) {
        break;
    }
}

在我回答之后,OP 指出更改bits_volatile uint8_t似乎也有效。我建议不要走那条路,因为volatile通常不应该用于同步,而且在这里使用栅栏似乎没有太大的缺点。

但是,我想得更多,在这种情况下,语义是这样的,即ov & MASK检查是否基于陈旧值并不重要,只要它不是基于无限期陈旧的值(即只要循环最终被打破),因为实际的更新尝试bits_是同步的。那么,对于任何现有的处理器,如果由另一个线程更新,那么volatile这里足以保证这个循环最终终止吗?换句话说,在没有显式内存栅栏的情况下,实际上是否可以无限期地由处理器有效地优化未由编译器优化的读取?(编辑:bits_bits_ & MASK == false为了清楚起见,我在这里询问现代硬件实际上可能会做什么,因为假设读取是由编译器在循环中发出的,所以这在技术上不是一个语言问题,尽管用 C++ 语义表达它很方便。)

这是它的硬件角度,但要稍微更新它并使其也成为有关 C++11 内存模型的可回答问题,请考虑对上述代码进行以下变体:

// bits_ is "std::atomic<unsigned char>"
unsigned char ov = bits_.load(std::memory_order_relaxed);
while (true) {
    if (ov & MASK) {
        ov = bits_.load(std::memory_order_relaxed);
        continue;
    }
    // compare_exchange_weak also updates ov if the exchange fails
    if (bits_.compare_exchange_weak(ov, ov | MASK, std::memory_order_acq_rel)) {
        break;
    }
}

cppreference声称这std::memory_order_relaxed意味着“对围绕原子变量的内存访问的重新排序没有限制”,因此独立于实际硬件会或不会做什么,确实意味着在符合实现的另一个线程上更新后,从bits_.load(std::memory_order_relaxed)技术上讲永远不会读取更新的值bits_?

编辑:我在标准(29.4 p13)中找到了这个:

实现应该使原子存储在合理的时间内对原子负载可见。

因此,显然等待更新值“无限长”(大部分?)是不可能的,但是除了应该“合理”之外,没有任何具体的新鲜时间间隔的硬性保证;尽管如此,关于实际硬件行为的问题仍然存在。

4

4 回答 4

9

C++11 原子处理三个问题:

  1. 确保在没有线程切换的情况下读取或写入完整的值;这可以防止撕裂。

  2. 确保编译器不会在原子读取或写入的线程内重新排序指令;这确保了线程内的排序。

  3. 确保(对于适当的内存顺序参数选​​择),将在原子写入之前写入线程中写入的数据,将通过读取原子变量并看到所写值的线程看到。这是可见性。

当您使用时,memory_order_relaxed您无法从轻松的存储或加载中获得可见性保证。你确实得到了前两个保证。

实现“应该”(即被鼓励)使内存写入在合理的时间内可见,即使是放松的排序。这是可以说的最好的了;这些事情迟早会出现。

因此,是的,正式地,从不让轻松写入对轻松读取可见的实现符合语言定义。在实践中,这不会发生。

至于做什么volatile,请询问您的编译器供应商。这取决于实施。

于 2013-03-17T23:05:42.877 回答
4

std::memory_order_relaxed从技术上讲,负载永远不会为负载返回新值是合法的。至于是否有任何实现会这样做,我不知道。

参考: http: //www.developerfusion.com/article/138018/memory-ordering-for-atomic-operations-in-c0x/ “唯一的要求是不能从同一个线程访问单个原子变量重新排序:一旦给定线程看到原子变量的特定值,该线程的后续读取将无法检索该变量的早期值。”

于 2013-03-17T23:11:47.113 回答
4

如果处理器没有缓存一致性协议或者它非常简单,那么它可以“优化”从缓存中获取陈旧数据的负载。现在大多数现代多核 CPU 都实现了缓存一致性协议。但是A9之前的ARM没有。非 CPU 架构也可能没有缓存一致性(尽管它们可能不符合 C++ 内存模型)。

另一个问题是许多架构(包括 ARM 和 x86)允许重新排序内存访问。我不知道处理器是否足够聪明,可以注意到对同一地址的重复访问,但我对此表示怀疑(在极少数情况下会花费空间和时间,因为编译器应该能够注意到它,但好处很小,因为以后的访问可能会是 L1 命中),但从技术上讲,它可以推测将采用分支,并且它可以在第一个访问之前重新排序第二个访问(不太可能,但如果我正确阅读 Intel 和 ARM 手册,这是允许的)。

最后,还有一些不符合缓存一致性的外部设备。如果 CPU 通过内存映射 IO/DMA 进行通信,则该页面必须标记为不可缓存(否则在 L1/L2/L3/... 缓存中的数据将是陈旧的数据)。在这种情况下,处理器通常不会重新排序读取和写入(有关详细信息,请参阅您的处理器手册 - 它可能具有更细粒度的控制) - 编译器可以因此您需要使用volatile. 但是,由于原子通常是基于缓存的,因此您不需要或可以使用它们。

恐怕我无法回答未来的处理器是否会提供如此强大的缓存一致性。我建议严格遵循规范(“将指针存储在 int 中有什么问题?肯定没有人会使用超过 4GiB 的用户,因此 32b 地址足够大。”)。正确性由其他人回答,所以我不会包括它。

于 2013-03-18T00:53:25.097 回答
1

这是我对它的看法,虽然我对这个话题了解不多,所以对它持保留态度。

关键字效果很可能取决于编译器volatile,但我会假设它实际上做了我们直观的期望,即避免别名或任何其他不会让用户在调试器中随时检查变量值的优化在该变量的生命周期内执行。这与关于 volatile 含义的答案非常接近(并且可能相同) 。

直接的含义是任何访问该volatile变量的代码块v都必须在修改它后立即将其提交到内存中。Fences 将使它与其他更新一起按顺序发生,但无论哪种方式,如果在源级别进行修改,则v在程序集输出中将有一个存储。v

实际上,您要问的问题是,如果v加载到寄存器中的 没有被某些计算修改,那么是什么迫使 CPUv再次执行对任何寄存器的读取,而不是简单地重用它之前已经获得的值。

我认为答案是 CPU不能假设存储单元从上次读取时没有改变。即使在单核系统上,内存访问也不是严格保留给 CPU 的。许多其他子系统可以读写访问它(这是DMA背后的原理)。

CPU 可以做的最安全的优化可能是检查缓存中的值是否更改,并将其用作v内存状态的提示。缓存应该保持同步。由于 DMA 附带的缓存失效机制,因此具有内存。在这种情况下,问题会回到多核上的缓存一致性,以及多线程情况下的“写后写”。最后一个问题不能用简单volatile的变量有效地处理,因为它们的修改操作不是原子的,正如你已经知道的。

于 2013-03-18T00:41:24.693 回答