C/C++ 中的 volatile

约定

Volatile 这个话题,涉及到计算机科学多个领域多个层次的诸多细节。仅靠一篇博客,很难穷尽这些细节。因此,若不对讨论范围做一些约定,很容易就有诸多漏洞。到时误人子弟,就不好了。以下是一些基本的约定:
1 这篇博文讨论的 volatile 关键字,是 C 和 C++ 语言中的关键字。Java 等语言中,也有 volatile 关键字。但它们和 C/C++ 里的 volatile 不完全相同,不在这篇博文的讨论范围内。
2 这篇博文讨论的 volatile 关键字,是限定在 C/C++ 标准之下的。这也就是说,我们讨论的内容应该是与平台无关的,同时也是与编译器扩展无关的。
3 相应的,这篇文章讨论的「标准」指的是 C/C++ 的标准,而不是其他什么东西。
4 我们希望编写的代码是 (1) 符合标准的,(2) 性能良好的,(3) 可移植的。这里 (1) 保证了代码执行结果的正确性,(2) 保证了高效性,(3) 体现了平台无关性(以及编译器扩展等的无关性)。

1 为什么要用volatile?[1]

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改。比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。

声明时语法:volatile int vInt;

当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。例如:

volatile int i=10;
int a = i;
int b = i; 

volatile 指出 i 是随时可能发生变化的,每次使用它的时候必须从 i的地址中读取,因而编译器生成的汇编代码会重新从i的地址读取数据放在 b 中。而优化做法是,由于编译器发现两次从 i读数据的代码之间的代码没有对 i 进行过操作,它会自动把上次读的数据放在 b 中。而不是重新从 i 里面读。这样以来,如果 i是一个寄存器变量或者表示一个端口数据就容易出错,所以说 volatile 可以保证对特殊地址的稳定访问。

下面通过插入汇编代码,测试有无 volatile 关键字,对程序最终代码的影响:

#include <stdio.h>
void main()
{
    int i = 10;
    int a = i;

    printf("i = %d", a);

    // 下面汇编语句的作用就是改变内存中 i 的值
    // 但是又不让编译器知道
    __asm {
        mov dword ptr [ebp-4], 20h
    }

    int b = i;
    printf("i = %d", b);
}

然后,在 Debug 版本模式运行程序,输出结果如下:

i = 10
i = 32

然后,在 Release 版本模式运行程序,输出结果如下:

i = 10
i = 10

输出的结果明显表明,Release 模式下,编译器对代码进行了优化,第二次没有输出正确的 i 值。下面,我们把 i 的声明加上 volatile 关键字,看看有什么变化:

#include <stdio.h>
void main()
{
    volatile int i = 10;
    int a = i;

    printf("i = %d", a);

    // 下面汇编语句的作用就是改变内存中 i 的值
    // 但是又不让编译器知道
    __asm {
        mov dword ptr [ebp-4], 20h
    }

    int b = i;
    printf("i = %d", b);
}

分别在 Debug 和 Release 版本运行程序,输出都是:

i = 10
i = 32

这说明这个 volatile 关键字发挥了它的作用。其实不只是“内嵌汇编操纵栈”这种方式属于编译无法识别的变量改变,另外更多的可能是多线程并发访问共享变量时,一个线程改变了变量的值,怎样让改变后的值对其它线程 visible。一般说来,volatile用在如下的几个地方:

1) 中断服务程序中修改的供其它程序检测的变量需要加volatile;

2) 多任务环境下各任务间共享的标志应该加volatile;

3) 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;

2 volatile指针

和 const 修饰词类似,const 有常量指针和指针常量的说法,volatile 也有相应的概念:

  • 修饰由指针指向的对象、数据是 const 或 volatile 的:
const char* cpch;
volatile char* vpch;
  • 指针自身的值——一个代表地址的整数变量,是 const 或 volatile 的:
char* const pchc;
char* volatile pchv; 

注意:(1) 可以把一个非volatile int赋给volatile int,但是不能把非volatile对象赋给一个volatile对象。

(2) 除了基本类型外,对用户定义类型也可以用volatile类型进行修饰。

(3) C++中一个有volatile标识符的类只能访问它接口的子集,一个由类的实现者控制的子集。用户只能用const_cast来获得对类型接口的完全访问。此外,volatile向const一样会从类传递到它的成员。

3 多线程下的volatile

有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值,如下:

volatile BOOL bStop = FALSE;

(1)在一个线程中:

while( !bStop ) { ... } 
bStop = FALSE; 
return; 

(2)在另外一个线程中,要终止上面的线程循环:

bStop  =  TRUE;  
while(  bStop  ); 

等待上面的线程终止,如果bStop不使用volatile声明,那么这个循环将是一个死循环,因为bStop已经读取到了寄存器中,寄存器中bStop的值永远不会变成FALSE,加上volatile,程序在执行时,每次均从内存中读出bStop的值,就不会死循环了。

这个关键字是用来设定某个对象的存储位置在内存中,而不是寄存器中。因为一般的对象编译器可能会将其的拷贝放在寄存器中用以加快指令的执行速度,例如下段代码中:

... 
int nMyCounter = 0; 
for(; nMyCounter<100;nMyCounter++) 
{ 
... 
} 
... 

在此段代码中,nMyCounter的拷贝可能存放到某个寄存器中(循环中,对nMyCounter的测试及操作总是对此寄存器中的值进行),但是另外又有段代码执行了这样的操作:nMyCounter -= 1;这个操作中,对nMyCounter的改变是对内存中的nMyCounter进行操作,于是出现了这样一个现象:nMyCounter的改变不同步。


4 volatile与多线程[2]

volatile可以解决多线程中的某些问题,这一错误认识荼毒多年。例如,在知乎「volatile」话题下的介绍就是「多线程开发中保持可见性的关键字」。为了拨乱反正,这里先给出结论(注意这些结论都基于本文第一节提出的约定之上):

  • volatile 不能解决多线程中的问题。
  • 按照 Hans Boehm & Nick Maclaren 的总结volatile 只在三种场合下是合适的。
    • 和信号处理(signal handler)相关的场合;
    • 和内存映射硬件(memory mapped hardware)相关的场合;
    • 和非本地跳转(setjmplongjmp)相关的场合。

以下我们尝试来用volatile关键字解决多线程同步的一个基本问题:happens-before。

native case

首先我们考虑这样一段(伪)代码。

// global shared data
bool flag = false;

thread1() {
    flag = false;
    Type* value = new Type(/* parameters */);
    thread2(value);
    while (true) {
        if (flag == true) {
            apply(value);
            break;
        }
    }
    thread2.join();
    if (nullptr != value) { delete value; }
    return;
}

thread2(Type* value) {
    // do some evaluations
    value->update(/* parameters */);
    flag = true;
    return;
}

这段代码将 thread1 作为主线程,等待 thread2 准备好 value。因此,thread2 在更新 value 之后将 flag 置为真,而 thread1 死循环地检测 flag。简单来说,这段代码的意图希望实现 thread2thread1 使用 value 之前执行完毕这样的语义。

对多线程编程稍有了解的人应该知道,这段代码是有问题的。问题主要出在两个方面。其一,在 thread1 中,flag = false 赋值之后,在 while 死循环里,没有任何机会修改 flag 的值,因此在运行之前,编译器优化可能会将 if (flag == true) 的内容全部优化掉。其二,在 thread2 中,尽管逻辑上 update 需要发生在 flag = true 之前,但编译器和 CPU 并不知道;因此编译器优化和 CPU 乱序执行可能会使 flag = true 发生在 update完成之前,因此 thread1 执行 apply(value) 时可能 value 还未准备好。

加一个 volatile 试试?

在错误的理解中,此时就到了 volatile 登场的时候了。

首先我们考虑这样一段(伪)代码。

// global shared data
volatile bool flag = false;  // 1.

thread1() {
    flag = false;
    Type* value = new Type(/* parameters */);
    thread2(value);
    while (true) {
        if (flag == true) {  // 2.
            apply(value);
            break;
        }
    }
    thread2.join();
    if (nullptr != value) { delete value; }
    return;
}

thread2(Type* value) {
    // do some evaluations
    value->update(/* parameters */);
    flag = true;
    return;
}

