为啥 volatile 在多线程 C 或 C++ 编程中没有用?

Posted

技术标签:

【中文标题】为啥 volatile 在多线程 C 或 C++ 编程中没有用?【英文标题】:Why is volatile not considered useful in multithreaded C or C++ programming?为什么 volatile 在多线程 C 或 C++ 编程中没有用? 【发布时间】:2011-01-29 21:50:06 【问题描述】:

正如我最近发布的this answer 中所展示的,我似乎对volatile 在多线程编程上下文中的实用性(或缺乏实用性)感到困惑。

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

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

我哪里错了?

【问题讨论】:

所有 volatile 所做的只是说编译器不应该缓存对 volatile 变量的访问。它没有说明序列化这种访问。这已经在这里讨论了不知道多少次了,我认为这个问题不会对这些讨论增加任何内容。 @neil 我搜索了其他问题,并找到了一个,但我看到的任何现有解释都没有触发我真正理解为什么我错了的原因。这个问题引出了这样一个答案。 如需深入研究 CPU 对数据的处理方式(通过其缓存),请查看:rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf 在 Java 中 volatile 在读取时会创建一个内存屏障,因此它可以用作方法已结束的线程安全标志,因为它与标志之前的代码强制执行发生前的关系放。在 C 中情况并非如此。 @curiousguy 这就是我所说的“C 中不是这种情况”的意思,它可以用于写入硬件寄存器等,并且不像 Java 中常用的那样用于多线程。 【参考方案1】:

您也可以通过Linux Kernel Documentation 考虑这一点。

C 程序员经常认为 volatile 表示变量 可以在当前执行线程之外更改;作为一个 结果,他们有时很想在内核代码中使用它 正在使用共享数据结构。换句话说,他们已经 已知将 volatile 类型视为一种简单的原子变量, 他们不是。在内核代码中使用 volatile 几乎从来没有 正确的;本文档描述了原因。

理解 volatile 的关键在于它的 目的是抑制优化,这几乎从来不是什么 真的很想做。在内核中,必须保护共享数据 针对不需要的并发访问的结构,这在很大程度上是一种 不同的任务。防止不需要的过程 并发也将避免几乎所有与优化相关的问题 以更有效的方式。

像 volatile 一样,内核原语可以同时访问 数据安全(自旋锁、互斥锁、内存屏障等)旨在 防止不必要的优化。如果它们使用得当, 也不需要使用 volatile 。如果 volatile 仍然 必要时,几乎可以肯定代码中的某个地方存在错误。在 正确编写的内核代码,易失性只能用于减慢速度 下来。

考虑一个典型的内核代码块:

spin_lock(&the_lock);
do_something_on(&shared_data);
do_something_else_with(&shared_data);
spin_unlock(&the_lock);

如果所有代码都遵循锁定规则,shared_data的值 持有 the_lock 时不能意外更改。任何其他代码 可能想要使用该数据的将在锁上等待。 自旋锁原语充当内存屏障——它们是显式的 这样做 - 意味着数据访问不会被优化 穿过他们。所以编译器可能会认为它知道里面会有什么 shared_data,但 spin_lock() 调用,因为它充当内存 障碍,会迫使它忘记它所知道的一切。将没有 访问该数据的优化问题。

如果 shared_data 被声明为 volatile,锁定仍然是 必要的。但是编译器也会被阻止优化 当我们知道 没有其他人可以使用它。在持有锁的同时, shared_data 不是易失的。在处理共享数据时,适当 锁定使 volatile 变得不必要 - 并且可能有害。

易失性存储类最初用于内存映射 I/O 寄存器。在内核中,寄存器访问也应该是 受锁保护,但也不希望编译器 “优化”关键部分中的寄存器访问。但是,内 内核,I/O 内存访问总是通过访问器完成 职能;直接通过指针访问 I/O 内存被皱眉 在所有架构上并且不适用于所有架构。这些访问器是 为防止不必要的优化而编写,因此,volatile 是 没必要。

另一种可能会尝试使用 volatile 的情况是 处理器正忙于等待变量的值。正确的 执行忙碌等待的方法是:

while (my_variable != what_i_want)
    cpu_relax();

cpu_relax() 调用可以降低 CPU 功耗或屈服于 超线程双处理器;它也恰好作为记忆 障碍,所以,再一次, volatile 是不必要的。当然, 忙着等待通常是一种反社会行为。

仍有一些罕见的情况下 volatile 在 内核:

上述访问器函数可能使用 volatile on 直接 I/O 内存访问确实有效的架构。本质上, 每个访问器调用本身都成为一个小的关键部分,并且 确保访问按程序员的预期进行。

内联汇编代码改变内存,但没有其他 可见的副作用,有被 GCC 删除的风险。添加挥发物 asm 语句的关键字将阻止此删除。

