繁体   English   中英

C++ volatile 关键字是否引入了内存栅栏?

[英]Does the C++ volatile keyword introduce a memory fence?

我知道volatile通知编译器该值可能会更改,但是为了完成此功能,编译器是否需要引入内存栅栏才能使其工作?

根据我的理解,对 volatile 对象的操作顺序不能重新排序,必须保留。 这似乎意味着一些内存栅栏是必要的,并且没有真正的解决方法。 我这样说对吗?


这个相关问题上有一个有趣的讨论

乔纳森·韦克利写道

... 对不同 volatile 变量的访问不能被编译器重新排序,只要它们出现在单独的完整表达式中 ... 没错, volatile 对于线程安全是无用的,但不是因为他给出的原因。 这不是因为编译器可能会重新排序对易失性对象的访问,而是因为 CPU 可能会重新排序它们。 原子操作和内存屏障阻止编译器和 CPU 重新排序

David Schwartz 在评论中回复:

... 从 C++ 标准的角度来看,编译器做某事和编译器发出导致硬件做某事的指令之间没有区别。 如果 CPU 可以重新排序对 volatile 的访问,则标准不要求保留它们的顺序。 ...

... C++ 标准对重新排序的内容没有任何区别。 并且您不能争辩说 CPU 可以在没有可观察效果的情况下对它们重新排序,所以没关系——C++ 标准将它们的顺序定义为可观察的。 如果编译器生成的代码使平台执行标准要求,则编译器在平台上符合 C++ 标准。 如果标准要求不重新排序对 volatile 的访问,则重新排序它们的平台不兼容。 ...

我的观点是,如果 C++ 标准禁止编译器重新排序对不同 volatile 的访问,理论上这种访问的顺序是程序可观察行为的一部分,那么它还要求编译器发出禁止 CPU 执行的代码所以。 该标准没有区分编译器做什么和编译器生成代码让 CPU 做什么。

这确实会产生两个问题:它们中的任何一个“正确”吗? 实际的实现到底做了什么?

与其解释volatile作用,不如让我解释一下您何时应该使用volatile

  • 在信号处理程序内部时。 因为写入volatile变量几乎是标准允许您在信号处理程序中执行的唯一操作。 从 C++11 开始,您可以为此目的使用std::atomic ,但前提是原子是无锁的。
  • 根据 Intel处理setjmp
  • 当直接处理硬件并且您希望确保编译器不会优化您的读取或写入时。

例如:

volatile int *foo = some_memory_mapped_device;
while (*foo)
    ; // wait until *foo turns false

如果没有volatile说明符,则允许编译器完全优化循环。 volatile说明符告诉编译器它可能不会假设 2 个后续读取返回相同的值。

请注意, volatile与线程无关。 如果有一个不同的线程写入*foo ,上面的例子就不起作用,因为不涉及获取操作。

在所有其他情况下, volatile使用应该被认为是不可移植的,并且不再通过代码审查,除非处理 C++11 之前的编译器和编译器扩展(例如 msvc 的/volatile:ms开关,在X86/I64)。

C++ volatile 关键字是否引入了内存栅栏?

符合规范的 C++ 编译器不需要引入内存栅栏。 您的特定编译器可能会; 将您的问题直接交给编译器的作者。

C++中“volatile”的功能与线程无关。 请记住,“易失性”的目的是禁用编译器优化,以便读取由于外生条件而发生变化的寄存器不会被优化掉。 由不同 CPU 上的不同线程写入的内存地址是否是由于外生条件而发生变化的寄存器? 不。同样,如果某些编译器作者选择将不同 CPU 上的不同线程写入的内存地址视为由于外生条件而更改的寄存器,那是他们的事; 他们不需要这样做。 也不需要它们——即使它确实引入了内存栅栏——例如,确保每个线程看到易失性读取和写入的一致顺序。

事实上, volatile 对于 C/C++ 中的线程几乎没有用处。 最好的做法是避免它。

