我应该为在不同线程之间共享内存的每个对象指定 volatile 关键字吗

Should i specify volatile keyword for every object that shares its memory between different threads

我刚刚阅读了 CERT 网站上的 Do not use volatile as a synchronization primitive 文章并注意到编译器理论上可以优化以下代码,它将 flag 变量存储在寄存器中而不是修改实际的不同线程之间共享的内存:

bool flag = false;//Not declaring as {{volatile}} is wrong. But even by declaring {{volatile}} this code is still erroneous
void test() {
  while (!flag) {
    Sleep(1000); // sleeps for 1000 milliseconds
  }
}
void Wakeup() {
  flag = true;
}
void debit(int amount){
   test();
   account_balance -= amount;//We think it is safe to go inside the critical section
}

我说得对吗?

我是否需要为我的程序中在不同线程之间共享其内存的每个对象使用 volatile 关键字?不是因为它为我做了某种同步(无论如何我需要使用互斥体或任何其他同步原语来完成这样的任务)而是因为编译器可能优化我的代码并将所有共享变量存储在寄存器中所以其他线程将永远不会获得更新值?

不仅仅是将它们存储在寄存器中,在共享主内存和 CPU 之间还有各种级别的缓存。大部分缓存是每个 CPU-核心,因此其他核心在很长一段时间内不会看到任何更改(或者如果其他核心正在修改相同的内存,那么这些更改可能会完全丢失)。

无法保证缓存的行为方式,即使某些情况适用于当前处理器,但对于旧处理器或下一代处理器而言可能并非如此。为了编写安全的多线程代码,您需要正确地执行它。为此,最简单的方法是使用提供的库和工具。尝试自己使用像 volatile 这样的低级原语来完成它是一件非常困难的事情,涉及大量深入的知识。

关于你的

bool flag = false;

示例,将其声明为 volatile 将普遍有效并且 100% 正确。 但它不会一直为你买单。

Volatile 对编译器强加了一个对象(或仅仅是 C 变量)的每一个评估要么直接在 memory/register 上完成,要么先从外部存储介质检索到内部 memory/registers。在某些情况下,代码和内存占用空间可能会很大,但真正的问题是还不够

当某些基于时间的上下文切换正在进行时(例如线程),并且您的易失性 object/variable 已对齐并适合 CPU 寄存器,您会得到您想要的。在这些严格的条件下,更改或评估是 原子地 完成的,因此在上下文切换场景中,另一个线程将立即 "aware" 任何更改。

但是,如果您的对象/大变量不适合 CPU 寄存器(从大小或不对齐),volatile 上的线程上下文切换可能仍然是 NO-NO...并发线程的评估可能会捕捉到一个中间变化的过程......例如在更改 5 成员结构副本时,并发线程在第 3 个成员更改期间被调用。 cabum!

结论是(回到"Operating-Systems 101"),你需要确定你的共享对象,选择抢占+阻塞或非抢占或其他并发资源访问策略,并使你的evaluaters/changers原子。访问方法 (change/eval) 通常包含 make-atomic 策略,或者(如果它对齐且很小)简单地将其声明为 volatile。

其实很简单,但同时也很迷惑。在高层次上,当您编写 C++ 代码时,有两个优化实体在起作用——编译器和 CPU。在编译器中,有两种关于变量访问的主要优化技术——省略变量访问,即使是在代码中编写的,并围绕这个特定的变量访问移动其他指令。

特别是,以下示例演示了这两种技术:

int k; 布尔标志;

void foo() {
    flag = true;
    int i = k;
    k++;
    k = i;
    flag = false;
}

在提供的代码中,编译器可以自由跳过标志的第一次修改——只留下最终赋值给 false;并完全删除对 k 的任何修改。如果你使 k 易变,你将要求编译器保留对 k 的所有访问 = 它会增加,而不是原始值放回。如果您也将标志设为 volatile,则两个赋值首先为 true,然后两个 false 将保留在代码中。但是,重新排序仍然是可能的,有效代码可能看起来像

void foo() {
    flag = true;
    flag = false;
    int i = k;
    k++;
    k = i;
}

如果另一个线程期望标志来指示现在是否正在修改 k,这将产生令人不快的效果。

实现预期效果的方法之一是将两个变量都定义为 atomic。这将阻止编译器进行两种优化,确保执行的代码与编写的代码相同。请注意,atomic 实际上是一个 volatile+ - 它完成了 volatile 所做的所有工作 + 更多。

另一件需要注意的事情是,编译器优化确实是一个非常强大且需要的工具。不应该为了好玩而阻碍他们,所以原子性应该只在需要的时候使用。