这里,在 (1) 处,我们将flag声明为volatile-qualified。因此,在 (2) 处,由于flag == true是对volatile变量的访问,故而if-block 不会被优化消失。然而,尽管flagvolatile-qualified,但value并不是。因此,编译器仍有可能在优化时将thread2中的update和对flag的赋值交换顺序。此外,由于volatile禁止了编译器对flag的优化,这样使用volatile不仅无法达成目的,反而会导致性能下降。

再加一个 volatile 呢?

在错误的理解中,可能会对value也加以volatile关键字修饰;颇有些「没有什么是一个volatile解决不了的;如果不行,那就两个」的意思。

// global shared data
volatile bool flag = false;

thread1() {
    flag = false;
    volatile Type* value = new Type(/* parameters */);   // 1.
    thread2(value);
    while (true) {
        if (flag == true) {
            apply(value);
            break;
        }
    }
    thread2.join();
    if (nullptr != value) { delete value; }
    return;
}

thread2(volatile Type* value) {
    // do some evaluations
    value->update(/* parameters */);                    // 2.
    flag = true;
    return;
}

在上一节代码的基础上,(1) 将 value 声明为 volatile-qualified。因此 (2) 处对两个 volatile-qualified 变量进行访问时,编译器不会交换他们的顺序。看起来就万事大吉了。

然而,volatile 只作用在编译器上,但我们的代码最终是要运行在 CPU 上的。尽管编译器不会将 (2) 处换序,但 CPU 的乱序执行(out-of-order execution)已是几十年的老技术了;在 CPU 执行时,valueflag 的赋值仍有可能是被换序了的(store-store)。

也许有人会说,x86 和 AMD64 架构的 CPU(大多数个人机器和服务器使用这两种架构的 CPU)只允许 sotre-load 乱序,而不会发生 store-store 乱序;或者在诸如 IA64 架构的处理器上,对volatile-qualified 变量的访问采用了专门的指令。因而,在这些条件下,这段代码是安全的。尽管如此,使用volatile会禁止编译器优化相关变量,从而降低性能,所以也不建议依赖volatile在这种情况下做线程同步。另一方面,这严重依赖具体的硬件规范,超出了本文的约定讨论范围。

到底应该怎样做?

回顾一下,我们最初遇到的问题其实需要解决两件事情。一是 flag 相关的代码块不能被轻易优化消失,二是要保证线程同步的 happens-before 语义。但本质上,设计使用 flag 本身也就是为了构建 happens-before 语义。这也就是说,两个问题,后者才是核心;如有其他不用 flag 的办法解决问题,那么 flag 就不重要。

对于当前问题,最简单的办法是使用原子操作

// global shared data
std::atomic<bool> flag = false;  // #include <atomic>

thread1() {
    flag = false;
    Type* value = new Type(/* parameters */);
    thread2(value);
    while (true) {
        if (flag == true) {
            apply(value);
            break;
        }
    }
    thread2.join();
    if (nullptr != value) { delete value; }
    return;
}

thread2(Type* value) {
    // do some evaluations
    value->update(/* parameters */);
    flag = true;
    return;
}

由于对 std::atomic<bool> 的操作是原子的,同时构建了良好的内存屏障,因此整个代码的行为在标准下是良定义的。

除此之外,还可以结合使用互斥量条件变量

// global shared data
std::mutex m;                   // #include <mutex>
std::condition_variable cv;     // #include <condition_variable>
bool flag = false;

thread1() {
    flag = false;
    Type* value = new Type(/* parameters */);
    thread2(value);
    std::unique_lock<std::mutex> lk(m);
    cv.wait(lk, [](){ return flag; });
    apply(value);
    lk.unlock();
    thread2.join();
    if (nullptr != value) { delete value; }
    return;
}

thread2(Type* value) {
    std::lock_guard<std::mutex> lk(m);
    // do some evaluations
    value->update(/* parameters */);
    flag = true;
    cv.notify_one();
    return;
}

这样一来,由线程之间的同步由互斥量和条件变量来保证,同时也避免了while (true)死循环空耗 CPU 的情况。


参考1

参考2

参考

  1. ^1
  2. ^2
编辑于 2019-04-29 11:27

文章被以下专栏收录