此外:内存栅栏是特定处理器架构的实现细节。 在C#中,其中挥发性明确专为多线程,该规范并没有说半挡片将被引入,因为该计划可能会在不具有在首位围栏的架构上运行。 相反,该规范再次确定(极弱)保证编译器、运行时和 CPU 将避免哪些优化,以对某些副作用的排序方式施加某些(极弱)约束。 在实践中,通过使用半围栏消除了这些优化,但这是一个实现细节,将来可能会发生变化。

您关心任何语言中 volatile 的语义,因为它们与多线程有关,这一事实表明您正在考虑跨线程共享内存。 考虑不这样做。 它使您的程序更难理解,并且更有可能包含微妙的、无法重现的错误。

David 忽略了一个事实,即 C++ 标准指定了仅在特定情况下交互的多个线程的行为,而其他一切都会导致未定义的行为。 如果不使用原子变量,则涉及至少一次写入的竞争条件是未定义的。

因此,编译器完全有权放弃任何同步指令,因为您的 CPU 只会注意到由于缺少同步而表现出未定义行为的程序中的差异。

首先,C++ 标准不保证正确排序非原子读/写所需的内存屏障。 建议将volatile变量与 MMIO、信号处理等一起使用。在大多数实现中, volatile对多线程没有用,并且通常不推荐使用。

关于 volatile 访问的实现,这是编译器的选择。

这篇描述gcc行为的文章表明,您不能使用易失性对象作为内存屏障来对一系列写入易失性内存进行排序。

关于icc行为,我发现这个来源还告诉我 volatile 不保证对内存访问进行排序。

Microsoft VS2013编译器有不同的行为。 文档解释了 volatile 如何强制执行 Release / Acquire 语义,并使 volatile 对象能够在多线程应用程序的锁定/释放中使用。

需要考虑的另一个方面是相同的编译器可能具有不同的行为。 易失性取决于目标硬件架构 这篇关于 MSVS 2013 编译器的 帖子清楚地说明了在 ARM 平台上使用 volatile 进行编译的细节。

所以我的回答是:

C++ volatile 关键字是否引入了内存栅栏?

将是:不保证,可能不会,但有些编译器可能会这样做。 你不应该依赖它确实如此。

据我所知,编译器只在安腾架构上插入内存栅栏。

volatile关键字最适合用于异步更改,例如,信号处理程序和内存映射寄存器; 它通常是用于多线程编程的错误工具。

这取决于“编译器”是哪个编译器。 自 2005 年以来,Visual C++ 确实如此。但标准不需要它,因此其他一些编译器不需要。

它没有必要。 Volatile 不是同步原语。 它只是禁用优化,即您在一个线程内按照抽象机器规定的相同顺序获得可预测的读取和写入序列。 但是在不同线程中的读取和写入首先是没有顺序的,谈论保留或不保留它们的顺序是没有意义的。 theads 之间的顺序可以通过同步原语建立,没有它们你会得到 UB。

关于内存屏障的一些解释。 典型的 CPU 具有多个级别的内存访问。 有一个内存管道,几个级别的缓存,然后是 RAM 等。

Membar 指令冲洗管道。 它们不会改变读取和写入的执行顺序,它只是强制在给定时刻执行未完成的。 它对多线程程序很有用,但除此之外就没什么用了。

缓存通常在 CPU 之间自动保持一致。 如果要确保缓存与 RAM 同步,则需要缓存刷新。 它与 membar 非常不同。

这主要来自内存,并且基于 C++11 之前的版本,没有线程。 但是在参与了提交中关于线程的讨论后,我可以说委员会从来没有打算将volatile用于线程之间的同步。 微软提出了它,但该提议没有通过。

volatile的关键规范是对volatile的访问代表一种“可观察的行为”,就像 IO 一样。 以同样的方式,编译器不能重新排序或删除特定的 IO,它不能重新排序或删除对 volatile 对象的访问(或更准确地说,通过具有 volatile 限定类型的左值表达式访问)。 事实上,volatile 的初衷是支持内存映射 IO。 然而,与此有关的“问题”在于实现定义了什么构成了“易失性访问”。 许多编译器将其实现为好像定义是“已执行读取或写入内存的指令”。 这是一个合法的,尽管无用的定义,如果实现指定它。 (我还没有找到任何编译器的实际规范。)

