为什么volatile在多线程C或C ++编程中不被认为有用?

时间:2010-03-20 22:10:27

标签: c++ c multithreading volatile c++-faq

正如我最近发布的this answer所示,我似乎对多线程编程环境中volatile的效用(或缺乏)感到困惑。

我的理解是这样的:每当一个变量可以在访问它的一段代码的控制流程之外被改变时,该变量应该被声明为volatile。信号处理程序,I / O寄存器和由另一个线程修改的变量都构成了这种情况。

因此,如果你有一个全局int foo,并且foo由一个线程读取并由另一个线程原子设置(可能使用适当的机器指令),则读取线程会看到这种情况它看待由信号处理程序调整或由外部硬件条件修改的变量的方式相同,因此foo应声明为volatile(或者,对于多线程情况,使用内存隔离负载访问,这是可能是一个更好的解决方案)。

我错在哪里和哪里?

9 个答案:

答案 0 :(得分:203)

多线程上下文中volatile的问题在于它不提供所有所需的保证。它确实有一些我们需要的属性,但不是所有属性,所以我们不能单独依赖volatile

但是,我们必须用于剩余属性的原语也提供volatile所做的原语,因此实际上没有必要。

对于共享数据的线程安全访问,我们需要保证:

  • 读/写实际发生(编译器不会将值存储在寄存器中而是推迟更新主存储器直到很久以后)
  • 没有重新排序。假设我们使用volatile变量作为标志来指示某些数据是否已准备好被读取。在我们的代码中,我们只是在准备数据后设置标志,因此所有看起来都。但是,如果指令被重新排序,那么该标志首先设置为

volatile确实保证了第一点。它还保证在不同的易失性读/写之间不会发生重新排序。所有volatile内存访问都将按照指定的顺序进行。这就是volatile所需的全部内容:操作I / O寄存器或内存映射硬件,但它对多线程代码没有帮助,volatile对象通常只用于同步对非易失性数据的访问。这些访问仍然可以相对于volatile进行重新排序。

防止重新排序的解决方案是使用内存屏障,它向编译器和CPU指示没有内存访问可以在此点重新排序。在易失性变量访问周围放置这些障碍可确保即使非易失性访问也不会在易失性访问中重新排序,从而允许我们编写线程安全的代码。

但是,内存障碍确保在达到障碍时执行所有挂起的读/写操作,因此它有效地为我们提供了我们所需的一切,使volatile变得不必要。我们可以完全删除volatile限定符。

从C ++ 11开始,原子变量(std::atomic<T>)为我们提供了所有相关的保证。

答案 1 :(得分:47)

答案 2 :(得分:11)

我不认为你错了 - 如果值被线程A以外的其他东西改变,那么volatile必须保证线程A会看到值的变化。据我所知,volatile基本上是一种方式告诉编译器“不要将这个变量缓存在寄存器中,而应确保在每次访问时始终从RAM存储器中读/写”。

混淆是因为挥发性不足以实现许多事情。特别是,现代系统使用多级缓存,现代多核CPU在运行时进行一些花哨的优化,现代编译器在编译时进行一些花哨的优化,这些都会导致各种不同的副作用出现在不同的如果您只是查看源代码,请从您期望的订单中订购。

所以挥发性很好,只要你记住,挥发性变量的“观察”变化可能不会在你认为的那个时候发生。具体来说,不要尝试使用volatile变量作为跨线程同步或命令操作的方法,因为它不能可靠地工作。

就个人而言,我对volatile标志的主要(仅限?)用作“pleaseGoAwayNow”布尔值。如果我有一个连续循环的工作线程,我将在循环的每次迭代中检查volatile布尔值,如果布尔值为真,则退出。然后,主线程可以通过将boolean设置为true来安全地清理工作线程,然后调用pthread_join()以等待工作线程消失。

答案 3 :(得分:6)

你的理解确实是错误的。

volatile变量具有的属性是“读取和写入此变量是程序可感知行为的一部分”。这意味着该程序可以工作(给定适当的硬件):