jiffies 变量的特殊之处在于它可以有不同的值 每次都被引用,但是没有什么特别的就可以读取 锁定。所以 jiffies 可能是不稳定的,但是添加其他 这种类型的变量被强烈反对。 Jiffies 被认为是 在这方面成为一个“愚蠢的遗产”问题(Linus 的话);修复它 麻烦多于其价值。

指向可能被修改的相干内存中的数据结构的指针 有时,通过 I/O 设备可以合法地波动。环形缓冲区 由网络适配器使用,该适配器将指针更改为 指示哪些描述符已被处理,就是一个例子 情况类型。

对于大多数代码,以上对 volatile 的理由都不适用。 因此,使用 volatile 很可能被视为一个错误,并且 将对代码进行额外的审查。开发人员 想使用 volatile 应该退后一步,想想什么 他们真的在努力完成。

【讨论】:

@curiousguy:是的。另见gcc.gnu.org/onlinedocs/gcc-4.0.4/gcc/Extended-Asm.html spin_lock() 看起来像一个常规的函数调用。它有什么特别之处,编译器会对其进行特殊处理,以便生成的代码“忘记”在 spin_lock() 之前读取并存储在寄存器中的 shared_data 的任何值,以便必须在在 spin_lock() 之后 do_something_on()? @underscore_d 我的意思是,我不能从函数名 spin_lock() 看出它做了什么特别的事情。我不知道里面有什么。特别是,我不知道实现中阻止编译器优化后续读取的内容。 切分音有一个好处。这实质上意味着程序员应该知道这些“特殊功能”的内部实现,或者至少非常了解它们的行为。这引发了其他问题,例如 - 这些特殊功能是否已标准化并保证在所有架构和所有编译器上都以相同的方式工作?是否有可用的此类函数的列表,或者至少有使用代码 cmets 的约定来向开发人员发出信号,即所讨论的函数可以保护代码不被“优化”? @Tuntable:任何代码都可以通过指针访问私有静态。它的地址正在被占用。也许数据流分析能够证明指针永远不会逃逸,但这通常是一个非常困难的问题,程序大小是超线性的。如果您有办法保证不存在别名,那么通过自旋锁移动访问实际上应该没问题。但如果不存在别名,volatile 也毫无意义。在所有情况下,“调用无法看到主体的函数”行为都是正确的。【参考方案2】:

comp.programming.threads 常见问题解答有 a classic explanation,作者是 Dave Butenhof:

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

但是,我担心编译器和 线程库满足各自的规范。一个符合的 C 编译器可以全局分配一些共享(非易失)变量到 当 CPU 从 线程到线程。每个线程都有自己的私有值 这个共享变量,这不是我们想要的共享变量 变量。

在某种意义上这是真的,如果编译器对 变量和 pthread_cond_wait 的各自范围(或 pthread_mutex_lock) 函数。在实践中,大多数编译器不会尝试 在对外部的调用中保留全局数据的寄存器副本 函数,因为很难知道例程是否可能 以某种方式可以访问数据的地址。

所以是的,严格遵守的编译器确实如此(但非常 积极地)到 ANSI C 可能无法在没有多个线程的情况下工作 易挥发的。但最好有人修复它。因为任何系统(即, 务实地,内核、库和 C 编译器的组合) 不提供 POSIX 内存一致性保证 不符合 POSIX 标准。时期。系统不能要求您使用 volatile 在共享变量上以确保正确的行为,因为 POSIX 只要求 POSIX 同步函数是必需的。

因此,如果您的程序因未使用 volatile 而中断,那就是 BUG。 它可能不是 C 中的错误,也可能不是线程库中的错误,也可能不是 内核。但这是一个系统错误,以及其中一个或多个组件 必须努力修复它。

您不想使用 volatile,因为在它生成的任何系统上 任何区别,它都会比适当的贵得多 非易失性变量。 (ANSI C 需要 volatile 的“序列点” 每个表达式的变量,而 POSIX 仅在 同步操作——计算密集型线程应用程序 使用 volatile 会看到更多的内存活动,并且,之后 总而言之,真正让你慢下来的是记忆活动。)

/---[戴夫布滕霍夫]-----------------------[butenhof@zko.dec.com]---\ | Digital Equipment Corporation 110 Spit *** Rd ZKO2-3/Q18 | | 603.881.2218, 传真 603.881.0120 纳舒厄 NH 03062-2698 | -----------------[通过并发改善生活]----------------/

Butenhof 先生在this usenet post 中涵盖了许多相同的领域:

使用“易失性”不足以确保正确的内存 线程之间的可见性或同步。互斥锁的使用是 足够了,而且,除非借助各种非便携式机器 代码替代品,(或更微妙的 POSIX 内存含义 更难以普遍应用的规则,如在 我以前的帖子),互斥锁是必需的。

