【问题标题】:volatile variable updated from multiple threads C++从多线程 C++ 更新的 volatile 变量
【发布时间】:2022-08-10 13:42:14
【问题描述】:
    volatile bool b;
 
    
    Thread1: //only reads b
    void f1() {
    while (1) {
       if (b) {do something};
       else { do something else};
    }
    }
    
    Thread2: 
    //only sets b to true if certain condition met
    // updated by thread2
    void f2() {
    while (1) {
       //some local condition evaluated - local_cond
       if (!b && (local_cond == true)) b = true;
        //some other work
    }
    }
    
    Thread3:
    //only sets b to false when it gets a message on a socket its listening to
    void f3() {
    while (1) {
        //select socket
        if (expected message came) b = false;
        //do some other work
    }
    }

如果线程 2 首先在时间 t 更新 b,然后线程 3 在时间 t+5 更新 b:

thread1 在读取 b 时会看到“及时”的最新值吗?

例如:从 t+delta 到 t+5+delta 的读取应该是 true 并且 在 t+5+delta 之后读取应该是假的。

delta 是线程 2 或 3 之一更新它时将“b”存储到内存中的时间

  • volatile 不用于线程。
  • @YouliLuo:没有人谈论关键部分。 C/C++ volatile 也不适用于无锁代码;这就是 C++ std::atomic 的用途。 When to use volatile with multi threading? - 基本上从不使用std::atomic<int>std::memory_order_releaserelaxed,如果那是你想要的。
  • 一个核心上的存储不会立即对其他核心可见;在它们执行之后,在它实际提交到 L1d 缓存之前会有一些延迟。如果t+5 晚了 5 个时钟或纳秒,那么线程间延迟在您的时间尺度上很重要。但如果它是 5 秒,那么可以肯定的是,不稳定的能见度足够接近瞬时。请参阅Is a memory barrier required to read a value that is atomically modified? 的“最新值”部分
  • 简短的回答是“不”。您正在寻求的行为需要在访问共同变量或操作原子性的线程之间进行一些同步元素,volatile 两者都不支持。 volatile 所做的只是告诉编译器一个变量可能会以某种编译器不可见的方式被修改——通常这会影响优化/重新排序代码和指令的能力。如果它们抢占正在更改该变量值的线程,则不能确保(例如)读取变量的线程接收有意义的值之类的事情。
  • 如果a 仅在 f2 中使用,为什么它在结构中。

标签: c++ multithreading x86-64 volatile lock-free


【解决方案1】:

volatile 关键字的作用主要是两件事(我在这里避免科学上严格的表述):

​1)它的访问不能被缓存或组合。 (UPD:根据建议,我强调这是用于缓存在寄存器或其他编译器提供的位置,而不是 CPU 中的 RAM 缓存。)例如,以下代码:

x = 1;
x = 2;

对于 volatile 的 x 将永远不会合并为单个 x = 2,无论需要何种优化级别;但是如果x 不是易失性的,即使是低级别也可能导致这种崩溃成为一次写入。读取也是如此:每次读取操作都将访问变量值,而不会尝试对其进行缓存。

​2)所有volatile操作都与机器命令层相关,它们之间的顺序相同(下划线,仅在volatile操作之间),正如它们在源代码中定义的那样。

但对于非易失性和易失性存储器之间的访问,情况并非如此。对于以下代码:

int *x;
volatile int *vy;
void foo()
{
  *x = 1;
  *vy = 101;
  *x = 2;
  *vy = 102;
}

带有 -O2 的 gcc (9.4) 和带有 -O 的 clang (10.0) 产生类似于:

        movq    x(%rip), %rax
        movq    vy(%rip), %rcx
        movl    $101, (%rcx)
        movl    $2, (%rax)
        movl    $102, (%rcx)
        retq

因此,对x 的一次访问已经消失,尽管它存在于两个易失性访问之间。如果需要第一个x = 1 在第一次写入vy 之前成功,让他设置一个明确的障碍(因为C11,atomic_signal_fence 是平台无关的意思)。


