【问题标题】:Is volatile required here这里需要 volatile
【发布时间】:2018-02-12 15:17:15
【问题描述】:

我正在实现一个“序列锁”类,以允许对数据结构进行锁定写入和无锁读取。

将包含数据的结构包含序列值,在写入时序列值将增加两次。写开始前一次,写完后一次。作者在读者之外的其他线程上。

这是保存数据副本的结构,序列值如下所示:

template<typename T>
struct seq_data_t
{
    seq_data_t() : seq(0) {};
    int seq;                     <- should this be 'volatile int seq;'?
    T data;
};

整个序列锁类在一个循环缓冲区中保存了这个结构的 N 个副本。写入器线程总是覆盖循环缓冲区中最旧的数据副本,然后将其标记为当前副本。写入被互斥锁。

读取功能未锁定。它尝试读取数据的“当前”副本。它在读取之前存储“seq”值。然后它读取数据。然后它再次读取 seq 值,并将其与第一次读取的值进行比较。如果 seq 值没有变化,则认为 read 是好的。

由于写入线程可以在读取发生时更改“seq”的值,我认为应该将 seq 变量标记为 volatile,以便读取函数在读取数据后显式读取该值。

读取函数如下所示:它将在写入者以外的线程上,并且可能在多个线程上。

    void read(std::function<void(T*)>read_function)
    {
        for (;;)
        {
            seq_data_type<T>* d = _data.current; // get current copy
            int seq1 = d->seq;      // store starting seq no
            if (seq1 % 2)           // if odd, being modified...
                continue;           //     loop back

            read_function(&d->data);  // call the passed in read function
                                      // passing it our data.


//??????? could this read be optimized out if seq is not volatile?
            int seq2 = d->seq;      // <-- does this require that seq be volatile?
//???????

            if (seq1 == seq2)       // if still the same, good.
                return;             // if not the same, we will stay in this
                                    // loop until this condition is met.
        }
    }

问题:

1) seq 在这种情况下必须是 volatile 吗?

2) 在具有多个成员的结构的上下文中,是否只有 volatile 限定变量 volatile,而不是其他成员?也就是说,如果我只在结构中将其标记为 volatile,那么它是否只是 'seq' volatile?

【问题讨论】:

  • volatile 完全没有使代码线程安全 - 这是对它应该用于的用途的公然滥用
  • 除非您平台的文档中有说明,否则多线程代码中永远不需要volatile
  • 我不是想用 volatile 使代码线程安全。文字(此处未显示)被锁定。这提供了无锁读取。
  • “由于写入线程可以在读取发生时更改 'seq' 的值,我认为应该将 seq 变量标记为 volatile” -这与您上次的评论直接矛盾
  • @ttemple 应该是std::atomic&lt;int&gt;

标签: c++ multithreading volatile


【解决方案1】:

不要使用volatile,使用std::atomic&lt;&gt;volatile 旨在用于与内存映射硬件进行交互,std::atomic&lt;&gt; 旨在用于线程同步。为工作使用正确的工具。

良好的std::atomic&lt;&gt; 实现的特点:

  • 它们对于标准整数类型是无锁的(通常是 long long 之前的所有类型)。

  • 它们适用于任何数据类型,但将对复杂数据类型使用透明互斥体。

  • 如果std::atomic&lt;&gt; 是无锁的,它会插入正确的内存屏障/栅栏以实现正确的语义。

  • std::atomic&lt;&gt; 的操作无法优化,毕竟它们是为线程间通信而设计的。

【讨论】:

  • 由于 OP 正在尝试实现 Seqlock,即某种“hack”来提高性能,是什么让您认为它与可移植性有关?
  • @AndriyBerestovskyy 是什么让您认为如果可能的话,性能 hack 不应该是可移植的(据我所知,这个是可移植的)?
  • @AndriyBerestovskyy 我不认为,这与可移植性有关。它与速度和正确性一样重要。如果要实现快速的Seqlock,需要使用原子类型,否则可能会得到无意义的序号读取,而原子类型是保证序号有效读取的最快方式 .
  • @ttemple 是的,这是对原子的保证。
  • @ttemple 正如我所说,原子是专门设计用于线程间通信的。这意味着编译器不得 a) 优化读/写,或 b) 发明读/写。如果是这样,您将无法生成依赖于原子的有效并行代码。
【解决方案2】:

正如Is volatile required here 所说,您不应该使用volatile 进行线程间同步。这就是原因(来自 C++ 标准):

[..] volatile 是对实现避免激进的提示 涉及对象的优化,因为对象的值 可能会通过实现无法检测到的方式进行更改。[...]

volatile不做是确保一个线程中的操作顺序(尤其是内存读取和写入)在其他线程中以相同的顺序可见(due to superscalar architecture of modern CPUs ) .为此,您需要 内存屏障内存栅栏(同一事物的不同名称)。以下是一些您可能会觉得有用的阅读材料:

