volatile的两个作用

2024-01-17更新

今天再去查资料,发现volatile完全不是之前了解的那么回事。

volatile是编译器的关键字,只在编译时:

  1. 不会把volatile变量的值缓存在寄存器里,每次读取数据都mov,不会被优化掉
  2. 阻止编译器重排
  3. cv限定符:const volatile,既有const修饰(不能被修改),又有volatile修饰(不能被优化),而普通的const变量通常会被编译器优化直接替换,无法取地址,而cv限定符可以取地址。

其他就没有了,只是针对编译器,并不会阻止CPU对指令的优化。
volatile在多线程的数据保护中起不到任何作用。

如果要保证原子性、可见性,应该使用atomic或mutex。

  • 每个 std::atomic 模板的实例化和全特化均定义一个原子类型。如果一个线程写入原子对象,同时另一线程从它读取,那么行为有良好定义(数据竞争的细节见内存模型)。另外,对原子对象的访问可以建立线程间同步,并按 std::memory_order 对非原子内存访问定序。
  • mutex 类是能用于保护共享数据免受从多个线程同时访问的同步原语。

看到有诸如 @朱涵俊 等很多答主说volatile会让编译器每次都从内存读数据,这个是不对的。让我们看看cppreference里对其是怎么介绍的。

Every access (both read and write) made through an lvalue expression of volatile-qualified type is considered an observable side effect for the purpose of optimization and is evaluated strictly according to the rules of the abstract machine (that is, all writes are completed at some time before the next sequence point). This means that within a single thread of execution, a volatile access cannot be optimized out or reordered relative to another visible side effect that is separated by a sequence point from the volatile access.
这里提到的volatile的作用是:

  1. 让编译器认为每次对volatile变量的操作都是有可观察的副作用。也就是编译器不会把volatile变量当作常量消灭掉,也不会把volatile变量的值缓存在寄存器里。
  2. 作为一个sequence point,阻止编译器在重排operator的时候跨越这个点。
    但是注意了!!!
    但是注意了!!!
    但是注意了!!!
    文档里这些阻止重排阻止优化只是针对编译器而言的,而对cpu的优化完全没提。也就可以认为,volatile并不会阻止cpu对指令进行优化。

我们知道,在现代多核CPU中有很多核心,还有一二三级缓存,前端后端还有一堆稀奇古怪的队列和小型缓冲区。那么问题来了,即使编译器每次都用mov对指定地址操作数据,但是因为cpu有缓存和队列,那么cpu在第一次读取之后:

  1. 对读的情况来说,第一次从内存读出以后,之后每次读取cpu都可能naïve地直接将数据从缓存读出,直到收到其他部分的缓存失效通知。这时候可能发通知的组件早就执行完写指令好长时间了,也就是之前读到的很可能包含脏数据。
  2. 对写的情况来说,如果cpu后端store单元带异步队列,一个uop指令向目标地址写入后,数据可能还卡在这个队列里,没有同步到内存或者L1/2/3这种缓存。(之前把队列说成了缓存,20200807更正,并为之前用语错误深刻道歉)。
    这时如果有多于一个核心操作同一个地址,每个核心就都有可能出现读到属于自己核心的脏数据,或者写到指定地址的数据还卡在属于自己核心的队列或者缓冲区里的情况。

有人可能会问了,现在CPU有MESI,这个不是能保证核心间缓存一致性吗?其实,MESI只能保证后端组件真正发出读写指令后各个核心带缓存访存是干净的。一个ISA前端指令在CPU内可能有一大堆操作,MESI没法保证你一大堆uop是原子的。同样MESI也没法保证你的访存uop什么时候才真正开始执行和执行完成。uop可能会卡在uops buffer里,写入的东西也可能卡在队列里。
(20200807补充)
而且还有个要命的问题是,arm架构上不用专门指令刷缓存,他的缓存一般不会自动同步。
而且CPU有乱序执行,可能会把你的指令甚至后端uops都进行xjb重排。而且最重要的是,CPU仅保证它的重排在单核心范围内是符合原有语义的!
所以在多核系统中,用volatile做多线程同步是大错特错的,用多线程举例也是会误人子弟的。
多线程不能单纯依赖volatile做同步!!!
多线程不能单纯依赖volatile做同步!!!
多线程不能单纯依赖volatile做同步!!!
那么问题来了,不能单纯用volatile做同步,那么该用啥做同步呢?工程上正确的做法是:

  1. 从C11开始,C语言提供了_Atomic修饰符和stdatomic.h头文件,用它就对了。
  2. 从C++11开始,C++提供了std::atomic类,用它就对了。
  3. Rust有core::sync::atomic模块,用它就对了。