int volatile* reg=IO_MAPPED_REGISTER_ADDRESS;
*reg=1; // turn the fuel on
*reg=2; // ignition
*reg=3; // release
int x=*reg; // fire missiles

问题是,这不是我们想要的任何线程安全的属性。

例如,一个线程安全的计数器就是(linux-kernel-like code,不知道c ++ 0x等价):

atomic_t counter;

...
atomic_inc(&counter);

这是原子的,没有记忆障碍。如有必要,您应该添加它们。添加volatile可能没有帮助,因为它不会将访问与附近的代码相关联(例如,将一个元素附加到计数器正在计数的列表中)。当然,您不需要在程序之外看到计数器递增,并且仍然需要优化,例如。

atomic_inc(&counter);
atomic_inc(&counter);

仍然可以优化

atomically {
  counter+=2;
}

如果优化器足够智能(它不会改变代码的语义)。

答案 4 :(得分:6)

volatile对于实现自旋锁互斥锁的基本结构很有用(尽管不够),但是一旦你拥有了它(或者更好的东西),你就不需要另一个volatile

多线程编程的典型方法不是保护机器级别的每个共享变量,而是引入指导程序流程的保护变量。而不是volatile bool my_shared_flag;你应该

pthread_mutex_t flag_guard_mutex; // contains something volatile
bool my_shared_flag;

这不仅封装了“硬件”,而且从根本上说是必要的:C不包括实现互斥锁所需的原子操作;它只有volatile来为普通操作提供额外的保证。

现在你有这样的事情:

pthread_mutex_lock( &flag_guard_mutex );
my_local_state = my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );

pthread_mutex_lock( &flag_guard_mutex ); // may alter my_shared_flag
my_shared_flag = ! my_shared_flag; // critical section
pthread_mutex_unlock( &flag_guard_mutex );

my_shared_flag不需要易变,尽管不可缓存,因为

  1. 另一个主题可以访问它。
  2. 意味着必须在某个时候(使用&运算符)对其进行引用。
    • (或参考包含结构)
  3. pthread_mutex_lock是一个库函数。
  4. 这意味着编译器无法判断pthread_mutex_lock是否以某种方式获取该引用。
  5. 意味着编译器必须假设 pthread_mutex_lock修改共享标志
  6. 因此必须从内存重新加载变量。 volatile虽然在这方面有意义,但却是无关紧要的。

答案 5 :(得分:6)

要使数据在并发环境中保持一致,您需要应用两个条件:

1)原子性,即如果我向存储器读取或写入一些数据,则该数据一次读取/写入,并且由于例如上下文切换而不能被中断或争用

2)一致性,即读/写操作的顺序必须在多个并发环境之间是相同的 - 是线程,机器等

volatile不符合上述要求 - 或者更具体地说,c或c ++标准关于挥发性应该如何表现不包括上述内容。

在实践中甚至更糟,因为一些编译器(例如intel Itanium编译器)确实尝试实现并发访问安全行为的某些元素(即通过确保内存栅栏)但是编译器实现之间没有一致性,而且标准确实如此首先不要求执行此操作。

将变量标记为volatile只会意味着每次强制将值刷新到内存中以及从内存中刷新值,这在很多情况下会降低代码速度,因为基本上会破坏缓存性能。

c#和java AFAIK通过使volatile符合1)和2)来解决这个问题,但是对于c / c ++编译器来说同样不能说,所以基本上可以根据你的需要使用它。

有关该主题的更深入(但不公正)的讨论,请阅读this

答案 6 :(得分:5)

comp.programming.threads FAQ由Dave Butenhof撰写a classic explanation

  

问题56:为什么我不需要声明共享变量VOLATILE?

     然而,我关心编译器和编译器的情况   线程库满足各自的规范。一致的   C编译器可以全局分配一些共享(非易失性)变量   在CPU传递时保存和恢复的寄存器   线程到线程。每个线程都有自己的私有值   这个共享变量,这不是我们想要的共享变量   变量

     