【讨论】:

  • 这不是所要求的,OP 关心的是不正确的编译器优化,而不是线程竞争条件,也不是指令序列。一旦有人在同一个问题中加上“线程”和“易失性”,这种下意识的回答就会变得非常令人厌烦。
  • @Lundin 如果 OP 担心某些编译器对某些语言特性的行为,那么他们不应该询问在某些特定线程代码中是否需要或适当,这不可避免地会带来线程安全讨论。
  • 由于 OP 正在尝试实现 Seqlock,即某种“hack”来提高性能,是什么让您认为它与可移植性有关?
  • @MárioFeroldi 如何实现没有回调函数的线程?这里的问题是回调函数的使用,而不是线程的使用。
  • 我不使用它进行同步。我需要确保第二次读取的 seq 没有​​被优化掉,因为值可能已被写入者更改。如果 seq 的第二次读取显示它的值已更改,则循环重复并返回一个新的“当前”,并再次尝试读取,直到 seq 在读取期间没有更改。在我的情况下发生这种情况的可能性为零,但必须进行检查以确保“数据”在被读取时没有被写入。
【解决方案3】:

1) seq 在这种情况下必须是 volatile 吗?

当然,来自seq 的读取很可能会使用-O3 进行优化。所以是的,您应该提示编译器 seq 可能会在其他地方(即在其他线程中)使用 volatile 关键字进行更改。

对于 x86 架构,这就足够了,因为 x86 内存模型(几乎)是顺序的,如 on Wikipedia 所述。

为了可移植性,您最好使用原子原语。

2) 在具有多个成员的结构的上下文中,是否只有 volatile 限定变量 volatile,而不是其他成员?也就是说,如果我只在结构中将其标记为 volatile,那么它是否只是 'seq' volatile?

不,data 也应该标记为 volatile(或者您也应该使用原子原语)。基本上,循环:

for (;;) {
    seq1 = d->seq;
    read_data(d->data);
    seq2 = d->seq;
    if (seq1 == seq2)
        return;
}

相当于:

read_data(d->data);
return;

因为代码中唯一可观察到的效果是read_data() 调用。

请注意,-O3 编译器很可能会非常广泛地重新排序您的代码。因此,即使对于 x86 架构,您也需要在第一个 seq 读取、data 读取和第二个 seq 读取之间设置一个编译器屏障,即:

for (;;)
    {
        seq_data_type<T>* d = _data.current;
        int seq1 = d->seq;
        COMPILER_BARRIER();
        if (seq1 % 2)
            continue;

        read_function(&d->data);
        COMPILER_BARRIER();
        int seq2 = d->seq;
        if (seq1 == seq2)
            return;
    }
}

最轻量级的编译器屏障是:

 #define COMPILER_BARRIER asm volatile("" ::: "memory")

对于 C++11,您可以改用 atomic_signal_fence()

总的来说,使用std::atomic&lt;&gt; 更安全:它更便携,而且不像volatiles 和编译器障碍那么棘手......

还请查看 Herb Sutter 名为“原子武器”的演示文稿,其中解释了编译器和其他内存屏障以及原子:https://channel9.msdn.com/Shows/Going+Deep/Cpp-and-Beyond-2012-Herb-Sutter-atomic-Weapons-1-of-2

【讨论】:

  • C++ 有一个API for memory orderstd::memory_order 指定如何围绕原子操作对常规的非原子内存访问进行排序。应该使用它而不是你的COMPILER_BARRIER hack。
  • @MárioFeroldi 当然,我在回答中至少说了两次——使用 atomic 我完全同意这一点,但是需要这些 hack 和 volatile 来修复 x86 上的现有代码架构......它不是可移植的,但它会工作。
  • 这是最全面、最准确的答案。使用 Compiler Explorer 我能够看到 volatile 和 std::atomic 的效果,并且您所说的一切都是正确的。 volatile 单独需要屏障。正如您所说,std::atomic 似乎在没有 volatile 和障碍的组合的情况下给了我所需的一切。谢谢。
  • @MárioFeroldi:seq 的第二次加载必须等到T data 的加载之后。 std::memory_order_acquire 不能给我们那个除非我们有atomic&lt;T&gt; data 并且在那个上做一个获取负载。 (理论上我们应该这样做以避免数据争用 UB,但这会破坏 seqlock 的全部目的,即避免类型锁定以使硬件自动加载)。所以我们实际上想要一个std::atomic_thread_fence(std::memory_order_acquire),即负载屏障。这仍然是 x86 上仅编译时的屏障,但实际上在弱排序 ISA 上是正确的。
  • 相关:Implementing 64 bit atomic counter with 32 bit atomics 是对 C++11 seqlock 的尝试,其中包含对 asm 中的安全性以及制作数据的评论 volatile 与使用 GNU C 内存屏障以获得良好的 asm可以进行更广泛的复制。