那么问题又来了,在语言没有提供这些功能时,有没有办法实现正确的多线程同步呢?答案是当然可以,不过需要用到汇编语言,以及volatile修饰符。
要自己实现一个正确的atomic类型,我们需要解决以下几点问题。

  1. CPU有缓存,我们要确保写入了数据以后缓存内容被同步到内存。
  2. CPU有缓存,我们要确保读到的是内存里的最新的内容。
  3. CPU有乱序执行,意味着它可能在单核心内正确的前提下调换我们指令实际的执行顺序。多核系统上这样调换是不行的。为了使行为可以预测,我们要避免乱序执行。
  4. 编译器可能会把我们的变量消灭掉,或者把我们写的代码重排。为了使行为可以预测,我们要避免这个情况。

那么在实际的CPU架构中,我们有对应的指令可以解决CPU部分的问题:
在x86中:我们有lfence/sfence/mfence这三条指令。他们的功能分别是:

  1. lfence确保lfence前的读取指令在lfence前执行,lfence后的读取指令在lfence后执行。
  2. sfence确保sfence前后的写入指令在sfence前执行,sfence后的读取指令在sfence后执行。
  3. mfence读写都管了。
  4. 对于单条指令,可以用lock指令前缀。性能比各种fence要好。

同样的,在ARM中,我们有DMB,DSB,ISB等指令可以完成相似的功能。
通过这些指令我们可以:

  1. 建立起内存屏障,防止明明指令已经执行完了但是数据还没有真正写入内存或者外设(假设你的外设使用的是内存地址映射方式操作)。
  2. 建立起指令屏障,防止指令的执行顺序被CPU的各种缓存和指令重排一类优化特性破坏。

同时,我们还需要防止编译器优化破坏多核并发代码,这时候我们就需要volatile修饰符,建立起编译器屏障。
通过指令屏障、内存屏障和编译器屏障三者结合,我们才可以保证多核并发的正确性。
顺带一提,x86中各种fence是自带指令屏障的,但是ARM中DMB和DSB是没有指令屏障功能的。

然而问题又又又来了。为什么网上看到的很多单纯用volatile做多线程同步的代码在我的电脑上都可以按预期的行为运行呢?
因为目前的所有x86架构实现都不支持弱内存序。也就是说,单条指令内的内存读写的多核心同步都被cpu内部搞定了。这样虽然省心,但是有个缺点是cpu电路的复杂度会大大增加。个人怀疑x86这样搞是为了一些老代码的兼容性,毕竟单核时代的多线程程序是没有考虑多核心同步的问题的。
但是,x86这样的属于奇葩,其他的如ARM一类的CPU都是带弱内存序的,因此在不加屏障指令的情况下可能会不正确。
综上所述,在现在ARM架构满天飞的情况下,即使x86上没问题,也不应该单纯用volatile做多线程同步,除非你决定放弃可移植性。
当然,单核CPU也是,只要你放弃可移植性,用volatile没问题。


作者:梅铭姿
链接:https://www.zhihu.com/question/31459750/answer/1312136508
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


volatile的两个作用:

  1. 线程可见性
  2. 内存屏障,保证指令不重排序

volatile与线程可见性

  1. 保证写后数据马上回写到系统内存
  2. 写后通知其他CPU缓存数据过期,其他CPU读时需从内存中读取,CPU的L1/L2/L3级中此变量所在的cacheline内存全部失效

不能保证i++的原子性,即使声明了volidate
volidate int i ; i++应看成三个原子操作:
1)从内存读取i至寄存器
2)i自增1
3)写入i,回写到系统内存,通知CPU缓存过期

volatile与cacheline

volatile为了保证线程可见性,每次修改volatile变量后都需要回写内存,并通知其他CPU缓存失效,若其他CPU线程缓存了此变量同一cacheline的变量则需要再次从内存读取数据,必然会导致性能下降;如何防止这种情况呢?
cacheline大小是64Byte,若两个volatile变量在同一个cacheline中,互相之间必然会有影响。

// volatile 对cacheline的影响

#include <thread>
#include <iostream>

using namespace std;
using namespace std::chrono; //增加引用空间

struct T
{
    // 在x的前后添加7个long类型数据占位,x一定独占一个cacheline
    // 注释掉x1~x14看看结果是什么

    volatile long x1 = 0L;
    volatile long x2 = 0L;
    volatile long x3 = 0L;
    volatile long x4 = 0L;
    volatile long x5 = 0L;
    volatile long x6 = 0L;
    volatile long x7 = 0L;

    volatile long x = 0L;

    volatile long x8 = 0L;
    volatile long x9 = 0L;
    volatile long x10 = 0L;
    volatile long x11 = 0L;
    volatile long x12 = 0L;
    volatile long x13 = 0L;
    volatile long x14 = 0L;
};

