内存屏障_内存屏障底层原理 - CSDN
精华内容
参与话题
  • 什么是内存屏障

    2019-06-23 14:03:12
    内存屏障是指“由于编译器的优化和缓存的使用,导致对内存的写入操作不能及时的反应出来,也就是说当完成对内存的写入操作之后,读取出来的可能是旧的内容”(摘自《独辟蹊径品内核》)。(这里概念貌似不是很准确,...
       内存屏障是指“由于编译器的优化和缓存的使用,导致对内存的写入操作不能及时的反应出来,也就是说当完成对内存的写入操作之后,读取出来的可能是旧的内容”(摘自《独辟蹊径品内核》)。(这里概念貌似不是很准确,正确的定义:为了防止编译器和硬件的不正确优化,使得对存储器的访问顺序(其实就是变量)和书写程序时的访问顺序不一致而提出的一种解决办法。 它不是一种错误的现象,而是一种对错误现象提出的解决方发----欢迎指正!!)
    

    概念就是概念,生硬的东西,懂的人能从中悟出点什么,不懂的人还是一头雾水。不要着急,我们先给内存屏障分下类,然后挨个来研究一番,等看完这篇文章,再回来读读概念,你就懂了!

    内存屏障的分类:

    编译器引起的内存屏障
    缓存引起的内存屏障
    乱序执行引起的内存屏障
    1、编译器引起的内存屏障:

    我们都知道,从寄存器里面取一个数要比从内存中取快的多,所以有时候编译器为了编译出优化度更高的程序,就会把一些常用变量放到寄存器中,下次使用该变量的时候就直接从寄存器中取,而不再访问内存,这就出现了问题,当其他线程把内存中的值改变了怎么办?也许你会想,编译器怎么会那么笨,犯这种低级错误呢!是的,编译器没你想象的那么聪明!让我们看下面的代码:(代码摘自《独辟蹊径品内核》)

    int flag=0;

    void wait(){
    while ( flag == 0 )
    sleep(1000);

    }

    void wakeup(){
    flag=1;
    }
    这段代码表示一个线程在循环等待另一个线程修改flag。 Gcc等编译器在编译的时候发现,sleep()不会修改flag的值,所以,为了提高效率,它就会把某个寄存器分配给flag,于是编译后就生成了这样的伪汇编代码:

    void wait(){
    movl flag, %eax;

    while ( %eax == 0)
        sleep(1000);
    

    }
    这时,当wakeup函数修改了flag的值,wait函数还在傻乎乎的读寄存器的值而不知道其实flag已经改变了,线程就会死循环下去。由此可见,编译器的优化带来了相反的效果!

    但是,你又不能说是让编译器放弃这种优化,因为在很多场合下,这种优化带来的性能是十分可观的!那我们该怎么办呢?有没有什么办法可以避免这种情况?答案必须是肯定的,我们可以使用关键字volatile来避免这种情况。

    volatile int flag = 0;
    这样,我们就能避免编译器把某个寄存器分配给flag了。

    好,上面所描述这些,就叫做“编译器优化引起的内存屏障”,是不是懂了点什么?再回去看看概念?

    2、缓存引起的内存屏障

    好,既然寄存器能够引起这样的问题,那么缓存呢?我们都知道,CPU会把数据取到一个叫做cache的地方,然后下次取的时候直接访问cache,写入的时候,也先将值写入cache。

    那么,先让我们考虑,在单核的情况下会不会出现问题呢?先想一下,单核情况下,除了CPU还会有什么会修改内存?对了,是外部设备的DMA!那么,DMA修改内存,会不会引起内存屏障的问题呢?答案是,在现在的体系结构中,不会。

    当外部设备的DMA操作结束的时候,会有一种机制保证CPU知道他对应的缓存行已经失效了;而当CPU发动DMA操作时,在想外部设备发送启动命令前,需要把对应cache中的内容写回内存。在大多数RISC的架构中,这种机制是通过一写个特殊指令来实现的。在X86上,采用一种叫做总线监测技术的方法来实现。就是CPU和外部设备访问内存的时候都需要经过总线的仲裁,有一个专门的硬件模块用于记录cache中的内存区域,当外部设备对内存写入的时候,就通过这个硬件来判断下改内存区域是否在cache中,然后再进行相应的操作。

    那么,什么时候才能产生cache引起的内存屏障呢?多CPU? 是的,在多CPU的系统里面,每个CPU都有自己的cache,当同一个内存区域同时存在于两个CPU的cache中时,CPU1改变了自己cache中的值,但是CPU2却仍然在自己的cache中读取那个旧值,这种结果是不是很杯具呢?因为没有访存操作,总线也是没有办法监测的,这时候怎么办?

    对阿,怎么办呢?我们需要在CPU2读取操作之前使自己的cache失效,x86下,很多指令能做到这点,如lock前缀的指令,cpuid, iret等。内核中使用了一些函数来完成这个功能:mb(), rmb(), wmb()。用的也是以上那些指令,感兴趣可以去看下内核代码。

    3、乱序执行引起的内存屏障:

    我们都知道,超标量处理器越来越流行,连龙芯都是四发射的。超标量实际上就是一个CPU拥有多条独立的流水线,一次可以发射多条指令,因此,很多允许指令的乱序执行,具体怎么个乱序方法,可以去看体系结构方面的书,这里只说内存屏障。

    指令乱序执行了,就会出现问题,假设指令1给某个内存赋值,指令2从该内存取值用来运算。如果他们两个颠倒了,指令2先从内存中取值运算,是不是就错了?

    对于这种情况,x86上专门提供了lfence,sfence,和mfence 指令来停止流水线:

    lfence:停止相关流水线,知道lfence之前对内存进行的读取操作指令全部完成

    sfence:停止相关流水线,知道lfence之前对内存进行的写入操作指令全部完成

    mfence:停止相关流水线,知道lfence之前对内存进行的读写操作指令全部完成

    转载:http://www.spongeliu.com/clanguage/memorybarrier/

    展开全文
  • 理解Memory Barrier(内存屏障

    万次阅读 多人点赞 2017-12-20 15:50:15
    本文例子均在 Linux(g++)下验证通过,CPU 为 X86-64 处理器架构。所有罗列的 Linux 内核代码也均在(或只在)X86-64 下有效。 本文首先通过范例(以及内核...程序在运行时内存实际的访问顺序和程序代码编写的访

    本文例子均在 Linux(g++)下验证通过,CPU 为 X86-64 处理器架构。所有罗列的 Linux 内核代码也均在(或只在)X86-64 下有效。

    本文首先通过范例(以及内核代码)来解释 Memory Barrier,然后介绍一个利用 Memory Barrier 实现的无锁环形缓冲区。

    Memory Barrier 简介

    程序在运行时内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。内存乱序访问行为出现的理由是为了提升程序运行时的性能。内存乱序访问主要发生在两个阶段:

    1. 编译时,编译器优化导致内存乱序访问(指令重排)
    2. 运行时,多 CPU 间交互引起内存乱序访问

    Memory Barrier 能够让 CPU 或编译器在内存访问上有序。一个 Memory Barrier 之前的内存访问操作必定先于其之后的完成。Memory Barrier 包括两类:

    1. 编译器Memory Barrier
    2. CPU Memory Barrier

    很多时候,编译器和 CPU 引起内存乱序访问不会带来什么问题,但一些特殊情况下,程序逻辑的正确性依赖于内存访问顺序,这时候内存乱序访问会带来逻辑上的错误,例如:

    // thread 1
    while (!ok);
    do(x);
     
    // thread 2
    x = 42;
    ok = 1;

    此段代码中,ok 初始化为 0,线程 1 等待 ok 被设置为 1 后执行 do 函数。假如说,线程 2 对内存的写操作乱序执行,也就是 x 赋值后于 ok 赋值完成,那么 do 函数接受的实参就很可能出乎程序员的意料,不为 42。

    编译时内存乱序访问

    在编译时,编译器对代码做出优化时可能改变实际执行指令的顺序(例如 gcc 下 O2 或 O3 都会改变实际执行指令的顺序):

    // test.cpp
    int x, y, r;
    void f()
    {
    x = r;
    y = 1;
    }

    编译器优化的结果可能导致 y = 1 在 x = r 之前执行完成。首先直接编译此源文件:

    g++ -S test.cpp

    得到相关的汇编代码如下:

    movl r(%rip), %eax
    movl %eax, x(%rip)
    movl $1, y(%rip)

    这里我们看到,x = r 和 y = 1 并没有乱序。现使用优化选项 O2(或 O3)编译上面的代码(g++ -O2 -S test.cpp),生成汇编代码如下:

    movl r(%rip), %eax
    movl $1, y(%rip)
    movl %eax, x(%rip)

    我们可以清楚的看到经过编译器优化之后 movl $1, y(%rip) 先于 movl %eax, x(%rip) 执行。避免编译时内存乱序访问的办法就是使用编译器 barrier(又叫优化 barrier)。Linux 内核提供函数 barrier() 用于让编译器保证其之前的内存访问先于其之后的完成。内核实现 barrier() 如下(X86-64 架构):

    #define barrier() __asm__ __volatile__("" ::: "memory")

    现在把此编译器 barrier 加入代码中:

    int x, y, r;
    void f()
    {
    x = r;
    __asm__ __volatile__("" ::: "memory");
    y = 1;
    }

    这样就避免了编译器优化带来的内存乱序访问的问题了(如果有兴趣可以再看看编译之后的汇编代码)。本例中,我们还可以使用 volatile 这个关键字来避免编译时内存乱序访问(而无法避免后面要说的运行时内存乱序访问)。volatile 关键字能够让相关的变量之间在内存访问上避免乱序,这里可以修改 x 和 y 的定义来解决问题:

    volatile int x, y;
    int r;
    void f()
    {
    x = r;
    y = 1;
    }

    现加上了 volatile 关键字,这使得 x 相对于 y、y 相对于 x 在内存访问上有序。在 Linux 内核中,提供了一个宏 ACCESS_ONCE 来避免编译器对于连续的 ACCESS_ONCE 实例进行指令重排。其实 ACCESS_ONCE 实现源码如下:

    #define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))

    此代码只是将变量 x 转换为 volatile 的而已。现在我们就有了第三个修改方案:

    int x, y, r;
    void f()
    {
    ACCESS_ONCE(x) = r;
    ACCESS_ONCE(y) = 1;
    }

    到此基本上就阐述完了我们的编译时内存乱序访问的问题。下面开始介绍运行时内存乱序访问。

    运行时内存乱序访问

    在运行时,CPU 虽然会乱序执行指令,但是在单个 CPU 的上,硬件能够保证程序执行时所有的内存访问操作看起来像是按程序代码编写的顺序执行的,这时候 Memory Barrier 没有必要使用(不考虑编译器优化的情况下)。这里我们了解一下 CPU 乱序执行的行为。在乱序执行时,一个处理器真正执行指令的顺序由可用的输入数据决定,而非程序员编写的顺序。
    早期的处理器为有序处理器(In-order processors),有序处理器处理指令通常有以下几步:

    1. 指令获取
    2. 如果指令的输入操作对象(input operands)可用(例如已经在寄存器中了),则将此指令分发到适当的功能单元中。如果一个或者多个操作对象不可用(通常是由于需要从内存中获取),则处理器会等待直到它们可用
    3. 指令被适当的功能单元执行
    4. 功能单元将结果写回寄存器堆(Register file,一个 CPU 中的一组寄存器)

    相比之下,乱序处理器(Out-of-order processors)处理指令通常有以下几步:

    1. 指令获取
    2. 指令被分发到指令队列
    3. 指令在指令队列中等待,直到输入操作对象可用(一旦输入操作对象可用,指令就可以离开队列,即便更早的指令未被执行)
    4. 指令被分配到适当的功能单元并执行
    5. 执行结果被放入队列(而不立即写入寄存器堆)
    6. 只有所有更早请求执行的指令的执行结果被写入寄存器堆后,指令执行的结果才被写入寄存器堆(执行结果重排序,让执行看起来是有序的)

    从上面的执行过程可以看出,乱序执行相比有序执行能够避免等待不可用的操作对象(有序执行的第二步)从而提高了效率。现代的机器上,处理器运行的速度比内存快很多,有序处理器花在等待可用数据的时间里已经可以处理大量指令了。
    现在思考一下乱序处理器处理指令的过程,我们能得到几个结论:

    1. 对于单个 CPU 指令获取是有序的(通过队列实现)
    2. 对于单个 CPU 指令执行结果也是有序返回寄存器堆的(通过队列实现)

    由此可知,在单 CPU 上,不考虑编译器优化导致乱序的前提下,多线程执行不存在内存乱序访问的问题。我们从内核源码也可以得到类似的结论(代码不完全的摘录):

    #ifdef CONFIG_SMP
    #define smp_mb() mb()
    #else
    #define smp_mb() barrier()
    #endif

    这里可以看到,如果是 SMP 则使用 mb,mb 被定义为 CPU Memory barrier(后面会讲到),而非 SMP 时,直接使用编译器 barrier。

    在多 CPU 的机器上,问题又不一样了。每个 CPU 都存在 cache(cache 主要是为了弥补 CPU 和内存之间较慢的访问速度),当一个特定数据第一次被特定一个 CPU 获取时,此数据显然不在 CPU 的 cache 中(这就是 cache miss)。此 cache miss 意味着 CPU 需要从内存中获取数据(这个过程需要 CPU 等待数百个周期),此数据将被加载到 CPU 的 cache 中,这样后续就能直接从 cache 上快速访问。当某个 CPU 进行写操作时,它必须确保其他的 CPU 已经将此数据从它们的 cache 中移除(以便保证一致性),只有在移除操作完成后此 CPU 才能安全的修改数据。显然,存在多个 cache 时,我们必须通过一个 cache 一致性协议来避免数据不一致的问题,而这个通讯的过程就可能导致乱序访问的出现,也就是这里说的运行时内存乱序访问。这里不再深入讨论整个细节,这是一个比较复杂的问题,有兴趣可以研究http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf 一文,其详细的分析了整个过程。

    现在通过一个例子来说明多 CPU 下内存乱序访问:

    // test2.cpp
    #include <pthread.h>
    #include <assert.h>
     
    // -------------------
    int cpu_thread1 = 0;
    int cpu_thread2 = 1;
     
    volatile int x, y, r1, r2;
     
    void start()
    {
    x = y = r1 = r2 = 0;
    }
     
    void end()
    {
    assert(!(r1 == 0 && r2 == 0));
    }
     
    void run1()
    {
    x = 1;
    r1 = y;
    }
     
    void run2()
    {
    y = 1;
    r2 = x;
    }
     
    // -------------------
    static pthread_barrier_t barrier_start;
    static pthread_barrier_t barrier_end;
     
    static void* thread1(void*)
    {
    while (1) {
    pthread_barrier_wait(&barrier_start);
    run1();
    pthread_barrier_wait(&barrier_end);
    }
     
    return NULL;
    }
     
    static void* thread2(void*)
    {
    while (1) {
    pthread_barrier_wait(&barrier_start);
    run2();
    pthread_barrier_wait(&barrier_end);
    }
     
    return NULL;
    }
     
    int main()
    {
    assert(pthread_barrier_init(&barrier_start, NULL, 3) == 0);
    assert(pthread_barrier_init(&barrier_end, NULL, 3) == 0);
     
    pthread_t t1;
    pthread_t t2;
    assert(pthread_create(&t1, NULL, thread1, NULL) == 0);
    assert(pthread_create(&t2, NULL, thread2, NULL) == 0);
     
    cpu_set_t cs;
    CPU_ZERO(&cs);
    CPU_SET(cpu_thread1, &cs);
    assert(pthread_setaffinity_np(t1, sizeof(cs), &cs) == 0);
    CPU_ZERO(&cs);
    CPU_SET(cpu_thread2, &cs);
    assert(pthread_setaffinity_np(t2, sizeof(cs), &cs) == 0);
     
    while (1) {
    start();
    pthread_barrier_wait(&barrier_start);
    pthread_barrier_wait(&barrier_end);
    end();
    }
     
    return 0;
    }

    这里创建了两个线程来运行测试代码(需要测试的代码将放置在 run 函数中)。我使用了 pthread barrier(区别于本文讨论的 Memory Barrier)主要为了让两个子线程能够同时运行它们的 run 函数。此段代码不停的尝试同时运行两个线程的 run 函数,以便得出我们期望的结果。在每次运行 run 函数前会调用一次 start 函数(进行数据初始化),run 运行后会调用一次 end 函数(进行结果检查)。run1 和 run2 两个函数运行在哪个 CPU 上则通过 cpu_thread1 和 cpu_thread2 两个变量控制。
    先编译此程序:g++ -lpthread -o test2 test2.cpp(这里未优化,目的是为了避免编译器优化的干扰)。需要注意的是,两个线程运行在两个不同的 CPU 上(CPU 0 和 CPU 1)。只要内存不出现乱序访问,那么 r1 和 r2 不可能同时为 0,因此断言失败表示存在内存乱序访问。编译之后运行此程序,会发现存在一定概率导致断言失败。为了进一步说明问题,我们把 cpu_thread2 的值改为 0,换而言之就是让两个线程跑在同一个 CPU 下,再运行程序发现断言不再失败。

    最后,我们使用 CPU Memory Barrier 来解决内存乱序访问的问题(X86-64 架构下):

    int cpu_thread1 = 0;
    int cpu_thread2 = 1;
     
    void run1()
    {
    x = 1;
    __asm__ __volatile__("mfence" ::: "memory");
    r1 = y;
    }
     
    void run2()
    {
    y = 1;
    __asm__ __volatile__("mfence" ::: "memory");
    r2 = x;
    }

    准备使用 Memory Barrier

    Memory Barrier 常用场合包括:

    1. 实现同步原语(synchronization primitives)
    2. 实现无锁数据结构(lock-free data structures)
    3. 驱动程序

    实际的应用程序开发中,开发者可能完全不知道 Memory Barrier 就可以开发正确的多线程程序,这主要是因为各种同步机制中已经隐含了 Memory Barrier(但和实际的 Memory Barrier 有细微差别),这就使得不直接使用 Memory Barrier 不会存在任何问题。但是如果你希望编写诸如无锁数据结构,那么 Memory Barrier 还是很有用的。

    通常来说,在单个 CPU 上,存在依赖的内存访问有序:

    Q = P;
    D = *Q;

    这里内存操作有序。然而在 Alpha CPU 上,存在依赖的内存读取操作不一定有序,需要使用数据依赖 barrier(由于 Alpha 不常见,这里就不详细解释了)。

    在 Linux 内核中,除了前面说到的编译器 barrier — barrier() 和 ACCESS_ONCE(),还有 CPU Memory Barrier:

    1. 通用 barrier,保证读写操作有序的,mb() 和 smp_mb()
    2. 写操作 barrier,仅保证写操作有序的,wmb() 和 smp_wmb()
    3. 读操作 barrier,仅保证读操作有序的,rmb() 和 smp_rmb()

    注意,所有的 CPU Memory Barrier(除了数据依赖 barrier 之外)都隐含了编译器 barrier。这里的 smp 开头的 Memory Barrier 会根据配置在单处理器上直接使用编译器 barrier,而在 SMP 上才使用 CPU Memory Barrier(也就是 mb()、wmb()、rmb(),回忆上面相关内核代码)。

    最后需要注意一点的是,CPU Memory Barrier 中某些类型的 Memory Barrier 需要成对使用,否则会出错,详细来说就是:一个写操作 barrier 需要和读操作(或数据依赖)barrier 一起使用(当然,通用 barrier 也是可以的),反之依然。

    Memory Barrier 的范例

    读内核代码进一步学习 Memory Barrier 的使用。
    Linux 内核实现的无锁(只有一个读线程和一个写线程时)环形缓冲区 kfifo 就使用到了 Memory Barrier,实现源码如下:

    /*
    * A simple kernel FIFO implementation.
    *
    * Copyright (C) 2004 Stelian Pop <stelian@popies.net>
    *
    * This program is free software; you can redistribute it and/or modify
    * it under the terms of the GNU General Public License as published by
    * the Free Software Foundation; either version 2 of the License, or
    * (at your option) any later version.
    *
    * This program is distributed in the hope that it will be useful,
    * but WITHOUT ANY WARRANTY; without even the implied warranty of
    * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    * GNU General Public License for more details.
    *
    * You should have received a copy of the GNU General Public License
    * along with this program; if not, write to the Free Software
    * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
    *
    */
     
    #include <linux/kernel.h>
    #include <linux/module.h>
    #include <linux/slab.h>
    #include <linux/err.h>
    #include <linux/kfifo.h>
    #include <linux/log2.h>
     
    /**
    * kfifo_init - allocates a new FIFO using a preallocated buffer
    * @buffer: the preallocated buffer to be used.
    * @size: the size of the internal buffer, this have to be a power of 2.
    * @gfp_mask: get_free_pages mask, passed to kmalloc()
    * @lock: the lock to be used to protect the fifo buffer
    *
    * Do NOT pass the kfifo to kfifo_free() after use! Simply free the
    * &struct kfifo with kfree().
    */
    struct kfifo *kfifo_init(unsigned char *buffer, unsigned int size,
    gfp_t gfp_mask, spinlock_t *lock)
    {
    struct kfifo *fifo;
     
    /* size must be a power of 2 */
    BUG_ON(!is_power_of_2(size));
     
    fifo = kmalloc(sizeof(struct kfifo), gfp_mask);
    if (!fifo)
    return ERR_PTR(-ENOMEM);
     
    fifo->buffer = buffer;
    fifo->size = size;
    fifo->in = fifo->out = 0;
    fifo->lock = lock;
     
    return fifo;
    }
    EXPORT_SYMBOL(kfifo_init);
     
    /**
    * kfifo_alloc - allocates a new FIFO and its internal buffer
    * @size: the size of the internal buffer to be allocated.
    * @gfp_mask: get_free_pages mask, passed to kmalloc()
    * @lock: the lock to be used to protect the fifo buffer
    *
    * The size will be rounded-up to a power of 2.
    */
    struct kfifo *kfifo_alloc(unsigned int size, gfp_t gfp_mask, spinlock_t *lock)
    {
    unsigned char *buffer;
    struct kfifo *ret;
     
    /*
    * round up to the next power of 2, since our 'let the indices
    * wrap' technique works only in this case.
    */
    if (!is_power_of_2(size)) {
    BUG_ON(size > 0x80000000);
    size = roundup_pow_of_two(size);
    }
     
    buffer = kmalloc(size, gfp_mask);
    if (!buffer)
    return ERR_PTR(-ENOMEM);
     
    ret = kfifo_init(buffer, size, gfp_mask, lock);
     
    if (IS_ERR(ret))
    kfree(buffer);
     
    return ret;
    }
    EXPORT_SYMBOL(kfifo_alloc);
     
    /**
    * kfifo_free - frees the FIFO
    * @fifo: the fifo to be freed.
    */
    void kfifo_free(struct kfifo *fifo)
    {
    kfree(fifo->buffer);
    kfree(fifo);
    }
    EXPORT_SYMBOL(kfifo_free);
     
    /**
    * __kfifo_put - puts some data into the FIFO, no locking version
    * @fifo: the fifo to be used.
    * @buffer: the data to be added.
    * @len: the length of the data to be added.
    *
    * This function copies at most @len bytes from the @buffer into
    * the FIFO depending on the free space, and returns the number of
    * bytes copied.
    *
    * Note that with only one concurrent reader and one concurrent
    * writer, you don't need extra locking to use these functions.
    */
    unsigned int __kfifo_put(struct kfifo *fifo,
    const unsigned char *buffer, unsigned int len)
    {
    unsigned int l;
     
    len = min(len, fifo->size - fifo->in + fifo->out);
     
    /*
    * Ensure that we sample the fifo->out index -before- we
    * start putting bytes into the kfifo.
    */
     
    smp_mb();
     
    /* first put the data starting from fifo->in to buffer end */
    l = min(len, fifo->size - (fifo->in & (fifo->size - 1)));
    memcpy(fifo->buffer + (fifo->in & (fifo->size - 1)), buffer, l);
     
    /* then put the rest (if any) at the beginning of the buffer */
    memcpy(fifo->buffer, buffer + l, len - l);
     
    /*
    * Ensure that we add the bytes to the kfifo -before-
    * we update the fifo->in index.
    */
     
    smp_wmb();
     
    fifo->in += len;
     
    return len;
    }
    EXPORT_SYMBOL(__kfifo_put);
     
    /**
    * __kfifo_get - gets some data from the FIFO, no locking version
    * @fifo: the fifo to be used.
    * @buffer: where the data must be copied.
    * @len: the size of the destination buffer.
    *
    * This function copies at most @len bytes from the FIFO into the
    * @buffer and returns the number of copied bytes.
    *
    * Note that with only one concurrent reader and one concurrent
    * writer, you don't need extra locking to use these functions.
    */
    unsigned int __kfifo_get(struct kfifo *fifo,
    unsigned char *buffer, unsigned int len)
    {
    unsigned int l;
     
    len = min(len, fifo->in - fifo->out);
     
    /*
    * Ensure that we sample the fifo->in index -before- we
    * start removing bytes from the kfifo.
    */
     
    smp_rmb();
     
    /* first get the data from fifo->out until the end of the buffer */
    l = min(len, fifo->size - (fifo->out & (fifo->size - 1)));
    memcpy(buffer, fifo->buffer + (fifo->out & (fifo->size - 1)), l);
     
    /* then get the rest (if any) from the beginning of the buffer */
    memcpy(buffer + l, fifo->buffer, len - l);
     
    /*
    * Ensure that we remove the bytes from the kfifo -before-
    * we update the fifo->out index.
    */
     
    smp_mb();
     
    fifo->out += len;
     
    return len;
    }
    EXPORT_SYMBOL(__kfifo_get);

    为了更好的理解上面的源码,这里顺带说一下此实现使用到的一些和本文主题无关的技巧:

    1. 使用与操作来求取环形缓冲区的下标,相比取余操作来求取下标的做法效率要高不少。使用与操作求取下标的前提是环形缓冲区的大小必须是 2 的 N 次方,换而言之就是说环形缓冲区的大小为一个仅有一个 1 的二进制数,那么 index & (size – 1) 则为求取的下标(这不难理解)
    2. 使用了 in 和 out 两个索引且 in 和 out 是一直递增的(此做法比较巧妙),这样能够避免一些复杂的条件判断(某些实现下,in == out 时还无法区分缓冲区是空还是满)

    这里,索引 in 和 out 被两个线程访问。in 和 out 指明了缓冲区中实际数据的边界,也就是 in 和 out 同缓冲区数据存在访问上的顺序关系,由于未使用同步机制,那么保证顺序关系就需要使用到 Memory barrier 了。索引 in 和 out 都分别只被一个线程修改,而被两个线程读取。__kfifo_put 先通过 in 和 out 来确定可以向缓冲区中写入数据量的多少,这时,out 索引应该先被读取后才能真正的将用户 buffer 中的数据写入缓冲区,因此这里使用到了 smp_mb(),对应的,__kfifo_get 也使用 smp_mb() 来确保修改 out 索引之前缓冲区中数据已经被成功读取并写入用户 buffer 中了。对于 in 索引,在 __kfifo_put 中,通过 smp_wmb() 保证先向缓冲区写入数据后才修改 in 索引,由于这里只需要保证写入操作有序,故选用写操作 barrier,在 __kfifo_get 中,通过 smp_rmb() 保证先读取了 in 索引(这时候 in 索引用于确定缓冲区中实际存在多少可读数据)才开始读取缓冲区中数据(并写入用户 buffer 中),由于这里只需要保证读取操作有序,故选用读操作 barrier。

    到这里,Memory Barrier 就介绍完毕了。


    原文链接:http://blog.csdn.net/world_hello_100/article/details/50131497

    展开全文
  • 一文理解内存屏障

    千次阅读 2019-05-15 20:24:48
    内存屏障是硬件之上、操作系统或JVM之下,对并发作出的最后一层支持。再向下是是硬件提供的支持;向上是操作系统或JVM对内存屏障作出的各种封装。内存屏障是一种标准,各厂商可能采用不同的实现。 本文仅为了帮助...

    内存屏障是硬件之上、操作系统或JVM之下,对并发作出的最后一层支持。再向下是是硬件提供的支持;向上是操作系统或JVM对内存屏障作出的各种封装。内存屏障是一种标准,各厂商可能采用不同的实现。

    本文仅为了帮助理解JVM提供的并发机制。首先,从volatile的语义引出可见性与重排序问题;接下来,阐述问题的产生原理,了解为什么需要内存屏障;然后,浅谈内存屏障的标准、厂商对内存屏障的支持,并以volatile为例讨论内存屏障如何解决这些问题;最后,补充介绍JVM在内存屏障之上作出的几个封装。为了帮助理解,会简要讨论硬件架构层面的一些基本原理(特别是CPU架构),但不会深入实现机制。

    内存屏障的实现涉及大量硬件架构层面的知识,又需要操作系统或JVM的配合才能发挥威力,单纯从任何一个层面都无法理解。本文整合了这三个层面的大量知识,篇幅较长,希望能在一篇文章内,把内存屏障的基本问题讲述清楚。

    如有疏漏,还望指正!

    volatile变量规则

    一个用于引出内存屏障的好例子是volatile变量规则

    volatile关键字可参考猴子刚开博客时的文章volatile关键字的作用、原理。volatile变量规则描述了volatile变量的偏序语义;这里从volatile变量规则的角度来讲解,顺便做个复习。

    定义

    volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行

    volatile变量规则只是一种标准,要求JVM实现保证volatile变量的偏序语义。结合程序顺序规则、传递性,该偏序语义通常表现为两个作用:

    • 保持可见性
    • 禁用重排序(读操作禁止重排序之后的操作,写操作禁止重排序之前的操作)

    补充:

    • 程序顺序规则:如果程序中操作A在操作B之前,那么在线程中操作A将在操作B之前执行。
    • 传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。

    后文,如果仅涉及可见性,则指明“可见性”;如果二者均涉及,则以“偏序”代称。重排序一定会带来可见性问题,因此,不会出现单独讨论重排序的场景。

    正确姿势

    之前的文章多次涉及volatile变量规则的用法。

    简单的仅利用volatile变量规则对volatile变量本身的可见性保证:

    复杂的利用volatile变量规则(结合了程序顺序规则、传递性)保证变量本身及周围其他变量的偏序:

    可见性与重排序

    前文多次提到可见性与重排序的问题,内存屏障的存在就是为了解决这些问题。到底什么是可见性?什么是重排序?为什么会有这些问题?

    可见性

    定义

    可见性的定义常见于各种并发场景中,以多线程为例:当一个线程修改了线程共享变量的值,其它线程能够立即得知这个修改。

    从性能角度考虑,没有必要在修改后就立即同步修改的值——如果多次修改后才使用,那么只需要最后一次同步即可,在这之前的同步都是性能浪费。因此,实际的可见性定义要弱一些,只需要保证:当一个线程修改了线程共享变量的值,其它线程在使用前,能够得到最新的修改值

    可见性可以认为是最弱的“一致性”(弱一致),只保证用户见到的数据是一致的,但不保证任意时刻,存储的数据都是一致的(强一致)。下文会讨论“缓存可见性”问题,部分文章也会称为“缓存一致性”问题。

    问题来源

    一个最简单的可见性问题来自计算机内部的缓存架构:

    image.png

    缓存大大缩小了高速CPU与低速内存之间的差距。以三层缓存架构为例:

    • L1 Cache最接近CPU, 容量最小(如32K、64K等)、速度最高,每个核上都有一个L1 Cache。
    • L2 Cache容量更大(如256K)、速度更低, 一般情况下,每个核上都有一个独立的L2 Cache。
    • L3 Cache最接近内存,容量最大(如12MB),速度最低,在同一个CPU插槽之间的核共享一个L3 Cache。

    准确地说,每个核上有两个L1 Cache, 一个存数据 L1d Cache, 一个存指令 L1i Cache。

    单核时代的一切都是那么完美。然而,多核时代出现了可见性问题。一个badcase如下:

    1. Core0与Core1命中了内存中的同一个地址,那么各自的L1 Cache会缓存同一份数据的副本。
    2. 最开始,Core0与Core1都在友善的读取这份数据。
    3. 突然,Core0要使坏了,它修改了这份数据,使得两份缓存中的数据不同了,更确切的说,Core1 L1 Cache中的数据失效了。

    单核时代只有Core0,Core0修改Core0读,没什么问题;但是,现在Core0修改后,Core1并不知道数据已经失效,继续傻傻的使用,轻则数据计算错误,重则导致死循环、程序崩溃等。

    实际的可见性问题还要扩展到两个方向:

    • 除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于CPU与L1 Cache之间的缓存。
    • 各种高级语言(包括Java)的多线程内存模型中,在线程栈内自己维护一份缓存是常见的优化措施,但显然在CPU级别的缓存可见性问题面前,一切都失效了

    以上只是最简单的可见性问题,不涉及重排序等。

    重排序也会导致可见性问题;同时,缓存上的可见性也会引起一些看似重排序导致的问题。

    重排序

    定义

    重排序并没有严格的定义。整体上可以分为两种:

    • 真·重排序:编译器、底层硬件(CPU等)出于“优化”的目的,按照某种规则将指令重新排序(尽管有时候看起来像乱序)。
    • 伪·重排序:由于缓存同步顺序等问题,看起来指令被重排序了。

    重排序也是单核时代非常优秀的优化手段,有足够多的措施保证其在单核下的正确性。在多核时代,如果工作线程之间不共享数据或仅共享不可变数据,重排序也是性能优化的利器。然而,如果工作线程之间共享了可变数据,由于两种重排序的结果都不是固定的,会导致工作线程似乎表现出了随机行为。

    第一次接触重排序的概念一定很迷糊,耐心,耐心。

    问题来源

    重排序问题无时无刻不在发生,源自三种场景:

    1. 编译器编译时的优化
    2. 处理器执行时的乱序优化
    3. 缓存同步顺序(导致可见性问题)

    场景1、2属于真·重排序;场景3属于伪·重排序。场景3也属于可见性问题,为保持连贯性,我们先讨论场景3。

    可见性导致的伪·重排序

    缓存同步顺序本质上是可见性问题。

    假设程序顺序(program order)中先更新变量v1、再更新变量v2,不考虑真·重排序:

    1. Core0先更新缓存中的v1,再更新缓存中的v2(位于两个缓存行,这样淘汰缓存行时不会一起写回内存)。
    2. Core0读取v1(假设使用LRU协议淘汰缓存)。
    3. Core0的缓存满,将最远使用的v2写回内存。
    4. Core1的缓存中本来存有v1,现在将v2加载入缓存。

    重排序是针对程序顺序而言的,如果指令执行顺序与程序顺序不同,就说明这段指令被重排序了。

    此时,尽管“更新v1”的事件早于“更新v2”发生,但Core1只看到了v2的最新值,却看不到v1的最新值。这属于可见性导致的伪·重排序:虽然没有实际上没有重排序,但看起来发生了重排序

    可以看到,缓存可见性不仅仅导致可见性问题,还会导致伪·重排序。因此,只要解决了缓存上的可见性问题,也就解决了伪·重排序

    MESI协议

    回到可见性问题中的例子和可见性的定义。要解决这个问题很简单,套用可见性的定义,只需要:在Core0修改了数据v后,让Core1在使用v前,能得到v最新的修改值

    这个要求很弱,既可以在每次修改v后,都同步修改值到其他缓存了v的Cache中;又可以只同步使用前的最后一次修改值。后者性能上更优,如何实现呢:

    1. Core0修改v后,发送一个信号,将Core1缓存的v标记为失效,并将修改值写回内存。
    2. Core0可能会多次修改v,每次修改都只发送一个信号(发信号时会锁住缓存间的总线),Core1缓存的v保持着失效标记。
    3. Core1使用v前,发现缓存中的v已经失效了,得知v已经被修改了,于是重新从其他缓存或内存中加载v。

    以上即是MESI(Modified Exclusive Shared Or Invalid,缓存的四种状态)协议的基本原理,不算严谨,但对于理解缓存可见性(更常见的称呼是“缓存一致性”)已经足够。

    MESI协议解决了CPU缓存层面的可见性问题。

    以下是MESI协议的缓存状态机,简单看看即可:

    image.png

    状态:

    • M(修改, Modified): 本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此cache只有本地一个拷贝(专有)。
    • E(专有, Exclusive): 缓存行内容和内存中的一样, 而且其它处理器都没有这行数据。
    • S(共享, Shared): 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝。
    • I(无效, Invalid): 缓存行失效, 不能使用。

    剩余问题

    既然有了MESI协议,是不是就不需要volatile的可见性语义了?当然不是,还有三个问题:

    • 并不是所有的硬件架构都提供了相同的一致性保证,JVM需要volatile统一语义(就算是MESI,也只解决CPU缓存层面的问题,没有涉及其他层面)。
    • 可见性问题不仅仅局限于CPU缓存内,JVM自己维护的内存模型中也有可见性问题。使用volatile做标记,可以解决JVM层面的可见性问题。
    • 如果不考虑真·重排序,MESI确实解决了CPU缓存层面的可见性问题;然而,真·重排序也会导致可见性问题。

    暂时第一个问题称为“内存可见性”问题,内存屏障解决了该问题。后文讨论。

    编译器编译时的优化

    JVM自己维护的内存模型中也有可见性问题,使用volatile做标记,取消volatile变量的缓存,就解决了JVM层面的可见性问题。编译器产生的重排序也采用了同样的思路。

    编译器为什么要重排序(re-order)呢?和处理器乱序执行的目的是一样的:与其等待阻塞指令(如等待缓存刷入)完成,不如先去执行其他指令。与处理器乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。

    由于同处理器乱序执行的目的相同,原理相似,这里不讨论编译器重排序的实现原理。

    幸运的是,既然是编译器层面的重排序,自然可以由编译器控制。使用volatile做标记,就可以禁用编译器层面的重排序。

    处理器执行时的乱序优化

    处理器层面的乱序优化节省了大量等待时间,提高了处理器的性能。

    所谓“乱序”只是被叫做“乱序”,实际上也遵循着一定规则:只要两个指令之间不存在数据依赖,就可以对这两个指令乱序。不必关心数据依赖的精确定义,可以理解为:只要不影响程序单线程、顺序执行的结果,就可以对两个指令重排序

    不进行乱序优化时,处理器的指令执行过程如下:

    1. 指令获取。
    2. 如果输入的运算对象是可以获取的(比如已经存在于寄存器中),这条指令会被发送到合适的功能单元。如果一个或者更多的运算对象在当前的时钟周期中是不可获取的(通常需要从主内存获取),处理器会开始等待直到它们是可以获取的。
    3. 指令在合适的功能单元中被执行。
    4. 功能单元将运算结果写回寄存器。

    乱序优化下的执行过程如下:

    1. 指令获取。
    2. 指令被发送到一个指令序列(也称执行缓冲区或者保留站)中。
    3. 指令将在序列中等待,直到它的数据运算对象是可以获取的。然后,指令被允许在先进入的、旧的指令之前离开序列缓冲区。(此处表现为乱序)
    4. 指令被分配给一个合适的功能单元并由之执行。
    5. 结果被放到一个序列中。
    6. 仅当所有在该指令之前的指令都将他们的结果写入寄存器后,这条指令的结果才会被写入寄存器中。(重整乱序结果)

    当然,为了实现乱序优化,还需要很多技术的支持,如寄存器重命名分枝预测等,但大致了解到这里就足够。后文的注释中会据此给出内存屏障的实现方案。

    乱序优化在单核时代不影响正确性;但多核时代的多线程能够在不同的核上实现真正的并行,一旦线程间共享数据,就出现问题了。看一段很经典的代码:

    public class OutofOrderExecution {
        private static int x = 0, y = 0;
        private static int a = 0, b = 0;
        
        public static void main(String[] args)
            throws InterruptedException {
            Thread t1 = new Thread(new Runnable() {
                public void run() {
                    a = 1;
                    x = b;
                }
            });
            Thread t2 = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println(“(” + x + “,” + y + “)”);
        }
    }
    

    不考虑编译器重排序和缓存可见性问题,上面的代码可能会输出什么呢?

    最容易想到的结果是(0,1)(1,0)(1,1)。因为可能先后执行线程t1、t2,也可能反之,还可能t1、t2交替执行。

    然而,这段代码的执行结果也可能是(0,0),看起来违反常理。这是处理器乱序执行的结果:线程t1内部的两行代码之间不存在数据依赖,因此,可以将x = b乱序到a = 1前;同时,线程t2中的y = a早于线程t1中的a = 1执行。一个可能的执行序列如下:

    1. t1: x = b
    2. t2: b = 1
    3. t2: y = a
    4. t1: a = 1

    这里将代码等同于指令,不严谨,但不妨碍理解。

    看起来,似乎将上述重排序(或乱序)导致的问题称为“可见性”问题也未尝不可。然而,这种重排序的危害要远远大于单纯的可见性,因为并不是所有的指令都是简单的读或者写——面试中单例模式有几种写法?volatile关键字的作用、原理中都提到了部分初始化的例子,这种不安全发布就是由于重排序导致的。因此,将重排序归为“可见性”问题并不合适,只能说重排序会导致可见性问题

    也就是说,单纯解决内存可见性问题是不够的,还需要专门解决处理器重排序的问题

    当然,某些处理器不会对指令乱序,或能够基于多核间的数据依赖乱序。这时,volatile仅用于统一重排序方面的语义。

    内存屏障

    内存屏障(Memory Barrier)与内存栅栏(Memory Fence)是同一个概念,不同的叫法。

    通过volatile标记,可以解决编译器层面的可见性与重排序问题。而内存屏障则解决了硬件层面的可见性与重排序问题

    猴子暂时没有验证下述分析,仅从逻辑和系统设计考量上进行了判断、取舍。以后会补上实验。

    标准

    先简单了解两个指令:

    • Store:将处理器缓存的数据刷新到内存中。
    • Load:将内存存储的数据拷贝到处理器的缓存中。
    屏障类型 指令示例 说明
    LoadLoad Barriers Load1;LoadLoad;Load2 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
    StoreStore Barriers Store1;StoreStore;Store2 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
    LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
    StoreLoad Barriers Store1;StoreLoad;Load2 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

    StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。

    然而,除了mfence,不同的CPU架构对内存屏障的实现方式与实现程度非常不一样。相对来说,Intel CPU的强内存模型比DEC Alpha的弱复杂内存模型(缓存不仅分层了,还分区了)更简单。x86架构是在多线程编程中最常见的,下面讨论x86架构中内存屏障的实现。

    查阅资料时,你会发现每篇讲内存屏障的文章讲的都不同。不过,重要的是理解基本原理,需要的时候再继续深究即可。

    不过不管是那种方案,内存屏障的实现都要针对乱序执行的过程来设计。前文的注释中讲解了乱序执行的基本原理:核心是一个序列缓冲区,只要指令的数据运算对象是可以获取的,指令就被允许在先进入的、旧的指令之前离开序列缓冲区,开始执行。对于内存可见性的语义,内存屏障可以通过使用类似MESI协议的思路实现。对于重排序语义的实现机制,猴子没有继续研究,一种可行的思路是:

    • 当CPU收到屏障指令时,不将屏障指令放入序列缓冲区,而将屏障指令及后续所有指令放入一个FIFO队列中(指令是按批发送的,不然没有乱序的必要)
    • 允许乱序执行完序列缓冲区中的所有指令
    • 从FIFO队列中取出屏障指令,执行(并刷新缓存等,实现内存可见性的语义)
    • 将FIFO队列中的剩余指令放入序列缓冲区
    • 恢复正常的乱序执行

    对于x86架构中的sfence屏障指令而言,则保证sfence之前的store执行完,再执行sfence,最后执行sfence之后的store;除了禁用sfence前后store乱序带来的新的数据依赖外,不影响load命令的乱序。详细见后。

    x86架构的内存屏障

    x86架构并没有实现全部的内存屏障。

    Store Barrier

    sfence指令实现了Store Barrier,相当于StoreStore Barriers。

    强制所有在sfence指令之前的store指令,都在该sfence指令执行之前被执行,发送缓存失效信号,并把store buffer中的数据刷出到CPU的L1 Cache中;所有在sfence指令之后的store指令,都在该sfence指令执行之后被执行。即,禁止对sfence指令前后store指令的重排序跨越sfence指令,使所有Store Barrier之前发生的内存更新都是可见的

    这里的“可见”,指修改值可见(内存可见性)且操作结果可见(禁用重排序)。下同。

    内存屏障的标准中,讨论的是缓存与内存间的相干性,实际上,同样适用于寄存器与缓存、甚至寄存器与内存间等多级缓存之间。x86架构使用了MESI协议的一个变种,由协议保证三层缓存与内存间的相关性,则内存屏障只需要保证store buffer(可以认为是寄存器与L1 Cache间的一层缓存)与L1 Cache间的相干性。下同。

    Load Barrier

    lfence指令实现了Load Barrier,相当于LoadLoad Barriers。

    强制所有在lfence指令之后的load指令,都在该lfence指令执行之后被执行,并且一直等到load buffer被该CPU读完才能执行之后的load指令(发现缓存失效后发起的刷入)。即,禁止对lfence指令前后load指令的重排序跨越lfence指令,配合Store Barrier,使所有Store Barrier之前发生的内存更新,对Load Barrier之后的load操作都是可见的

    Full Barrier

    mfence指令实现了Full Barrier,相当于StoreLoad Barriers。

    mfence指令综合了sfence指令与lfence指令的作用,强制所有在mfence指令之前的store/load指令,都在该mfence指令执行之前被执行;所有在mfence指令之后的store/load指令,都在该mfence指令执行之后被执行。即,禁止对mfence指令前后store/load指令的重排序跨越mfence指令,使所有Full Barrier之前发生的操作,对所有Full Barrier之后的操作都是可见的。

    volatile如何解决内存可见性与处理器重排序问题

    在编译器层面,仅将volatile作为标记使用,取消编译层面的缓存和重排序。

    如果硬件架构本身已经保证了内存可见性(如单核处理器、一致性足够的内存模型等),那么volatile就是一个空标记,不会插入相关语义的内存屏障。

    如果硬件架构本身不进行处理器重排序、有更强的重排序语义(能够分析多核间的数据依赖)、或在单核处理器上重排序,那么volatile就是一个空标记,不会插入相关语义的内存屏障。

    如果不保证,仍以x86架构为例,JVM对volatile变量的处理如下:

    • 在写volatile变量v之后,插入一个sfence。这样,sfence之前的所有store(包括写v)不会被重排序到sfence之后,sfence之后的所有store不会被重排序到sfence之前,禁用跨sfence的store重排序;且sfence之前修改的值都会被写回缓存,并标记其他CPU中的缓存失效。
    • 在读volatile变量v之前,插入一个lfence。这样,lfence之后的load(包括读v)不会被重排序到lfence之前,lfence之前的load不会被重排序到lfence之后,禁用跨lfence的load重排序;且lfence之后,会首先刷新无效缓存,从而得到最新的修改值,与sfence配合保证内存可见性。

    在另外一些平台上,JVM使用mfence代替sfence与lfence,实现更强的语义。

    二者结合,共同实现了Happens-Before关系中的volatile变量规则。

    JVM对内存屏障作出的其他封装

    除volatile外,常见的JVM实现还基于内存屏障作了一些其他封装。借助于内存屏障,这些封装也得到了内存屏障在可见性与重排序上的语义

    借助:piggyback。

    在JVM中,借助通常指:将Happens-Before的程序顺序规则与其他某个顺序规则(通常是监视器锁规则、volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。

    本文将借助的语义扩展到更大的范围,可以借助任何现有机制,以获得现有机制的某些属性。当然,并不是所有属性都能被借助,比如原子性。但基于前文对内存屏障的分析可知,可见性与重排序是可以被借助的。

    下面仍基于x86架构讨论。

    final关键字

    如果一个实例的字段被声明为final,则JVM会在初始化final变量后插入一个sfence。

    类的final字段在<clinit>()方法中初始化,其可见性由JVM的类加载过程保证。

    final字段的初始化在<init>()方法中完成。sfence禁用了sfence前后对store的重排序,且保证final字段初始化之前(include)的内存更新都是可见的。

    再谈部分初始化

    上述良好性质被称为“初始化安全性”。它保证,对于被正确构造的对象,所有线程都能看到构造函数给对象的各个final字段设置的正确值,而不管采用何种方式来发布对象

    这里将可见性从“final字段初始化之前(include)的内存更新”缩小到“final字段初始化”。猴子没找到确切的原因,手里暂时只有一个jdk也不方便验证。可能是因为,JVM没有要求虚拟机实现在生成<init>()方法时编排字段初始化指令的顺序

    初始化安全性为解决部分初始化问题带来了新的思路:如果待发布对象的所有域都是final修饰的,那么可以防止对对象的初始引用被重排序到构造过程完成之前。于是,面试中单例模式有几种写法?中的饱汉变种三还可以扔掉volatile,改为借助final的sfence语义:

    // 饱汉
    // ThreadSafe
    public class Singleton1_3 {
      private static Singleton1_3 singleton = null;
      
      public int f1 = 1;   // 触发部分初始化问题
      public int f2 = 2;
    
      private Singleton1_3() {
      }
    
      public static Singleton1_3 getInstance() {
        if (singleton == null) {
          synchronized (Singleton1_3.class) {
            // must be a complete instance
            if (singleton == null) {
              singleton = new Singleton1_3();
            }
          }
        }
        return singleton;
      }
    }
    

    注意,初始化安全性仅针对安全发布中的部分初始化问题,与其他安全发布问题、发布后的可见性问题无关。

    CAS

    在x86架构上,CAS被翻译为"lock cmpxchg..."。cmpxchg是CAS的汇编指令。在CPU架构中依靠lock信号保证可见性并禁止重排序。

    lock前缀是一个特殊的信号,执行过程如下:

    • 对总线和缓存上锁。
    • 强制所有lock信号之前的指令,都在此之前被执行,并同步相关缓存。
    • 执行lock后的指令(如cmpxchg)。
    • 释放对总线和缓存上的锁。
    • 强制所有lock信号之后的指令,都在此之后被执行,并同步相关缓存。

    因此,lock信号虽然不是内存屏障,但具有mfence的语义(当然,还有排他性的语义)。

    与内存屏障相比,lock信号要额外对总线和缓存上锁,成本更高。

    JVM的内置锁通过操作系统的管程实现。且不论管程的实现原理,由于管程是一种互斥资源,修改互斥资源至少需要一个CAS操作。因此,锁必然也使用了lock信号,具有mfence的语义。

    锁的mfence语义实现了Happens-Before关系中的监视器锁规则。

    CAS具有同样的mfence语义,也必然具有与锁相同的偏序关系。尽管JVM没有对此作出显式的要求。


    参考:


    本文链接:一文解决内存屏障
    作者:猴子007
    出处:https://monkeysayhi.github.io
    本文基于 知识共享署名-相同方式共享 4.0 国际许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名及链接。



    作者:猴子007
    链接:https://www.jianshu.com/p/64240319ed60
    来源:简书
    简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

    展开全文
  • java内存屏障的原理与应用

    千次阅读 2019-07-01 10:37:28
    1.java内存屏障 2.java内存屏障的使用 一.java内存屏障 1.1 什么是内存屏障(Memory Barrier)? 内存屏障(memory barrier)是一个CPU指令。基本上,它是这样一条指令: a) 确保一些特定操作执行的顺序; b) ...

    目录

    1. java内存屏障

    2.java内存屏障的使用

     

    一. java内存屏障

    1.1 什么是内存屏障(Memory Barrier)?
    内存屏障(memory barrier)是一个CPU指令。基本上,它是这样一条指令: a) 确保一些特定操作执行的顺序; b) 影响一些数据的可见性(可能是某些指令执行后的结果)。
    编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。


    1.2 为什么需要内存屏障
    我们知道,在多CPU(核)场景下,为了充分利用CPU,会通过流水线将指令并行进行。为了能并行执行,又需要将指令进行重排序以便进行并行执行,那么问题来了,那些指令不是在所有场景下都能进行重排,除了本身的一些规则(如Happens Before 规则)之外,我们还需要确保多CPU的高速缓存中的数据与内存保持一致性, 不能确保内存与CPU缓存数据一致性的指令也不能重排,内存屏障正是通过阻止屏障两边的指令重排序来避免编译器和硬件的不正确优化而提出的一种解决办法
     

    1.3 硬件层的内存屏障
    Intel硬件提供了一系列的内存屏障,主要有: 
    1. lfence,是一种Load Barrier 读屏障 
    2. sfence, 是一种Store Barrier 写屏障 
    3. mfence, 是一种全能型的屏障,具备ifence和sfence的能力 
    4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
     

    1.4 内存屏障的主要类型
    不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。 

    Java内存屏障主要有Load和Store两类。 
    对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据 
    对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

    对于Load和Store,在实际使用中,又分为以下四种:

    LoadLoad 屏障 
    序列:Load1,Loadload,Load2 
    确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。通常能执行预加载指令或/和支持乱序处理的处理器中需要显式声明Loadload屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。 而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。

    StoreStore 屏障 
    序列:Store1,StoreStore,Store2 
    确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用StoreStore屏障。

    LoadStore 屏障 
    序列: Load1; LoadStore; Store2 
    确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。

    StoreLoad 屏障 
    序列: Store1; StoreLoad; Load2 
    确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。StoreLoad屏障可以防止一个后续的load指令 不正确的使用了Store1的数据,而不是另一个处理器在相同内存位置写入一个新数据。正因为如此,所以在下面所讨论的处理器为了在屏障前读取同样内存位置存过的数据,必须使用一个StoreLoad屏障将存储指令和后续的加载指令分开。Storeload屏障在几乎所有的现代多处理器中都需要使用,但通常它的开销也是最昂贵的。它们昂贵的部分原因是它们必须关闭通常的略过缓存直接从写缓冲区读取数据的机制。这可能通过让一个缓冲区进行充分刷新(flush),以及其他延迟的方式来实现。

    二. java内存屏障的使用

    2.1 java内存屏障使用介绍

    常见的有以下几种:

    a. 通过 Synchronized关键字包住的代码区域,当线程进入到该区域读取变量信息时,保证读到的是最新的值.这是因为在同步区内对变量的写入操作,在离开同步区时就将当前线程内的数据刷新到内存中,而对数据的读取也不能从缓存读取,只能从内存中读取,保证了数据的读有效性.这就是插入了StoreStore屏障
    b. 使用了volatile修饰变量,则对变量的写操作,会插入StoreLoad屏障.
    c. 其余的操作,则需要通过Unsafe这个类来执行.
        UNSAFE.putOrderedObject类似这样的方法,会插入StoreStore内存屏障 
        Unsafe.putVolatiObject 则是插入了StoreLoad屏障
     

    2.2 volatile实现原理

    Volatile基本介绍
    Java语言规范第三版中对volatile的定义如下: java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。
    Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。
    volatile作用
    能保证可见性和防止指令重排序

    volatile与synchronized对比
    volatile变量修饰符如果使用恰当的话,它比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度
     

    volatile如何保证可见性、防止指令重排序
    volatile保持内存可见性和防止指令重排序的原理,本质上是同一个问题,也都依靠内存屏障得到解决
    在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。
    Java代码:    instance = new Singleton();//instance是volatile变量
    汇编代码:    0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp);
    lock前缀指令相当于一个内存屏障(也称内存栅栏),内存屏障主要提供3个功能:
    1、 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
    2、 强制将对缓存的修改操作立即写入主存,利用缓存一致性机制,并且缓存一致性机制会阻止同时修改由两个以上CPU缓存的内存区域数据;
    3、如果是写操作,它会导致其他CPU中对应的缓存行无效。
    一个处理器的缓存回写到内存会导致其他处理器的缓存失效。
    处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如CPU A嗅探到CPU B打算写内存地址,且这个地址处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,
    在下次访问相同内存地址时,强制执行缓存行填充。
    volatile关键字通过“内存屏障”来防止指令被重排序。
    为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。
    下面是基于保守策略的JMM内存屏障插入策略:
    在每个volatile写操作的前面插入一个StoreStore屏障。
    在每个volatile写操作的后面插入一个StoreLoad屏障。
    在每个volatile读操作的后面插入一个LoadLoad屏障。
    在每个volatile读操作的后面插入一个LoadStore屏障。
    volatile为什么不能保证原子性
    原子操作是一些列的操作要么全做,要么全不做,而volatile 是一种弱的同步机制,只能确保共享变量的更新操作及时被其他线程看到,以最常用的i++来说吧,包含3个步骤
    1,从内存读取i当前的值 2,加1 变成 3,把修改后的值刷新到内存,volatile无法保证这三个不被打断的执行完毕,如果在刷新到内存之前有中断,此时被其他线程修改了,之前的值就无效了
    volatile的适用场景
    volatile是在synchronized性能低下的时候提出的。如今synchronized的效率已经大幅提升,所以volatile存在的意义不大。
     

    展开全文
  • 内存屏障

    千次阅读 2018-03-20 00:38:13
    1. 什么是内存屏障 它是一条CPU指令: a)确保一些特定操作执行的顺序; b)影响一些数据的可见性(可能是某些指令执行后的结果)。 2. 内存屏障与处理器重排序 现代的处理器使用写缓冲区来临时保存向内存写入的...
  • 揭开内存屏障的面纱

    千次阅读 2020-04-02 14:38:09
    推荐阅读(强烈推荐)c++标准库内存屏障的使用 一 什么是内存屏障 内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个...
  • JMM——volatile与内存屏障

    万次阅读 2016-03-30 13:08:09
    为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序 1.当第一个操作为普通的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作(1,3) 2.当第一个操作是volatile读...
  • 优化屏障和内存屏障

    万次阅读 2010-04-14 19:47:00
    转自:http://blog.chinaunix.net/u3/93713/showart_2061476.html 优化屏障和内存屏障 优化屏障 编译器编译源代码时,会将源代码进行优化,将源代码的指令进行重排序,以适合于CPU的并行执行。然而,内核同步必须...
  • 之前 @高V 同学对本人之前《代码技巧及优化(c/c++)》的文章第六条,有关cache命中和cpu流水优化比较感兴趣,也提出了一些他的看法,今天,我就细化的说一下某些编程的点 -- 内存屏障,以及内存屏障对代码的影响。...
  • Java面试之内存屏障

    千次阅读 多人点赞 2020-03-17 17:09:54
    为什么要有内存屏障 这个是为了解决因为cpu,高速缓存,主内存出现的时候,导致的可见性和重序性问题,什么问题呢,看下面的代码 我们都知道计算机运算任务需要CPU和内存相互配合共同完成,其中CPU负责逻辑计算,...
  • barrier 和 smp_mb

    万次阅读 2012-07-30 10:12:27
    优化屏障和内存屏障 优化屏障  编译器编译源代码时,会将源代码进行优化,将源代码的指令进行重排序,以适合于CPU的并行执行。然而,内核同步必须避免指令重新排序,优化屏障(Optimization barrier)避免...
  • 在聊聊高并发(三十三)从一致性(Consistency)的角度理解Java内存模型 我们说了硬件层提供了满足某些一致性需求的能力,Java内存模型利用了硬件层提供的能力...硬件层提供了一系列的内存屏障 memory barrier / memory
  • volatile与内存屏障

    千次阅读 2019-02-22 09:26:48
    而针对变量的可见性我们知道是读volatile变量的时候直接从内存中读,而写volatile变量的时候直接写入内存。那么重排序呢? 2重排序 2.1定义 所谓重排序是指编译器和处理器为了提高程序的执行效率,在不违背...
  • JVM内存屏障(Memory Barrier)

    千次阅读 2018-05-08 00:01:32
    内存屏障(Memory Barrier) 内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行。内存屏障,又称内存栅栏,是一个CPU指令,基本上它是一条这样的指令: 保证特定操作的执行顺序。 影响...
  • 并发编程系列之volatile内存语义

    万次阅读 2020-06-09 19:29:43
    前言 前面介绍顺序一致性模型时,我们提到了程序如果正确的同步就会具备顺序一致性,这里所说的同步泛指广义上的同步,其中包括就包括同步原语volatile,那么volatile声明的变量为什么就能保证同步呢?...
  • 谈乱序执行和内存屏障

    万次阅读 多人点赞 2017-02-22 19:21:15
    十多年前的程序员对处理器乱序执行和内存屏障应该是很熟悉的,但随着计算机技术突飞猛进的发展,我们离底层原理越来越远,这并不是一件坏事,但在有些情况下了解一些底层原理有助于我们更好的工作,比如现代高级语言多...
  • LINUX内核之内存屏障

    千次阅读 2020-02-19 10:50:29
    @CopyLeft by ICANTH,I Can do ANy THing that I CAN THink!~ Author:WenHui,WuHan University,2012-6-4 源地址:http://www.cnblogs.com/icanth/archive/2012/06/10/2544300.html... 内存屏障(Memory Barriers)
  • 关于指令重排内存屏障和总线风暴

    千次阅读 2019-09-18 22:52:18
    指令重排 java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序...内存屏障可以禁止指令重排序 从上图可以看出: 1)当第二个操作是vola...
  • Java内存模型之volatile(2)

    千次阅读 2020-05-28 20:54:55
    Java内存模型的设计,主要介绍Java内存模型的设计原理,及其与处理器内存模型和顺序一致性内存模型的关系。 volatile的内存语义 当声明共享变量为volatile后,对这个变量的读/写将会很特别。为了揭开volatile的神秘...
  • Java 内存屏障

    千次阅读 2018-02-27 11:30:51
    jvm运行时刻内存分配在 java 垃圾回收整理一文中,描述了jvm运行时刻内存的分配。其中有一个内存区域是jvm虚拟机栈,每一个线程运行时都有一个线程栈,线程栈保存了线程运行时候变量值信息。当线程访问某一个对象...
1 2 3 4 5 ... 20
收藏数 27,607
精华内容 11,042
关键字:

内存屏障