【解决方案4】:

如果代码是可移植的,volatile 是不合适的,除非处理内存映射的硬件。我再说一遍,从不合适。 Microsoft Visual C++(x86 或 x86/64)使用默认编译器标志,添加了一些标准中没有的内存顺序保证。因此,使用该编译器并打开非标准行为,volatile 可能适用于某些多线程操作。

使用标准的多线程支持,例如std::atomic、std::mutex、std::condition_variable等

【讨论】:

  • 我在问题的任何地方都没有看到“Windows PC”。在很多情况下volatile 非常合适,尤其是在各种嵌入式系统中。 volatile 当然从不适合线程安全的目的,但这是另一个话题。
  • 只适用于处理硬件。这就是 Bjarne Stroustrup 所说的,他知道自己的东西。如果您知道反例,请告诉我们(和他)。关于 VC++ 的一点是奖励信息。
  • 抱歉,Bjarne Stroustrup 甚至不知道 C++ 中用于独立系统的 main() 的格式 - 自 1998 年以来,他的 C++ FAQ 就独立系统而言一直是不正确的,现在仍然如此。我有点怀疑他是否一开始就知道这种系统的存在。
  • @Lundin 你歪曲了我的评论。我没有说要使用 Stroustrup 作为独立系统的标准。事实上,我说他[几乎可以肯定]不关心他们,故意忽视他们。这并不意味着他不知道他们,我仍然认为这极不可能。无论如何,请帮我找出错误:我看不出文字有什么问题。 void main() 在 C++ 中根本无效,无论“独立”还是“托管”。
  • @Lundin 不抱歉,你失去了我。这部分和他的论点纯粹是关于main 的有效签名,而不是关于替代(实现定义的)启动方法。 void main() 在托管和独立 C++ 中都是无效的,本章其余部分都不能证明他的论点是错误的。区别与本节完全无关(对于 C++;我不了解 C)。
【解决方案5】:

实际的问题是在写入时从某些内存(在这种情况下为data)读取被描述为数据竞争,因此程序的行为是未定义的。即使您将seq 设为原子,从data 读取仍会导致数据竞争。一种可能的正确方法是也锁定读取。

回答您关于volatile 是否解决了从seq 读取被优化的问题:编译器不会从seq 中删除两个读取,但这并不能解决任何问题,因为seq 仍然很容易发生到数据竞争,也会导致未定义的行为。 volatile 不是这个意思,所以不要滥用它。

【讨论】:

  • “读取数据仍会导致数据竞争”——不正确。原作者试图实现的算法称为 Seqlock,这是维基百科的文章:en.wikipedia.org/wiki/Seqlock我知道,那是另一种“hack”,但有些人需要那些......
  • @AndriyBerestovskyy 这不是可移植的,我的回答是从标准层面讲。
  • 为什么不呢? Seqlocks 用于 Linux 内核,所以我很确定它可以移植到相当多的架构中......
  • @ttemple 引用了马里奥对我的回答的评论......我确实相信问题是关于性能,而不是便携性,我相信 Seqlocks 是便携的......
  • @ttemple 当然,我明白这一点,但大多数答案都归结为“不要使用挥发物”,这有点令人沮丧......
【解决方案6】:

答案是:视情况而定。您是否有理由怀疑您的编译器不知道从回调函数中执行的代码可以随时执行?在托管系统编译器(Windows/Linux 等)上通常不是这种情况,但在嵌入式系统中很可能是这种情况,尤其是裸机或 RTOS。

这个话题有点像死马,例如here

volatile 的作用:

  • 如果变量是从外部源(硬件寄存器、中断、不同线程、回调函数等)修改的,则保证变量中的值是最新的。
  • 阻止对变量的读/写访问的所有优化。
  • 当编译器没有意识到线程/中断/回调被程序调用时,防止可能发生在多个线程/中断/回调函数之间共享的变量上的危险优化错误。 (这在各种有问题的嵌入式系统编译器中尤为常见,当您遇到此错误时,很难追查到。)

volatile 没有:

  • 它不保证原子访问或任何形式的线程安全。
  • 它不能用来代替互斥体/信号量/保护/临界区。它不能用于线程同步。

volatile 可以做什么或不可以做什么:

  • 编译器可能会也可能不会实现它以提供内存屏障,以防止在多核环境中出现指令缓存/指令管道/指令重新排序问题。你永远不应该假设 volatile 会为你做这件事,除非编译器文档明确声明它会这样做。

【讨论】:

  • 我希望我知道为什么会被批评!
猜你喜欢
  • 2015-03-02
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-05-16
  • 2012-06-03
  • 1970-01-01
  • 2021-08-27
相关资源
最近更新 更多