这是通用规则,但不涉及多线程问题。多线程在这里会发生什么?

好吧,想象一下,当您声明线程 2 将 true 写入 b 时,这是将值 1 写入单字节位置。但是,这是普通的无需任何内存排序要求即可写入。你提供给volatile的是编译器不会优化它。但是处理器呢?

如果这是一个现代抽象处理器,或者一个规则宽松的处理器,比如 ARM,我会说没有什么能阻止它无限期地推迟真正的写入。 (澄清一下,“写”是将操作暴露给 RAM 和所有缓存的联合体。)这完全取决于处理器的考虑。好吧,处理器的设计目的是尽可能快地刷新其待处理写入的库存。但是什么会影响真正的延迟,你不知道:例如,它可以“决定”用下几行填充指令缓存,或者刷新另一个排队的写入......很多变体。我们唯一知道它提供了“尽最大努力”来刷新所有排队的操作,以避免被先前的结果所掩盖。这真的很自然,仅此而已。

对于 x86,还有一个额外的因素。在 x86 中,几乎每个内存写入(我猜也是这个)都是“释放”写入,因此,所有先前的读取和写入都应在此写入之前完成。但是,直觉是要完成的操作是这个写。因此,当您将true 写入易失性b 时,您将确定所有先前的操作已经对其他参与者可见......但这仍然可以推迟一段时间......多久?纳秒?微秒?任何其他对内存的写入都会刷新,因此将此写入发布到b...您是否在线程 2 的循环迭代中有写入?

这同样会影响线程 3。您无法确定此 b = false 是否会在您需要时发布到其他 CPU。延迟是不可预测的。唯一可以保证的是,如果这不是一个实时感知的硬件系统,则可以无限期地保证,并且 ISA 规则和障碍提供了排序但不提供确切的时间。而且,x86 绝对不适合这样的实时性。


好吧,这意味着您还需要一个显式的写后屏障,这不仅会影响编译器,还会影响 CPU:前一次写入之前的屏障以及后续的读取或写入。在 C/C++ 方法中,完全屏障满足了这一点 - 因此您必须添加 std::atomic_thread_fence(std::memory_order_seq_cst) 或使用具有相同内存顺序的原子变量(而不是普通的 volatile 变量)进行写入。

而且,所有这些仍然不会像您描述的那样为您提供准确的时间(“t”和“t+5”),因为不同 CPU 的相同操作的可见“时间戳”可能不同! (嗯,这有点像爱因斯坦的相对论。)在这种情况下,你只能说一些东西被写入内存,并且通常(并非总是)CPU 间的顺序是你所期望的(但顺序违规会惩罚你) .


但是,我无法理解你想用这个标志b 实现什么。你想从中得到什么,它应该反映什么状态?让你回到上层任务,重新制定。这(我只是在咖啡渣上猜测)是做某事的绿灯,但被外部订单取消了吗?如果是这样,来自线程 2 的内部许可(“我们准备好了”)不应放弃此取消。这可以使用不同的方法来完成,例如:

​1)只需在它们的集合周围分离标志和互斥锁/自旋锁。简单但有点贵(甚至非常昂贵,我不知道你的环境)。

​​2)原子修饰的类似物。例如,您可以使用通过比较和交换修改的位域变量。将位 0 ​​分配给“就绪”,但将位 1 分配给“已取消”。对于 C,atomic_compare_exchange_strong 是您在 x86(以及大多数其他 ISA)中需要的。而且,如果您继续使用 memory_order_seq_cst,则此处不再需要 volatile