const long count = 100000000l;
static T ts[2];
int main()
{

    ts[0].x = 0L;
    ts[1].x = 0L;

    auto beg_t = system_clock::now(); //开始时间

    auto t1 = thread([]() {
        int i = 0;
        while (i < count)
        {
            ts[0].x++;
            i++;
        }
    });

    auto t2 = thread([]() {
        int i = 0;
        while (i < count)
        {
            ts[1].x++;
            i++;
        }
    });

    t1.join();
    t2.join();

    auto end_t = system_clock::now(); //结束时间
    duration<double> diff = end_t - beg_t;
    // printf("performTest total time: ");
    cout << "performTest total time:" << diff.count() << endl;
}

在O0下性能能有1倍多的提升。


在java中的影响尤其大:

import java.util.concurrent.CountDownLatch;

public class Deprecated {

    public static long COUNT = 1_0000_0000l;

    private static class T {
        // public volatile long x17 = 0L;
        // public volatile long x16 = 0L;
        // public volatile long x15 = 0L;
        // public volatile long x14 = 0L;
        // public volatile long x13 = 0L;
        // public volatile long x12 = 0L;
        // public volatile long x11 = 0L;
        public volatile long x = 0L;
        // public volatile long x1 = 0L;
        // public volatile long x2 = 0L;
        // public volatile long x3 = 0L;
        // public volatile long x4 = 0L;
        // public volatile long x5 = 0L;
        // public volatile long x6 = 0L;
        // public volatile long x7 = 0L;
    }

    public static T[] arr = new T[2];

    static {
        arr[0] = new T();
        arr[1] = new T();
    }

    public static void main(String[] args) throws Exception {

        CountDownLatch latch = new CountDownLatch(2);

        Thread t1 = new Thread(() -> {
            for (long i = 0; i < COUNT; i++) {
                arr[0].x = i;
            }

            latch.countDown();
        });

        Thread t2 = new Thread(() -> {
            for (long i = 0; i < COUNT; i++) {
                arr[1].x = i;
            }

            latch.countDown();
        });

        final long start = System.nanoTime();
        t1.start();
        t2.start();
        latch.await();

        final long end = System.nanoTime();
        System.out.println((end - start) / 100_0000);
    }
}

性能有超过100倍的提升。

volatile与指令重排序

我们先看以下代码:

// main.cpp
#include <iostream>
#include <thread>

using namespace std;

int main()
{
    static int a = 0;
    static int b = 0;
    static int y = 0;
    static int x = 0;
    static int j = 0;

    while (true)
    {
        a = b = x = y = 0;

        auto t1 = thread([]() {
            a = 1; // L1
            x = b; // L2
        });

        auto t2 = thread([]() {
            b = 1; // L3
            y = a; // L4
        });

        t1.join();
        t2.join();

        j++;

        // 判断x与y的值
        /*
正常情况下,x与y可能的输出有哪些?

执行顺序(L1在L2前,L3在L4前):
1. L1-L2-L3-L4 : 0,1
2. L3-L4-L1-L2 : 1,0
3. L1-L3-L2-L4 : 1,1
4. L1-L3-L4-L2 : 1,1
...

总之,x与y至少有一个为1。

若x与y都为0,则说明执行顺序必须同时满足:
1. L4 在 L1 前执行,x = 0
2. L2 在 L3 前执行,y = 0

而正常的执行顺序:L1必须在L2前,L3必须在L4前

发生x=y=0的情况一定是发生了指令重排序
        */

        if (x == 0 && y == 0)
        {
            cout << "第" << j << "次发生了指令重排序" << endl;
            break;
        }
    }
}

编译:

g++ -std=c++11 main.cpp -lpthread -O2

在执行过程中确实发生了指令重排序。


java的实现:


public class barrier {

    private static int a = 0;
    private static int b = 0;
    private static int x = 0;
    private static int y = 0;

    public static void main(String[] args) throws InterruptedException{

        int i = 0;
        while (true) {
            i++;
            x = y = a = b = 0;

            Thread t1 = new Thread(new Runnable() {

                @Override
                public void run() {
                    a = 1;
                    x = b;
                }
            });

            Thread t2 = new Thread(new Runnable() {

                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });

            t1.start();
            t2.start();
            t1.join();
            t2.join();

            if (x == 0 && y == 0) {
                System.out.println("on " + i + " occur");
                break;
            }
        }
    }
}

思考

  1. 如果不加volatile,CPU缓存数据何时会回写?CPU又何时会重新从内存中加载数据?
  2. 发生指令重排序的原因?如何防止重排序?
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值