可以说(这是我接受的一个论点),这违反了标准的意图,因为除非硬件将地址识别为内存映射 IO,并禁止任何重新排序等,否则您甚至不能将 volatile 用于内存映射 IO,至少在 Sparc 或 Intel 架构上。 尽管如此,我看过的编译器(Sun CC、g++ 和 MSC)都没有输出任何栅栏或 membar 指令。 (关于微软提议扩展volatile规则的时候,我认为他们的一些编译器实现了他们的提议,并且确实为 volatile 访问发出了栅栏指令。我没有验证最近的编译器做了什么,但如果它我也不会感到惊讶取决于一些编译器选项。然而,我检查的版本 - 我认为是 VS6.0 - 没有发出围栏。)

编译器需要引入周围的存储栅栏volatile的访问,当且仅当,这是必要的,以便为使用volatile标准工作规定( setjmp那个特定的平台上,信号处理,等等)。

请注意,某些编译器确实超出了 C++ 标准的要求,以便在这些平台上使volatile更加强大或有用。 可移植代码不应该依赖volatile来做任何超出 C++ 标准规定的事情。

我总是在中断服务例程中使用 volatile,例如 ISR(通常是汇编代码)修改一些内存位置,而在中断上下文之外运行的更高级别代码通过指向 volatile 的指针访问内存位置。

我为 RAM 以及内存映射 IO 执行此操作。

根据这里的讨论,这似乎仍然是 volatile 的有效用法,但与多线程或 CPU 没有任何关系。 如果微控制器的编译器“知道”不能有任何其他访问(例如,所有内容都在片上,没有缓存并且只有一个内核),我会认为根本不暗示内存栅栏,编译器只需要防止某些优化。

随着我们在执行目标代码的“系统”中加入更多的东西,几乎所有的赌注都被取消了,至少我是这么读这个讨论的。 编译器怎么可能涵盖所有基础?

我认为关于 volatile 和指令重新排序的混淆源于 CPU 重新排序的 2 个概念:

  1. 乱序执行。
  2. 其他 CPU 看到的内存读/写顺序(重新排序,每个 CPU 可能会看到不同的顺序)。

Volatile 会影响编译器如何生成假定单线程执行的代码(这包括中断)。 它并不暗示任何关于内存屏障指令的内容,而是阻止编译器执行与内存访问相关的某些类型的优化。
一个典型的例子是从内存中重新获取一个值,而不是使用缓存在寄存器中的值。

乱序执行

如果最终结果可能发生在原始代码中,CPU 可以乱序/推测性地执行指令。 CPU 可以执行编译器不允许的转换,因为编译器只能执行在所有情况下都正确的转换。 相比之下,CPU 可以检查这些优化的有效性,如果结果不正确,则退出它们。

其他 CPU 所见的内存读/写顺序

指令序列的最终结果,即有效顺序,必须与编译器生成的代码的语义一致。 然而,CPU 选择的实际执行顺序可能不同。 在其他 CPU 中看到的有效顺序(每个 CPU 可以有不同的视图)可能会受到内存屏障的限制。
我不确定有效顺序和实际顺序有多少不同,因为我不知道内存屏障在多大程度上可以阻止 CPU 执行乱序执行。

资料来源:

当我在学习使用现代 OpenGL 进行 3D 图形和游戏引擎开发的在线可下载视频教程时。 我们确实在我们的一个类中使用了volatile 教程网站可以在这里找到,使用volatile关键字的视频可以在Shader Engine系列视频 98 中找到。这些作品不是我自己的,而是由Marek A. Krzeminski, MASc ,这是视频的摘录下载页面。

“由于我们现在可以让我们的游戏在多个线程中运行,因此在线程之间正确同步数据很重要。在本视频中,我展示了如何创建一个 volitile 锁定类以确保 volitile 变量正确同步......”

如果您订阅了他的网站并可以在此视频中访问他的视频,他会参考这篇关于在multithreading编程中使用Volatile 文章

这是上面链接中的文章: http : //www.drdobbs.com/cpp/volatile-the-multithreaded-programmers-b/184403766

volatile:多线程程序员最好的朋友

作者:Andrei Alexandrescu,2001 年 2 月 1 日

