【问题标题】:Reading shared variables with relaxed ordering: is it possible in theory? Is it possible in C++?以轻松的顺序读取共享变量:理论上可能吗?在 C++ 中可能吗?
【发布时间】:2024-05-02 12:50:02
【问题描述】:

考虑以下伪代码:

expected = null;
if (variable == expected)
{
    atomic_compare_exchange_strong(
        &variable, expected, desired(), memory_order_acq_rel, memory_order_acq);
}
return variable;

在执行variable == expected 检查时观察没有“获取”语义。

在我看来,desired 总共至少会被调用一次,每个线程最多一次
此外,如果desired 永远不会返回null那么这段代码将永远返回null

现在,我有三个问题:

  1. 以上内容一定是真的吗?也就是说,即使每次读取都没有栅栏,我们真的可以对共享变量进行有序的读取吗?

  2. 可以在 C++ 中实现吗?如果是这样,怎么做?如果不是,为什么?
    (希望有一个理由,而不仅仅是“因为标准是这样说的”。)

  3. 如果 (2) 的答案是肯定的,那么是否也可以在 C++ 中实现这一点无需 要求 variable == expected 执行 原子 读取的variable

基本上,我的目标是了解是否可以在代码完成后以与非共享变量的性能相同的方式执行共享变量的延迟初始化每个线程至少执行一次?

(这有点像“语言律师”的问题。所以这意味着问题不在于这是一个好主意还是有用的想法,而是关于在技术上是否可以正确地做到这一点。)

【问题讨论】:

  • @nosid:不。见#3。问题还在于是否需要原子性,无论内存排序问题如何。
  • @nosid:我没有提到它,因为我首先提到的是这是伪代码,所以不要指望它是有效的 C++。重点是概念; C++ 只是整个问题的一个方面。
  • 那么,如果variable 很大以至于atomic_compare_exchange_strong 必须使用互斥锁怎么办?您会尝试在更改对象时访问variable == expected,这意味着例如它的类不变量不必保持。
  • 如果没有内存模型来描述多线程读取/写入的工作原理,伪代码几乎没有意义。 C++11 有一个内存模型,但你的伪代码不是 C++,因为 C++ 变量有类型,而这些类型会影响它们在 C++11 内存模型下的行为。
  • @Yakk:我对使用 C++ 内存模型犹豫不决,因为大多数问题都是概念性的,而不是特定于 C++ 的。它只有一部分询问这是否可以在 C++ 中实现,并且对于该部分,您可以为变量提供您认为合适的任何类型以使其工作。你能想象这种在 any(理智的)内存模型上失败并有令人信服的理由(即不仅仅是因为“内存模型这么说”,而是为什么它可能这么说) ?

标签: c++ multithreading c++11 atomic memory-model


【解决方案1】:

关于是否可以在 C++ 中执行共享变量的延迟初始化的问题,其性能(几乎)与非共享变量相同:

答案是,它取决于硬件架构,以及编译器和运行时环境的实现。至少,在某些环境中是可能的。特别是在带有 GCC 和 Clang 的 x86 上。

在 x86 上,可以在没有内存栅栏的情况下实现原子读取。基本上,原子读取与非原子读取相同。看看下面的编译单元:

std::atomic<int> global_value;
int load_global_value() { return global_value.load(std::memory_order_seq_cst); }

虽然我使用了具有顺序一致性(默认)的原子操作,但生成的代码并没有什么特别之处。 GCC和Clang生成的汇编代码如下:

load_global_value():
    movl global_value(%rip), %eax
    retq

我说几乎相同,因为还有其他可能影响性能的原因。例如:

  • 虽然没有围栏,但原子操作仍然会阻止一些编译器优化,例如重新排序指令和消除存储和加载
  • 如果至少有一个线程写入同一缓存行上的不同内存位置,则会对性能产生巨大影响(称为错误共享

话虽如此,实现延迟初始化的推荐方法是使用std::call_once。这应该为您提供所有编译器、环境和目标架构的最佳结果。

std::once_flag _init;
std::unique_ptr<gadget> _gadget;

auto get_gadget() -> gadget&
{
    std::call_once(_init, [this] { _gadget.reset(new gadget{...}); });
    return *_gadget;
}

【讨论】:

  • +1 虽然这不是我要去的方向。我不是想问一个特定于架构的问题。我想知道我是否可以确定这可以在 all 架构上完成,假设他们以理智的方式实现了atomic_compare_and_swap_strong。真正的问题是为什么在某些架构上未受保护的读取可能不够,不是特定架构是否能够做到这一点。
  • @Mehrdad std::call_once 保证适用于所有平台。并且“在 x86 上,可以在没有内存栅栏的情况下实现原子读取”这句话并不是真的。处理器可以重新排序读取(但我认为,写入,至少对于较旧的处理器)。
  • @James:是的,但这些都不能回答我的问题。我的问题既不是关于 call_once 的问题,也不是关于 x86 等特定架构的问题。
  • @JamesKanze:你确定要重新排序吗?正如我所说,Clang 和 GCC 都使用普通负载。 Wikipedia 上的以下页面也给人一种印象,即负载无法重新排序(只要我们不是在谈论古代机器):en.wikipedia.org/wiki/Memory_ordering
  • @nosid 与 Sparc 和 Alpha 等处理器相比,英特尔肯定不那么宽容,但它肯定允许进行一些重新排序,请参阅 intel.com/content/dam/www/public/us/en/documents/manuals/…。 (更重要的是,也许:订购保证在过去已经放宽过一次,因此可以假设它们将来会再次放宽。)
【解决方案2】:

这是未定义的行为。您正在修改variable,在 至少在某些线程中,这意味着 all 访问 变量必须受到保护。特别是,当你 在一个线程中执行atomic_compare_exchange_strong, 没有什么可以保证另一个线程可能会看到 variable 的新值在它看到可能的写入之前 发生在desired()。 (atomic_compare_exchange_strong 只保证执行它的线程中的任何顺序。)

【讨论】:

  • 我不确定我是否理解。您是否担心desired 可能执行的内存写入?如果desired 没有执行任何内存写入,而只是返回一个本身就很重要的值,您的答案会改变吗? (比如说它返回的是一个整数而不是一个指针,或者说 desired 内部有一个内存屏障,确保在返回之前一切都是可见的。)此外,问题的关键是,为什么应该每个访问都受到保护? not 保护variable 的读取是否仍然会导致正确的代码,因为稍后进行比较和交换?
  • 其实没什么好理解的。如果一个对象可以在多个线程中被访问,并且您在任何线程中修改它,那么对它的 all 访问必须是同步的,否则您有未定义的行为。与desired 中的写入有关的问题只是可能出错的例子。
  • 我不是在问什么会出错(这是标准所说的),而是我在问为什么它可能会出错(标准背后的理由可能说)。因此,除了“因为标准是这样说的”之外,我不明白这段代码中可能出现什么问题。
  • @Mehrdad 出错的原因很简单:现代处理器根据处理器的不同,对外部总线上的读写方式进行了重新排序。它们都有各种栅栏或membar指令来保证在需要时的顺序。您的代码基本上是双重检查锁定的变体,在if 中的负载上没有额外的硬件同步已被证明是不安全的。
  • 不,我认为这与双重检查锁定不同,至少是典型的那种。这里没有共享内存的其他位置,但是在双重检查锁定中构造了一个新对象,其初始化需要在变量写入之后进行,因此相对顺序是一个问题。对吗?
最近更新 更多