【讨论】:

  • @PeterCordes 是的,我编辑了几次,在重新检查优化级别后得到了一个错误的例子。我刚刚添加了clang-O2 的示例,它确实避免了第一次写入。
  • 好的,这更有意义,是的,godbolt.org/z/TvnavhhTY 确认 GCC 和 clang -O2 对该源进行了死存储消除。 (除了消除对每个全局变量的读取,在寄存器中重用相同的指针,因为它们可以确定 int 存储不会修改 int * 对象。)(正确,别名分析不是重要的事情;这就是我删除以前的 cmets 的原因。)
  • @PeterCordes我猜别名分析不是这里的来源,只是我匆忙的错误。 gcc 和 clang 都首先使用-O2 写入。初始版本的代码来自带有-Og 的clang,当lea 从每个操作中删除但不是第一次写入时,我感到很困惑。
  • 有人告诉我,x86 CPU 在不超过 10 ns 内刷新任何写入。- 听起来像最好的如果它们都在 L1d 中命中,则用于耗尽存储缓冲区的情况。正如您所说,x86 是强排序的,因此在以前的商店提交之前,商店无法提交。如果您刚刚执行了一堆分散的存储,其 RFO(读取所有权)将丢失,那么之后的另一个存储将等待至少 DRAM 访问延迟才能提交。如果有比 LFB 更多的缓存未命中存储来跟踪传入线路或其他对内存带宽的需求,那么 RFO 不能全部并行运行。
  • 来自多个其他核心对一条线路所有权的竞争当然会进一步延迟它。我猜想 100 ns 可能足以让大多数商店变得可见(在没有高争用的情况下作为宽松的上限)。如果你真的试图用分散的商店创建一个糟糕的案例,那么可能会出现 1 微秒的延迟。见MESI Protocol & std::atomic - Does it ensure all writes are immediately visible to other threads?。可能需要 10 ns 才能淘汰一家商店?缓存未命中负载可能会使其停止。
【解决方案2】:

thread1 在读取 b 时会“及时”看到最新值吗?

是的,volatile 关键字表示它可以在编译器不知道的情况下在线程或硬件之外进行修改,因此每次访问(读取和写入)都将通过 volatile 限定类型的左值表达式进行,这被认为是可观察到的副作用优化的目的,并严格按照抽象机的规则进行评估(即所有写入都在下一个序列点之前的某个时间完成)。这意味着在单个执行线程中,相对于由序列点与 volatile 访问分隔的另一个可见副作用,无法优化或重新排序 volatile 访问。

不幸的是,volatile 关键字不是线程安全的,必须小心操作,建议为此使用 atomic,除非在嵌入式或裸机场景中。

整个结构也应该是原子的struct X {int a; volatile bool b;};

【讨论】:

  • 这是 c++11 之前的环境,也只有变量 b 被其他线程读取或写入。变量 a 仅由一个线程读/写
  • 有特定于编译器的原子。
  • “易失性访问不能相对于另一个易失性访问进行优化或重新排序”,但是对于任何非易失性访问都没有这样的要求,并且可能会发生重新排序。如果需要在非易失性和易失性访问之间进行排序,则应添加编译器屏障(如atomic_signal_fence)。请更新您的答案。
【解决方案3】:

假设我有一个有 2 个内核的系统。第一个核心运行线程 2,第二个核心运行线程 3。

从 t+delta 读取到 t+5+delta 应为 true,在 t+5+delta 之后读取应为 false。

问题是线程 1 将在t + 10000000 处读取,当内核决定其中一个线程运行了足够长的时间并调度另一个线程时。所以很可能 thread1 很多时候都看不到变化。

注意:这忽略了缓存同步性和可观察性的所有其他问题。如果线程甚至没有运行,那么所有这些都变得无关紧要。

【讨论】:

  • 假设线程有一个专用的核心并且它一直在运行,那么在 x86_64 强内存有序系统上会发生什么
  • 某物。或者是其他东西。谁知道这些功能需要多长时间。每个动作的时间都是独立的。任何事情都有可能发生。
猜你喜欢
  • 2013-04-14
  • 1970-01-01
  • 2016-11-30
  • 2021-04-09
  • 2013-08-10
  • 1970-01-01
  • 2018-04-11
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多