设计 volatile 关键字是为了防止编译器优化可能在出现某些异步事件时使代码不正确。

我不想破坏您的心情,但本专栏讨论了多线程编程这个可怕的话题。 如果——正如 Generic 的前一期所说——异常安全编程很难,与多线程编程相比,它是小菜一碟。

众所周知,使用多线程的程序通常难以编写、证明正确、调试、维护和驯服。 不正确的多线程程序可能会运行多年而不会出现故障,但由于满足了某些关键的时序条件,才会意外地运行异常。

不用说,编写多线程代码的程序员需要她所能获得的所有帮助。 本专栏重点关注竞争条件——多线程程序中常见的问题根源——并为您提供有关如何避免它们的见解和工具,而且令人惊讶的是,编译器会努力帮助您解决这个问题。

只是一个小关键词

尽管 C 和 C++ 标准在涉及线程时都明显保持沉默,但它们确实以 volatile 关键字的形式对多线程做出了一些让步。

就像它最著名的对应物 const 一样, volatile 是一个类型修饰符。 它旨在与在不同线程中访问和修改的变量结合使用。 基本上,没有 volatile,要么编写多线程程序变得不可能,要么编译器浪费大量优化机会。 一个解释是有条理的。

考虑以下代码:

 class Gadget { public: void Wait() { while (!flag_) { Sleep(1000); // sleeps for 1000 milliseconds } } void Wakeup() { flag_ = true; } ... private: bool flag_; };

上面 Gadget::Wait 的目的是每秒检查 flag_ 成员变量,并在该变量被另一个线程设置为 true 时返回。 至少这是它的程序员的意图,但是,唉,Wait 是不正确的。

假设编译器发现 Sleep(1000) 是对无法修改成员变量 flag_ 的外部库的调用。 然后编译器得出结论,它可以将 flag_ 缓存在寄存器中并使用该寄存器而不是访问速度较慢的板载内存。 这是对单线程代码的极好优化,但在这种情况下,它损害了正确性:在您为某个 Gadget 对象调用 Wait 后,尽管另一个线程调用了 Wakeup,但 Wait 将永远循环。 这是因为flag_的变化不会反映在缓存flag_的寄存器中。 优化太……乐观了。

在寄存器中缓存变量是一种非常有价值的优化,大部分时间都适用,因此浪费它会很可惜。 C 和 C++ 为您提供了显式禁用此类缓存的机会。 如果在变量上使用 volatile 修饰符,编译器将不会将该变量缓存在寄存器中——每次访问都会命中该变量的实际内存位置。 因此,要使 Gadget 的 Wait/Wakeup 组合起作用,您所要做的就是适当地限定 flag_:

 class Gadget { public: ... as above ... private: volatile bool flag_; };

对 volatile 的基本原理和用法的大多数解释到此为止,并建议您对在多线程中使用的原始类型进行 volatile 限定。 但是,您可以使用 volatile 做更多事情,因为它是 C++ 美妙的类型系统的一部分。

对用户定义的类型使用 volatile

您不仅可以对原始类型进行 volatile 限定,还可以对用户定义的类型进行 volatile 限定。 在这种情况下, volatile 以类似于 const 的方式修改类型。 (您也可以同时将 const 和 volatile 应用于同一类型。)

与 const 不同,volatile 区分原始类型和用户定义的类型。 也就是说,与类不同,原始类型在 volatile 限定时仍然支持它们的所有操作(加法、乘法、赋值等)。 例如,您可以将非 volatile int 分配给 volatile int,但不能将非 volatile 对象分配给 volatile 对象。

让我们通过一个例子来说明 volatile 如何处理用户定义的类型。

 class Gadget { public: void Foo() volatile; void Bar(); ... private: String name_; int state_; }; ... Gadget regularGadget; volatile Gadget volatileGadget;

如果您认为 volatile 对对象没有那么有用,请准备好迎接惊喜。

 volatileGadget.Foo(); // ok, volatile fun called for // volatile object regularGadget.Foo(); // ok, volatile fun called for // non-volatile object volatileGadget.Bar(); // error! Non-volatile function called for // volatile object!

从非限定类型到其 volatile 对应物的转换是微不足道的。 但是,就像使用 const 一样,您无法从 volatile 返回到非限定。 您必须使用演员表:

 Gadget& ref = const_cast<Gadget&>(volatileGadget); ref.Bar(); // ok

一个 volatile 限定的类只允许访问它的接口的一个子集,这个子集在类实现者的控制之下。 用户只能通过使用 const_cast 获得对该类型接口的完全访问权限。 此外,就像常量一样,易失性从类传播到其成员(例如, volatileGadget.name_ 和 volatileGadget.state_ 是易失性变量)。

volatile、临界区和竞争条件

多线程程序中最简单也是最常用的同步设备是互斥锁。 互斥锁公开 Acquire 和 Release 原语。 一旦您在某个线程中调用 Acquire,任何其他调用 Acquire 的线程都会阻塞。 稍后,当该线程调用 Release 时,恰好在 Acquire 调用中阻塞的一个线程将被释放。 换句话说,对于给定的互斥锁,只有一个线程可以在调用 Acquire 和调用 Release 之间获得处理器时间。 调用 Acquire 和调用 Release 之间的执行代码称为临界区。 (Windows 术语有点令人困惑,因为它将互斥锁本身称为临界区,而“互斥锁”实际上是一个进程间互斥锁。如果将它们称为线程互斥锁和进程互斥锁就好了。)

互斥体用于保护数据免受竞争条件的影响。 根据定义,当更多线程对数据的影响取决于线程的调度方式时,就会发生竞争条件。 当两个或多个线程竞争使用相同的数据时,就会出现竞争条件。 由于线程可以在任意时刻相互中断,因此数据可能会被破坏或误解。 因此,必须使用临界区仔细保护更改和有时对数据的访问。 在面向对象的编程中,这通常意味着您将互斥锁作为成员变量存储在类中,并在访问该类的状态时使用它。

有经验的多线程程序员在阅读上面两段时可能会打哈欠,但他们的目的是提供智力锻炼,因为现在我们将链接到 volatile 连接。 我们通过在 C++ 类型的世界和线程语义世界之间绘制一个平行线来做到这一点。

  • 在临界区之外,任何线程都可能随时中断其他线程; 没有控制,因此可从多个线程访问的变量是可变的。 这符合 volatile 的初衷——防止编译器无意中缓存多个线程同时使用的值。
  • 在互斥锁定义的临界区中,只有一个线程可以访问。 因此,在临界区中,执行代码具有单线程语义。 受控变量不再是 volatile - 您可以删除 volatile 限定符。

简而言之,线程之间共享的数据在概念上在临界区外是易失性的,而在临界区内是非易失性的。

您可以通过锁定互斥锁来进入临界区。 您可以通过应用 const_cast 从类型中删除 volatile 限定符。 如果我们设法将这两个操作放在一起,我们就在 C++ 的类型系统和应用程序的线程语义之间建立了联系。 我们可以让编译器为我们检查竞争条件。

锁定指针

我们需要一个收集互斥量获取和 const_cast 的工具。 让我们开发一个 LockingPtr 类模板,您可以使用易失性对象 obj 和互斥对象 mtx 对其进行初始化。 在其生命周期内,一个 LockingPtr 保持获取 mtx。 此外,LockingPtr 提供对 volatile-stripped obj 的访问。 通过operator-> 和operator* 以智能指针方式提供访问。 const_cast 在 LockingPtr 内执行。 强制转换在语义上是有效的,因为 LockingPtr 会在其生命周期内保留获取的互斥锁。

首先,让我们定义一个类 Mutex 的骨架,LockingPtr 将与它一起工作:

 class Mutex { public: void Acquire(); void Release(); ... };

要使用 LockingPtr,您需要使用操作系统的本机数据结构和原始函数来实现互斥锁。

LockingPtr 使用受控变量的类型进行模板化。 例如,如果你想控制一个 Widget,你可以使用一个 LockingPtr,你用一个 volatile Widget 类型的变量初始化它。

LockingPtr 的定义非常简单。 LockingPtr 实现了一个简单的智能指针。 它只专注于收集 const_cast 和临界区。

 template <typename T> class LockingPtr { public: // Constructors/destructors LockingPtr(volatile T& obj, Mutex& mtx) : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) { mtx.Lock(); } ~LockingPtr() { pMtx_->Unlock(); } // Pointer behavior T& operator*() { return *pObj_; } T* operator->() { return pObj_; } private: T* pObj_; Mutex* pMtx_; LockingPtr(const LockingPtr&); LockingPtr& operator=(const LockingPtr&); };

尽管它很简单,但 LockingPtr 是编写正确的多线程代码的非常有用的帮助。 您应该将线程之间共享的对象定义为 volatile 并且永远不要对它们使用 const_cast — 始终使用 LockingPtr 自动对象。 让我们用一个例子来说明这一点。

假设您有两个共享一个向量对象的线程:

 class SyncBuf { public: void Thread1(); void Thread2(); private: typedef vector<char> BufT; volatile BufT buffer_; Mutex mtx_; // controls access to buffer_ };

在线程函数中,您只需使用 LockingPtr 来控制对 buffer_ 成员变量的访问:

 void SyncBuf::Thread1() { LockingPtr<BufT> lpBuf(buffer_, mtx_); BufT::iterator i = lpBuf->begin(); for (; i != lpBuf->end(); ++i) { ... use *i ... } }

代码很容易编写和理解——每当你需要使用buffer_时,你必须创建一个指向它的LockingPtr。 一旦你这样做了,你就可以访问 vector 的整个界面。

好的部分是,如果您犯了错误,编译器会指出:

 void SyncBuf::Thread2() { // Error! Cannot access 'begin' for a volatile object BufT::iterator i = buffer_.begin(); // Error! Cannot access 'end' for a volatile object for ( ; i != lpBuf->end(); ++i ) { ... use *i ... } }

在应用 const_cast 或使用 LockingPtr 之前,您无法访问 buffer_ 的任何函数。 不同之处在于 LockingPtr 提供了一种将 const_cast 应用于 volatile 变量的有序方式。

LockingPtr 非常具有表现力。 如果只需要调用一个函数,可以创建一个未命名的临时LockingPtr对象,直接使用:

 unsigned int SyncBuf::Size() { return LockingPtr<BufT>(buffer_, mtx_)->size(); }

回到原始类型

我们看到了 volatile 如何很好地保护对象免受不受控制的访问,以及 LockingPtr 如何提供一种简单有效的方法来编写线程安全代码。 现在让我们回到原始类型,它们被 volatile 区别对待。

让我们考虑一个示例,其中多个线程共享一个 int 类型的变量。

 class Counter { public: ... void Increment() { ++ctr_; } void Decrement() { —ctr_; } private: int ctr_; };

如果要从不同的线程调用 Increment 和 Decrement,则上面的片段有问题。 首先, ctr_ 必须是 volatile。 其次,即使是++ctr_这种看似原子的操作,其实也是一个三阶段操作。 内存本身没有算术能力。 当增加一个变量时,处理器:

  • 在寄存器中读取该变量
  • 增加寄存器中的值
  • 将结果写回内存

这三步操作称为RMW(读-修改-写)。 在 RMW 操作的修改部分期间,大多数处理器释放内存总线,以便其他处理器访问内存。

如果此时另一个处理器对同一个变量执行 RMW 操作,我们就会遇到竞争条件:第二次写入会覆盖第一次写入的效果。

为避免这种情况,您可以再次依赖 LockingPtr:

 class Counter { public: ... void Increment() { ++*LockingPtr<int>(ctr_, mtx_); } void Decrement() { —*LockingPtr<int>(ctr_, mtx_); } private: volatile int ctr_; Mutex mtx_; };

现在代码是正确的,但与 SyncBuf 的代码相比,它的质量较差。 为什么? 因为使用 Counter,如果您错误地直接访问 ctr_(未锁定它),编译器将不会警告您。 如果 ctr_ 是 volatile,编译器会编译 ++ctr_,尽管生成的代码完全不正确。 编译器不再是你的盟友,只有你的注意力才能帮助你避免竞争条件。

那你应该怎么做? 简单地封装您在更高级别结构中使用的原始数据,并在这些结构中使用 volatile。 矛盾的是,直接将 volatile 与内置函数一起使用会更糟,尽管最初这是 volatile 的使用意图!

可变成员函数

到目前为止,我们已经有了聚合易变数据成员的类; 现在让我们考虑设计类,这些类反过来将成为更大对象的一部分并在线程之间共享。 这就是 volatile 成员函数可以提供很大帮助的地方。

在设计类时,您只对线程安全的成员函数进行 volatile 限定。 您必须假设来自外部的代码将随时从任何代码调用 volatile 函数。 不要忘记:volatile 等于免费的多线程代码并且没有临界区; 非易失性等于单线程场景或在关键部分内。

例如,您定义了一个 Widget 类,它在两个变体中实现了一个操作——一个线程安全的一个和一个快速的、不受保护的一个。

 class Widget { public: void Operation() volatile; void Operation(); ... private: Mutex mtx_; };

注意重载的使用。 现在,Widget 的用户可以使用统一的语法为 volatile 对象调用 Operation 并获得线程安全性,或者为常规对象调用操作并获得速度。 用户在将共享 Widget 对象定义为 volatile 时必须小心。

在实现 volatile 成员函数时,第一个操作通常是使用 LockingPtr 锁定它。 然后使用非易失性兄弟完成工作:

 void Widget::Operation() volatile { LockingPtr<Widget> lpThis(*this, mtx_); lpThis->Operation(); // invokes the non-volatile function }

概括

在编写多线程程序时,您可以使用 volatile。 您必须遵守以下规则:

  • 将所有共享对象定义为 volatile。
  • 不要将 volatile 直接用于原始类型。
  • 在定义共享类时,使用 volatile 成员函数来表达线程安全。

如果你这样做,并且如果你使用简单的通用组件 LockingPtr,你可以编写线程安全的代码并且不用担心竞争条件,因为编译器会为你担心并且会努力指出你错的地方。

我参与的几个项目使用 volatile 和 LockingPtr 取得了很好的效果。 代码简洁易懂。 我记得有几个死锁,但我更喜欢死锁而不是竞争条件,因为它们更容易调试。 几乎没有与竞态条件相关的问题。 但是你永远不会知道。

致谢

非常感谢 James Kanze 和 Sorin Janu,他们提出了富有洞察力的想法。


Andrei Alexandrescu 是位于华盛顿州西雅图的 RealNetworks Inc. (www.realnetworks.com) 的开发经理,并且着有广受好评的《现代 C++ 设计》一书。 可以通过 www.moderncppdesign.com 与他联系。 Andrei 还是 The C++ Seminar (www.gotw.ca/cpp_seminar) 的特邀讲师之一。

这篇文章可能有点过时,但它确实提供了很好的见解,可以很好地了解如何在多线程编程中使用 volatile 修饰符来帮助保持事件异步,同时让编译器为我们检查竞争条件。 这可能不会直接回答关于创建内存栅栏的 OP 原始问题,但我选择将其发布为其他人的答案,作为在使用多线程应用程序时良好使用 volatile 的极好参考。

关键字volatile本质上意味着读取和写入对象应该完全按照程序写入的方式执行,而不是以任何方式进行优化 二进制代码应该遵循 C 或 C++ 代码:读取的负载,写入的存储。

这也意味着不应预期读取会产生可预测的值:即使在写入相同的易失性对象之后,编译器也不应立即假设任何有关读取的内容:

volatile int i;
i = 1;
int j = i; 
if (j == 1) // not assumed to be true

volatile可能是“C 是高级汇编语言”工具箱中最重要的工具

声明一个对象 volatile 是否足以确保处理异步更改的代码的行为取决于平台:不同的 CPU 为正常的内存读取和写入提供不同级别的保证同步。 除非您是该领域的专家,否则您可能不应该尝试编写如此低级的多线程代码。

原子原语为多线程提供了一个很好的更高级别的对象视图,这使得代码推理变得容易。 几乎所有程序员都应该使用原子原语或提供互斥的原语,如互斥锁、读写锁、信号量或其他阻塞原语。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM