在这段多线程的C++代码中,是否需要使用“volatile”关键字?

11

我用C++编写了一个Windows程序,有时会使用两个线程:一个后台线程执行耗时的工作,另一个线程管理图形界面。这样程序对用户仍然响应,可以中止某个操作。线程之间通过共享的bool变量通信,在GUI线程发出终止信号时设置为true。下面是实现此行为的代码(已剥离无关部分):

由GUI线程执行的代码


class ProgressBarDialog : protected Dialog {

    /**
     * This points to the variable which the worker thread reads to check if it
     * should abort or not.
     */
    bool volatile* threadParameterAbort_;

    ...

    BOOL CALLBACK ProgressBarDialog::DialogProc( HWND dialog, UINT message, 
        WPARAM wParam, LPARAM lParam ) {

        switch( message ) {
            case WM_COMMAND :
                switch ( LOWORD( wParam ) ) {

                    ...

                    case IDCANCEL :
                    case IDC_BUTTON_CANCEL :
                        switch ( progressMode_ ) {
                            if ( confirmAbort() ) {
                                // This causes the worker thread to be aborted
                                *threadParameterAbort_ = true;
                            }
                            break;
                        }

                        return TRUE;
                }
        }

        return FALSE;
    }

    ...

};

工作线程执行的代码


class CsvFileHandler {

    /**
     * This points to the variable which is set by the GUI thread when this
     * thread should abort its execution.
     */
    bool volatile* threadParamAbort_;

    ...

    ParseResult parseFile( ItemList* list ) {
        ParseResult result;

        ...

        while ( readLine( &line ) ) {
            if ( ( threadParamAbort_ != NULL ) && *threadParamAbort_ ) {
                break;
            }

            ...
        }

        return result;
    }

    ...

};

threadParameterAbort_在两个线程中都指向一个结构体中声明的bool变量。该结构体在创建工作线程时被传递。它的声明如下:

bool volatile abortExecution_;
我的问题是:我需要在这里使用volatile吗?并且上面的代码是否足以确保程序是线程安全的?我认为可以用下面的理由来证明在这里使用volatile是合理的(有关背景,请参见此问题):
  • 防止读取*threadParameterAbort_使用缓存,而是从内存中获取值。

  • 防止编译器由于优化而删除工作线程中的if语句。

就我所知,它应该是线程安全的,因为在大多数体系结构中设置一个bool变量应该是原子操作。但我可能是错的。我还担心编译器可能重新排序指令,从而破坏线程安全性。但最好还是保险起见。

编辑:我的措辞有点小问题,让问题似乎是在问volatile是否足以确保线程安全。这不是我的意图--volatile确实没有以任何方式确保线程安全--但我想问的是上述提供的代码是否表现出正确的行为以确保程序是线程安全的。


1
复制更好的一半这个。最近的是IIRC问题 - Dummy00001
昨天同一人提出的问题涵盖了相同的内容:https://dev59.com/SXA65IYBdhLWcg3w1SbU。如果你不是在编写设备驱动程序并且对编译器非常熟悉,那么你就不需要使用`volatile`。 - Potatoswatter
@Potatoswatter:但是似乎volatile在编写多线程代码时很有用,正如在其中一个答案中链接的这篇文章(http://www.drdobbs.com/cpp/184403766)所示。 - gablin
1
那篇文章已经过时了,不要再关注它了。volatile 可以帮助解决不足的线程库中的错误,但这已经不是问题了。(请记住,C++ 并没有正式设计用于多线程,因此如果像 volatile 这样的语言特性有助于多线程,那只是巧合。)考虑到其他问题中讨论的问题,从头开始实现任何同步都是不够的。就像Jerry所说的,既不必要也不充分。永远不要使用它。熟悉您的线程库并使用干净、受支持的同步。 - Potatoswatter
@Potatoswatter:啊,非常感谢您指出这一点。天哪,我真希望早些时候就能找到Stackoverflow——它充满了有用的信息和人才。^^ 再次感谢。 - gablin
8个回答

14

不应该依赖于volatile来保证线程安全,因为即使编译器保证变量总是从内存中读取(而非寄存器缓存),在多处理器环境下,仍需要使用内存屏障。

相反,在共享内存周围使用正确的锁。像临界区这样的锁通常非常轻量级,在没有争用的情况下可能全部由用户实现。它们还将包含必要的内存屏障。

volatile只应用于内存映射IO,其中多个读取可能返回不同的值。同样适用于内存映射写入。


但是如果我删除 volatile,编译器会不会错误地优化掉 if 子句?或者 *threadParamAbort_ 的值总是使用缓存?通过互斥锁或信号量应用临界区是否确保不会发生这种情况? - gablin
3
互斥锁或信号量是非纯函数。它可能具有副作用(更改全局变量),因此编译器不能假设*threadParamAbort_的值将保持不变。在互斥锁或关键部分锁定或信号后,必须重新读取它。由于每次只有1个线程可以持有临界区,所以在锁内部重新排序指令并没有问题。只需确保正确应用锁即可。 - doron
好的,那么通过将 if 语句包装在关键段中,可以防止编译器对其进行优化,是吗?即使 threadParameterAbort_ 实际上不是全局变量?如果是这样,那么关键段提供了 volatile 的功能,因此不需要使用它。同意,在关键段内重新排序并没有错。但是,我需要在 GUI 线程中使用关键段来包装 threadParameterAbort_ 的设置吗?似乎多余,因为该操作本身是原子性的,是吗? - gablin
我理解 threadParameterAbort_ 是一个指向共享变量的指针,因此该值实际上是全局的。即使某些东西看起来是全局的,也要使用临界区。它们非常便宜,并且可以确保您不会受到处理器读/写重排序的影响。 - doron
@deus-ex-machina399:即使从布尔值读取和写入是原子操作? - gablin
是的,因为在多处理器/多核系统上,读写操作可能会无序进行。 - doron

9

维基百科上讲得很好。

在C语言中,以及因此也包括C++中,volatile关键字的一个目的是允许对内存映射设备进行访问;允许在setjmp函数调用之间使用变量;以及在信号处理程序中使用sig_atomic_t变量。

对volatile变量的操作既不是原子操作,也不能为线程建立适当的happens-before关系。这符合相关标准(C、C++、POSIX、WIN32),并且这是当前绝大多数实现的事实。 作为可移植的线程构造,volatile关键字基本上没有任何价值。


4
我不明白为什么人们一直认为volatile可以提供任何线程安全性。+1 - Billy ONeal
4
维基百科是一个可怕的引用来源(即使维基百科的创始人也说不要使用维基百科作为引文来源)。将其用作研究的起点,但至少引用一个权威的来源。 - Martin York
6
为什么呢?另一种选择就是干脆不引用任何东西。或者我可以引用C或C++的文档,但那会更难懂。而且,我并不是在提交论文。如果维基百科上有简明扼要的答案,我会链接到维基百科。我不理解人们对维基百科的厌恶,也不理解这些任意的发帖规则,因为大多数答案都没有引用来源。 - Serapth
3
你误解了我的意思。volatile 和线程安全完全没有任何关系。 - Billy ONeal
@Billy ONeal:我并不争辩那个说法;事实上,我非常同意。我认为我在提问时措辞有误,让人们认为我认为volatile与线程安全有关。现在已经更正了这个错误。 - gablin
显示剩余2条评论

3
关于昨天问题的回答,不需要使用volatile。事实上,这里的多线程是无关紧要的。
    while ( readLine( &line ) ) { // threadParamAbort_ is not local:
        if ( ( threadParamAbort_ != NULL ) && *threadParamAbort_ ) {
  1. 防止读取*threadParameterAbort_并使用缓存,而是从内存中获取该值,并
  2. 防止编译器由于优化而删除worker线程中的if子句。

函数readLine是外部库代码,或者它调用外部库代码。因此,编译器不能假设它没有修改任何非本地变量。一旦形成了指向对象(或其超级对象)的指针,它可能被传递和存储在任何地方。编译器无法跟踪哪些指针最终会进入全局变量,哪些不会。

因此,编译器假设readLine有自己的私有static bool *threadParamAbort_,并修改该值。因此需要重新从内存加载。


3
在C++中,volatile既不是多线程编程的必要条件,也不足以保证多线程编程。它会禁用一些本来可以使用的优化,并且不能强制执行像原子性这样的必要条件。
编辑:相比于使用关键部分,我可能会使用InterlockedIncrement,这样可以带来更少的开销并且具有原子写入的特性。
通常情况下,我会将一个线程安全的队列(或双端队列)作为线程的输入。当你需要线程执行某个任务时,你只需将描述该任务的数据包放入队列中,线程在能够处理时便会执行该任务。当你想要正常关闭线程时,只需向队列中放入一个“关闭”数据包即可。如果你需要立即中止线程,则使用双端队列,将“中止”命令放在队首。理论上,这种设计的缺点是直到线程完成当前任务之前才能中止线程。这意味着你需要将每个任务的大小/延迟范围与你当前检查标志的频率大致相同。
这种设计避免了许多进程间通信问题。

好的,你需要扩展一下。我同意它不够充分。我同意依赖于易失性的可移植性是没有用的。但是在Windows使用Cl1时,它不会提供一些好处吗?因为内存没有被缓存,因此我们不需要担心跨多个线程的缓存一致性问题。 - Martin York
@Jerry Coffin:我再次强调,我非常清楚volatile并不能保证线程安全或原子性。此外,写入或读取bool值不是原子操作吗? - gablin
1
@Martin:volatile只是防止编译器将数据保存在寄存器中,对于防止/解决缓存一致性问题没有任何作用。@gablin:读取或写入bool可能是原子的——但也可能不是。在MS VC++中,默认情况下它可能通常是原子的,但绝对有可能创建一个不会被原子地读取/写入的bool - Jerry Coffin
@Martin:到目前为止,任何运行Windows的设备在硬件上都处理缓存一致性。然而,这并不保证顺序性或者原子性。这就是为什么它们有原子更新指令、内存屏障指令等等的原因。 - Jerry Coffin
1
如果只是为了那个目的,我会同意@gablin的说法。但另一方面,我倾向于从一开始就设计它。大多数程序随着时间的推移而增长,通常将其放在那里可以使这种增长更加可管理。 - Jerry Coffin
显示剩余5条评论

1

看起来这里描述了相同的用例:volatile - Multithreaded Programmer's Best Friend by Alexandrescu。它指出在这种情况下(创建标志),volatile 可以完美地使用。

所以,在这种情况下,代码应该是正确的。volative 将防止从缓存中读取并防止编译器优化 if 语句。


1

好的,所以你已经听够了有关volatile和线程安全的问题!但是...

针对你特定的代码示例(尽管有点超纲而且在你的控制范围内),事实上你在一个“事务”内多次查看了变量:

if ( ( threadParamAbort_ != NULL ) && *threadParamAbort_ )

如果由于某种原因,在左侧和右侧之间删除了threadParamAbort_,那么你将会引用一个已删除的指针。再次强调,这种情况不太可能发生,因为你有控制权,但是它是volatile和原子性无法为你解决的问题的一个例子。


是的,这是一个正确的观察,但我知道在执行这行代码时 threadParamAbort_ 永远不会被删除,因为我的代码结构是这样的。当然,除非有 bug,但那是完全不同的问题。 - gablin

0

它确实看起来非常类似于我在程序中应用的内容。但是我是否仍然需要使用关键部分?它在我的程序中是否有必要? - gablin
不需要回复那个(看看其他答案的讨论)。 - gablin

0

我认为它会很好地工作(无论是否原子),因为你只是用它来取消后台操作。

volatile 主要作用是防止变量被缓存或从寄存器中检索。你可以确定它来自主存储器。

所以如果我是你,我会将变量定义为 volatile。

我认为最好假设 volatile 是必需的,以确保其他线程在写入并读取该变量时实际上获得该变量的正确值,并尽快确保 IF 不被优化掉(虽然我怀疑不会这样)。


如果从threadParamAbort_读取和写入确实不是原子操作,那么我在单CPU系统上也需要担心它。正如我向每个人指出的那样,我知道volatile不能为任何操作提供原子性;所以我们能否停止向我指出这一点? - gablin
安静听我说。我原本写了这篇文章2-3天前,所以不需要对我说什么。我的回答并不是新的,好吗?原子性是一个与线程安全相关但又不同的概念,而你在问题中提到的只是线程安全。现在,经过更深入的思考,我已经更新了我的回答,以满足你的需求。 - hookenz

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接