在某种意义上说,如果编译器对此有足够的了解,那就是这样   变量的各个范围和pthread_cond_wait(或   pthread_mutex_lock)函数。在实践中,大多数编译器都不会尝试   通过调用外部来保持全局数据的寄存器副本   功能,因为很难知道例程是否可能   不知何故可以访问数据的地址。

     

是的,确实是严格符合的编译器(但非常   积极地)ANSI C可能无法使用多个线程   易挥发。但有人最好解决它。因为任何系统(即,   实用,内核,库和C编译器的组合)   不提供POSIX内存一致性保证不CONFORM   符合POSIX标准。期。系统不要求您使用   因为POSIX,共享变量上的volatile是正确的行为   只要求POSIX同步功能是必需的。

     

因此,如果您的程序因为没有使用volatile而中断,那就是BUG。   它可能不是C中的错误,也不是线程库中的错误,也可能是错误   内核。但它是一个SYSTEM错误,以及这些组件中的一个或多个   将不得不努力解决它。

     

您不希望在其所使用的任何系统上使用volatile   任何差异,它将比一个适当的更昂贵   非易失性变量。 (ANSI C要求volatile的“序列点”   每个表达式的变量,而POSIX只需要它们   同步操作 - 计算密集型线程应用程序   将使用volatile,以及之后看到更多的内存活动   所有,记忆活动真的让你失望。)

     

/ --- [Dave Butenhof] ----------------------- [butenhof@zko.dec.com] --- \
  |数字设备公司110 Spit Brook Rd ZKO2-3 / Q18 |
  | 603.881.2218,传真603.881.0120 Nashua NH 03062-2698 |
  ----------------- [通过并发改善生活] ---------------- /

Butenhof先生在this usenet post中涵盖了相同的基础:

  

使用“volatile”不足以确保适当的内存   线程之间的可见性或同步。使用互斥锁是   足够的,除了诉诸各种非便携式机器   代码替代品,(或POSIX内存的更微妙的含义   一般来说难以应用的规则,如中所述   我以前的帖子),互斥是必要的。

     

因此,正如布莱恩解释的那样,挥发性的使用完成了   只是为了防止编译器变得有用和可取   优化,在使代码“线程”方面没有任何帮助   当然,我们欢迎您宣布任何您想要的东西   “volatile” - 毕竟它是合法的ANSI C存储属性。只是   不要指望它为你解决任何线程同步问题。

所有这些同样适用于C ++。

答案 7 :(得分:3)

根据我的旧C标准,“构成对具有volatile限定类型的对象的访问权限是实现定义的”。因此,C编译器编写者可以选择在多进程环境中具有“易变”意味着“线程安全访问”。但他们没有。

相反,在多核多进程共享内存环境中使关键部分线程安全所需的操作被添加为新的实现定义的功能。而且,由于“volatile”将在多进程环境中提供原子访问和访问排序的要求,编译器编写者优先考虑依赖于历史实现的“volatile”语义的代码减少。

这意味着像关键代码部分的“易变”信号量这样的东西,在新硬件上不能使用新的编译器,可能曾经在旧硬件上使用旧编译器,旧的例子有时候没有错,只是旧的。

答案 8 :(得分:2)

这就是“不稳定”所做的一切: “嘿编译器,这个变量可以在任何时刻(在任何时钟周期内)改变,即使没有任何本地指令作用于它。不要将这个值缓存在寄存器中。”

那就是IT。它告诉编译器你的值是,嗯,易变 - 这个值可能随时被外部逻辑(另一个线程,另一个进程,内核等)改变。它或多或少仅仅是为了抑制编译器优化,这些优化会静默地将一个值缓存在一个寄存器中,而这对EVER缓存本身就是不安全的。

您可能会遇到像“Dr. Dobbs”这样的文章,它们可以作为多线程编程的灵丹妙药。他的方法并不完全没有优点,但它有一个根本的缺陷,即使对象的用户对其线程安全负责,这往往与其他违反封装的问题相同。