因此,正如 Bryan 所解释的,使用 volatile 可以完成 只不过是为了防止编译器变得有用和可取 优化,在使代码“线程”方面没有任何帮助 安全”。当然,欢迎您将任何您想要的东西声明为 “volatile”——毕竟这是一个合法的 ANSI C 存储属性。只是 不要指望它能为你解决任何线程同步问题。

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

【讨论】:

链接坏了;它似乎不再指向您想要引用的内容。没有文字,这是一个毫无意义的答案。【参考方案3】:

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

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

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

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

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

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

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

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

【讨论】:

@jbcreix:你问的是哪个“它”?易失性或内存障碍?无论如何,答案几乎相同。它们都必须在编译器和 CPU 级别上工作,因为它们描述了程序的可观察行为——因此它们必须确保 CPU 不会重新排序所有内容,从而改变它们所保证的行为。但是您目前无法编写可移植的线程同步,因为内存屏障不是标准 C++ 的一部分(因此它们不是可移植的),而且volatile 不够强大,无法派上用场。 一个 MSDN 示例执行此操作,并声称指令无法通过 volatile 访问重新排序:msdn.microsoft.com/en-us/library/12a04hfd(v=vs.80).aspx @OJW:但微软的编译器将volatile 重新定义为一个完整的内存屏障(防止重新排序)。这不是标准的一部分,因此您不能在可移植代码中依赖这种行为。 @Skizz:线程本身始终是 C++11 和 C11 之前的平台相关扩展。据我所知,每个提供线程扩展的 C 和 C++ 环境也提供“内存屏障”扩展。无论如何,volatile 对于多线程编程总是没用的。 (在 Visual Studio 下除外,其中 volatile 内存屏障扩展。) @guardian:不,不是,数据依赖性分析将内存屏障视为一个外部函数,它可能会更改任何曾经被别名的变量。 (注册地址从未被占用的存储局部变量实际上是完全安全的)。即使在单线程代码中,global_x = 5; extern_call(); cout << global_x; 编译器也无法将其替换为 cout << 5;,因为 extern_call() 可能已经更改了值。【参考方案4】:

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

相反,在多核多进程共享内存环境中使临界区线程安全所需的操作被添加为新的实现定义的功能。而且,由于摆脱了“易失性”将在多进程环境中提供原子访问和访问顺序的要求,编译器编写者优先考虑代码缩减而不是依赖于历史实现的“易失性”语义。

这意味着关键代码部分周围的“易失性”信号量之类的东西,在新硬件和新编译器上不起作用,可能曾经在旧硬件上与旧编译器一起工作,而且旧示例有时并没有错,只是旧的。

【讨论】:

旧示例要求程序由适合低级编程的高质量编译器处理。不幸的是,“现代”编译器已经将标准不要求他们以有用的方式处理“易失性”这一事实作为表明要求他们这样做的代码被破坏的事实,而不是认识到标准没有努力禁止符合标准但质量低到无用的实现,但绝不容忍低质量但符合标准的编译器已变得流行 在大多数平台上,很容易识别volatile 需要做什么才能允许以一种依赖于硬件但独立于编译器的方式编写操作系统。要求程序员使用依赖于实现的特性而不是让volatile 按要求工作会破坏制定标准的目的。【参考方案5】:

这就是“volatile”所做的一切: “嘿编译器,即使没有本地指令作用于它,这个变量也可能在任何时刻(在任何时钟滴答)发生变化。不要将此值缓存在寄存器中。”

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

您可能会遇到诸如“Dr. Dobbs”之类的文章,它们将 volatile 视为多线程编程的灵丹妙药。他的方法并非完全没有优点,但它的根本缺陷是让对象的用户对其线程安全负责,这往往与其他违反封装的问题相同。

【讨论】:

【参考方案6】:

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

1) 原子性,即如果我在内存中读取或写入一些数据,那么这些数据会一次性读取/写入,并且不能因上下文切换而被中断或竞争

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

volatile 不符合上述任何一项——或者更具体地说,关于 volatile 应如何表现的 c 或 c++ 标准不包括上述任何一项。

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

将变量标记为 volatile 仅意味着您每次都强制将值刷新到内存中或从内存中刷新,这在许多情况下只会减慢您的代码速度,因为您基本上已经破坏了缓存性能。

c# 和 java AFAIK 确实通过使 volatile 遵守 1) 和 2) 来解决这个问题,但是对于 c/c++ 编译器不能这样说,所以基本上按照你认为合适的方式处理它。

有关该主题的更深入(尽管并非公正)讨论,请阅读this

【讨论】:

+1 - 保证原子性是我所缺少的另一部分。我假设加载 int 是原子的,因此防止重新排序的 volatile 在读取端提供了完整的解决方案。我认为对于大多数架构来说这是一个不错的假设,但它不是保证。 个人对内存的读写何时是可中断且非原子的?有什么好处吗?【参考方案7】:

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 不需要是易失性的,尽管不可缓存,因为

    另一个线程可以访问它。 表示对它的引用一定是在某个时候被引用的(使用& 运算符)。 (或引用包含结构) pthread_mutex_lock 是一个库函数。 意味着编译器无法判断 pthread_mutex_lock 是否以某种方式获取了该引用。 意味着编译器必须假定 pthread_mutex_lock 修改了共享标志! 因此必须从内存中重新加载变量。 volatile 虽然在这种情况下很有意义,但却是无关紧要的。

【讨论】:

【参考方案8】:

你的理解真的错了。

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 代码,不知道 c++0x 的等价物):

atomic_t counter;

...
atomic_inc(&counter);

这是原子的,没有内存屏障。如有必要,您应该添加它们。添加 volatile 可能无济于事,因为它不会关联对附近代码的访问(例如,将元素附加到计数器正在计数的列表中)。当然,您不需要在程序之外看到计数器递增,并且仍然需要优化,例如。

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

仍可优化为

atomically 
  counter+=2;

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

【讨论】:

【参考方案9】:

我不认为你错了—— volatile 是必要的,以保证线程 A 会看到值的变化,如果值被线程 A 以外的东西改变。据我了解, volatile 基本上是一种方式告诉编译器“不要将此变量缓存在寄存器中,而是确保在每次访问时始终从 RAM 内存中读取/写入它”。

混淆是因为 volatile 不足以实现许多事情。特别是现代系统使用多级缓存,现代多核 CPU 在运行时进行一些花哨的优化,现代编译器在编译时进行一些花哨的优化,这些都可能导致各种副作用以不同的方式出现。如果您只是查看源代码,请按照您期望的顺序进行排序。

所以 volatile 没问题,只要您记住 volatile 变量中的“观察到的”变化可能不会在您认为它们会发生的确切时间发生。具体来说,不要尝试使用 volatile 变量作为跨线程同步或排序操作的一种方式,因为它不会可靠地工作。

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

【讨论】:

您的布尔标志可能不安全。您如何保证工作人员完成其任务,并且标志保持在范围内,直到它被读取(如果它被读取)?这是信号的工作。 Volatile 适用于实现简单的自旋锁如果不涉及互斥锁,因为别名安全意味着编译器假定mutex_lock(以及所有其他库函数)可能会改变标志变量的状态。 显然它只有在工作线程例程的性质是保证定期检查布尔值时才有效。 volatile-bool-flag 保证保持在作用域内,因为线程关闭序列总是发生在保存 volatile-boolean 的对象被销毁之前,并且线程关闭序列在设置 bool 后调用 pthread_join()。 pthread_join() 将阻塞,直到工作线程消失。信号有其自身的问题,尤其是与多线程结合使用时。 工作线程保证在布尔值为真之前完成其工作——事实上,当布尔设置为真。但是工作线程何时完成其工作单元并不重要,因为在任何情况下,主线程都不会做任何事情,除非在工作线程退出之前阻塞 pthread_join() 。所以关闭顺序是有序的——在 pthread_join() 返回之前不会释放 volatile bool(和任何其他共享数据),并且 pthread_join() 在工作线程消失之前不会返回。 @Jeremy,您在实践中是正确的,但理论上它仍然可能会中断。在两核系统上,一个核不断执行您的工作线程。另一个核心将 bool 设置为 true。但是,不能保证工作线程的核心会看到这种变化,即即使它重复检查布尔值,它也可能永远不会停止。 c++0x、java 和 c# 内存模型允许这种行为。在实践中,这永远不会发生,因为繁忙的线程很可能会在某处插入内存屏障,之后它会看到 bool 的变化。 以POSIX系统,使用实时调度策略SCHED_FIFO,静态优先级高于系统中其他进程/线程,足够多的核,应该是完全可以的。在 Linux 中,您可以指定实时进程可以使用 100% 的 CPU 时间。如果没有更高优先级的线程/进程,它们将永远不会进行上下文切换,并且永远不会被 I/O 阻塞。但关键是 C/C++ volatile 并不意味着强制执行正确的数据共享/同步语义。我发现寻找特殊情况来证明不正确的代码有时可能会起作用是无用的练习。

以上是关于为啥 volatile 在多线程 C 或 C++ 编程中没有用?的主要内容,如果未能解决你的问题,请参考以下文章

在C语言的多线程编程中一般volatile应该用在啥地方?

为啥在双重检查锁定中使用 Volatile.Write?

C++ volatile关键字(多线程中声明为易变值不稳定值,告诉程序每次都从内存读取,不被编译优化,防止被优化后变量异常)

volatile变量的值在多线程中不会改变

volatile

volatile的应用