前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >震惊!你在 Java 中所理解的 volatile 在 C++ 中可能是错的?

震惊!你在 Java 中所理解的 volatile 在 C++ 中可能是错的?

作者头像
字节流动
发布2020-06-03 10:01:21
1.6K0
发布2020-06-03 10:01:21
举报
文章被收录于专栏:字节流动字节流动

谈谈 C++ 的 volatile 关键字以及常见的误解

近期看到 C++ 标准中对 volatile 关键字的定义,发现和 java 的 volatile 关键字完全不一样,C++ 的 volatile 对并发编程基本没有帮助。

网上也看到很多关于 volatile 的误解,于是决定写这篇文章详细解释一下 volatile 的作用到底是什么。

1. 编译器对代码的优化

在讲 volatile 关键字之前,先讲一下编译器的优化。

int main() {
    int i = 0;
    i++;
    cout << "hello world" << endl;
}

按照代码,这个程序会在内存中预留 int 大小的空间,初始化这段内存为 0,然后这段内存中的数据加 1,最后输出 “hello world” 到标准输出中。

但是根据这段代码编译出来的程序(加 -O2 选项),不会预留 int 大小的内存空间,更不会对内存中的数字加 1 。他只会输出 “hello world” 到标准输出中。

其实不难理解,这个是编译器为了优化代码,修改了程序的逻辑。实际上 C++ 标准是允许写出来的代码和实际生成的程序不一致的。

虽说优化代码是件好事情,但是也不能让编译器任意修改程序逻辑,不然的话我们没办法写可靠的程序了。所以 C++ 对这种逻辑的改写是有限制的,这个限制就是在编译器修改逻辑后,程序对外界的 IO 依旧是不变的。

怎么理解呢?实际上我们可以把我们写出来的程序看做是一个黑匣子,如果按照相同的顺序输入相同的输入,他就每次都会以同样的顺序给出同样的输出。

这里的输入输出包括了标准输入输出、文件系统、网络 IO 、甚至一些 system call 等等,所有程序外部的事物都包含在内。

所以对于程序使用者来说,只要两个黑匣子的输入输出是完全一致的,那么这两个黑匣子是一致的,所以编译器可以在这个限制下任意改写程序的逻辑。这个规则又叫 as-if 原则。

2. volatile 关键字的作用

不知道有没有注意到,刚刚提到输入输出的时候,并没有提到内存,事实上,程序对自己内存的操作不属于外部的输入输出。这也是为什么在上述例子中,编译器可以去除对 i 变量的操作。

但是这又会出现一个麻烦,有些时候操作系统会把一些硬件映射到内存上,让程序通过对内存的操作来操作这个硬件,比如说把磁盘空间映射到内存中。那么对这部分内存的操作实际上就属于对程序外部的输入输出了。

对这部分内存的操作是不能随便修改顺序的,更不能忽略。这个时候 volatile 就可以派上用场了。按照 C++ 标准,对于 glvalue 的 volatile 变量进行操作,与其他输入输出一样,顺序和内容都是不能改变的。

这个结果就像是把对 volatile 的操作看做程序外部的输入输出一样。(glvalue 是值类别的一种,简单说就是内存上分配有空间的对象,更详细的请看我的另一篇文章。)

按照 C++ 标准,这是 volatile 唯一的功能,但是在一些编译器(如,MSVC )中,volatile 还有线程同步的功能,但这就是编译器自己的拓展了,并不能跨平台应用。

3. 对 volatile 常见的误解

实际上“ volatile 可以在线程间同步” 也是比较常见的误解。比如以下的例子:

class AObject
{
public:
    void wait()
    {
        m_flag = false;
        while (!m_flag)
        {
            this_thread::sleep(1000ms);
        }
    }
    void notify()
    {
        m_flag = true;
    }

private:
    volatile bool m_flag;
};

AObject obj;

...

// Thread 1
...
obj.wait();
...

// Thread 2
...
obj.notify();
...

对 volatile 有误解的人,或者对并发编程不了解的人可能会觉得这段逻辑没什么问题,可能会认为 volatile 保证了,wait() 对 m_flag 的读取,notify() 对 m_flag 的写入,所以 Thread 1 能够正常醒来。

实际上并不是这么简单,因为在多核 CPU 中,每个 CPU 都有自己的缓存。缓存中存有一部分内存中的数据,CPU 要对内存读取与存储的时候都会先去操作缓存,而不会直接对内存进行操作。所以多个 CPU “看到” 的内存中的数据是不一样的,这个叫做内存可见性问题(memory visibility)。

放到例子中就是,Thread 2 修改了 m_flag 对应的内存,但是 Thread 1 在其他 CPU 核上运行,所以 Thread 1 不一定能看到 Thread 2 对 m_flag 做的更改。

C++11 开始,C++ 标准中有了线程的概念,C++ 标准规定了什么情况下一个线程一定可以看到另一个线程做的内存的修改。而根据标准,上述例子中的 Thread 1 可能永远看不到 m_flag 变成 true ,更严重的是,Thread 1 对m_flag 的读取会导致 Undefined Behavior 。

从 C++ 标准来说,这段代码是 Undefined Behavior ,既然是 Undefined Behavior 的话,是不是也可能正确执行?

是的,熟悉 MESI 的应该会知道,Thread 2 的修改导致缓存变脏,Thread 1 读取内存会试图获取最新的数据,所以这段代码可以正常执行。

那是不是就意味着我们可以放心使用 volatile 来做线程的同步?不是的,只是在这个例子能够正确执行而已。我们对例子稍作修改,volatile 就没那么好使了。

class AObject
{
public:
    void wait()
    {
        m_flag = false;
        while (!m_flag)
        {
            this_thread::sleep(1000ms);
        }
    }
    void notify()
    {
        m_flag = true;
    }

private:
    volatile bool m_flag;
};

AObject obj;
bool something = false;
... 

// Thread 1 
... 
obj.wait(); 
assert(something)
... 

// Thread 2 
... 
something = true;
obj.notify();
 ...

在以上代码中,Thread 1 的 assert 语句可能会失败。就如前文所说,C++ 编译器在保证 as-if 原则下可以随意打乱变量赋值的顺序,甚至移除某个变量。

所以上述例子中的 “something = true" 语句可能发生在 obj.notify() 之后。这样的话,“assert(something)” 就会失败了。

那么我们可不可能把 something 也变成 volatile?如果 something 是 volatile ,我们确实能够保证编译出来的程序中的语句顺序和源代码一致,但我们仍然不能保证两个语句是按照源代码中的顺序执行,因为现代CPU往往都有乱序执行的功能。

所谓乱序执行,CPU 会在保证代码正确执行的基础上,调整指令的顺序,加快程序的运算,更多细节我们不在这里展开。

我们如果单看 Thread 2 线程,something 和 m_flag 这两个变量的读写是没有依赖关系的,而 Thread 2 线程看不到这两个变量在其他线程上的依赖关系,所以 CPU 可能会打乱他们的执行顺序,或者同时执行这两个指令。

结果就是,在Thread 1 中,obj.wait() 返回后,something 可能仍然是 false ,assert 失败。当然,会不会出现这样的状况,实际上也和具体的 CPU 有关系。

但是我们知道错误的代码可能会引起错误的结果,我们应该避免错误的写法,而这个错误就在于误用了 volatile 关键字,volatile 可以避免优化、强制内存读取的顺序,但是 volatile 并没有线程同步的语义,C++ 标准并不能保证它在多线程情况的正确性。

那么用不了 volatile ,我们该怎么修改上面的例子?C++11 开始有一个很好用的库,那就是 atomic 类模板,在头文件中,多个线程对 atomic 对象进行访问是安全的,并且提供不同种类的线程同步。

不同种类的线程同步非常复杂,要涉及到 C++ 的内存模型与并发编程,我就不在此展开。它默认使用的是最强的同步,所以我们就使用默认的就好。以下为修改后的代码:

class AObject
{
public:
    void wait()
    {
        m_flag = false;
        while (!m_flag)
        {
            this_thread::sleep(1000ms);
        }
    }
    void notify()
    {
        m_flag = true;
    }

private:
    atomic<bool> m_flag;
};

只要把 “volatile bool” 替换为 “std::atomic<bool>” 就可以。头文件也定义了若干常用的别名,例如 “std::atomic<bool>” 就可以替换为 “atomic_bool” 。

atomic 模板重载了常用的运算符,所以 std::atomic 使用起来和普通的 bool 变量差别不大。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2019-12-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 字节流动 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 谈谈 C++ 的 volatile 关键字以及常见的误解
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档