内核技术中文网»首页 论坛 圈点 查看内容

0 评论

0 收藏

分享

一篇对伪共享讲的很透彻的文章

什么是伪共享

在这里,我们在来回顾一下CPU高速缓存的知识,在现代计算机中,CPU缓存是分层次结构的,例如:L1,L2,L3,当CPU发起一个读取内存指令的时候,首先在一级缓存L1中查找,如果不命中继续到低一层缓存中查找,缓存中没有直到最后到内存或硬盘上读取,读取到想要的内存地址之后,将由低一层的存储结构返回包含该内存地址的一个缓存行至上一层缓存,最上一层缓存获得该缓存行之后,将该缓存行放到自己的缓存中,然后抽取出CPU真正想要读取的内存地址数据返回给CPU。

在这里面有个很重要的概念那就是cache line缓存行 ,CPU高速缓存是以一个缓存行为单位进行读写的,并不是单单只存储CPU想要的数据,至于为什么请看上一文中的CPU高速缓存的逻辑原理,即局部性原理。

我提到了一个术语“缓存一致性协议”,当一个CPU高速缓存行发生改变,为了不影响其他CPU的相关数据的正确性,必须通过某些手段让其他拥有该缓存行数据的CPU高速缓存进行数据一致性同步,这就是造成“伪共享”的所在,试想运行在两个不同CPU核心上的两个线程,如果他们操作的内存数据在物理上处于相同或相邻的内存块区域(或者干脆就是操作的相同的数据),那么在将它们各自操作的数据加载到各自的一级缓存L1的时候,在很大概率上它们刚好位于一个缓存行(这是很有可能的,毕竟一个缓存行的大小一般有64个字节),即共享一个缓存行,这时候只要其中一个CPU核心更改了这个缓存行,都会导致另一个CPU核心的相应缓存行失效,如果它们确实操作的是同一个数据变量(即共享变量)这无可厚非,但如果它们操作的是不同的数据变量呢,依然会因为共享同一个缓存行导致整个缓存行失效,不得不重新进行缓存一致性同步,出现了类似串行化的运行结果,严重影响性能,这就是所谓的“伪共享”,即从逻辑层面上讲这两个处理器核心并没有共享内存,因为他们访问的是不同的内容(变量)。但是因为cache line缓存行的存在,这两个CPU核心要访问这两个不同的内存数据时,却一定要访问同一个cache line缓存行,产生了事实上的“共享”。显然,由于cache line大小限制带来的这种“伪共享”是我们不想要的,会浪费系统资源。

缓存行上的写竞争是运行在SMP系统中并行线程实现可伸缩性最重要的限制因素。有人将伪共享描述成无声的性能杀手,因为从代码中很难知道两个不同的变量是否会出现伪共享。

伪共享图例

在上图中,展示了伪共享的种表现形式,数据X、Y、Z被加载到同一Cache Line中,线程A在Core1修改X,线程B在Core2上修改Y。根据MESI缓存一致性协议,假设是Core1是第一个发起写操作的CPU核,Core1上的L1 Cache Line由S(共享)状态变成M(修改,脏数据)状态,然后告知其他的CPU核,图例则是Core2,引用同一地址的Cache Line已经无效了;当Core2发起写操作时,首先导致Core1将X写回主存,而后才是Core2从主存重新读取该地址内容以便后面的修改。

可见多个线程操作在同一Cache Line上的不同数据,相互竞争同一Cache Line,导致线程彼此牵制影响,变成了串行程序,降低了并发性。此时我们则需要将共享在多线程间的数据进行隔离,使他们不在同一个Cache Line上,从而提升多线程的性能。当然上图只是数据在同一个CPU的多个核心直接产生的伪共享,数据仅仅通过共享的三级缓存L3就能得到同步,如果是多CPU或者处理器核心位于不同的插槽上,带来的性能问题才更糟。

伪共享的解决方案

既然知道了伪共享产生的原因是不同的数据变量由于在物理地址的连续性导致被一起加载到同一个缓存行,所以解决的办法就是:通过数据填充的方式将不同的变量在物理地址上隔离开来,失去了地址连续性被加载到同一个缓存行的概率将大大减小。 当然填充是需要技巧的,你的对缓存行的大小以及操作的数据在内存中的布局有个了解。下面以Java对伪共享的解决为例进行举例。

以下内容来自Martin Thompson的博文(中文翻译),Martin Thompson的博文中指出每个对象的起始地址都对齐于8字节以提高性能。因此当封装对象的时候为了高效率,对象字段声明的顺序会被重排序成下列基于字节大小的顺序:

  1. doubles (8) 和 longs (8)
  2. ints (4) 和 floats (4)
  3. shorts (2) 和 chars (2)
  4. booleans (1) 和 bytes (1)
  5. references (4/8)
  6. <子类字段重复上述顺序>

了解到这些信息之后,我们就可以在任意字段间利用long类型的无用变量来隔离真正有意义的变量,使它们位于不同的缓存行。如以下这个示例

public final class FalseSharing implements Runnable{

    public final static int NUM_THREADS = Runtime.getRuntime().availableProcessors(); //获得CPU核心个数

    public final static long ITERATIONS = 500L * 1000L * 1000L;

    private final int arrayIndex;

    private static VolatileLong[] longs = new VolatileLong[NUM_THREADS];

    static{
        for (int i = 0; i < longs.length; i++){
            longs[i] = new VolatileLong();
        }
    }

    public FalseSharing(final int arrayIndex){
        this.arrayIndex = arrayIndex;
    }

    public static void main(final String[] args) throws Exception{
        final long start = System.nanoTime();
        runTest();
        System.out.println("duration = " + (System.nanoTime() - start));
    }

    private static void runTest() throws InterruptedException{
        Thread[] threads = new Thread[NUM_THREADS];

        for (int i = 0; i < threads.length; i++){
            threads[i] = new Thread(new FalseSharing(i));
        }

        for (Thread t : threads){
            t.start();
        }

        for (Thread t : threads){
            t.join();
        }
    }

    public void run(){
        long i = ITERATIONS + 1;
        while (0 != --i){
            longs[arrayIndex].value = i;

        }
    }

    public final static class VolatileLong{
        public volatile long value = 0L;
        public long p1, p2, p3, p4, p5, p6; //缓存行填充代码
    }
}

以上示例根据当前计算机CPU核心个数分别创建了对应个数的线程(我的i5双核四线程处理器的个数就是4),并且创建了对应个数的VolatileLong对象,每个线程更改一个VolatileLong对象。VolatileLong对象只有一个有意义的字段value,JVM在同一时间创建这四个VolatileLong对象的时候,理论上将会分配相邻的内存地址,所以如果不进行缓存行填充,那么这四个线程修改各自的VolatileLong对象很可能会由于伪共享的影响导致性能低下,下图是得出的线程数和执行花费的时间的线性对比图。

上图的蓝色柱形图是在有缓存行填充代码的情况下执行下的时间,可见随着线程数的不同对性能几乎没有影响,而代表没有缓存行填充代码时的棕色的柱形图显示出了伪共享带来的严重性能问题。

当然作者也提出了由于不能确定这些独立的VolatileLong对象到达会布局在内存的什么位置,而只是从经验的角度认为同一时间分配的对象趋向集中于一块。另一方面,由于对JVM在布局对象的时候对是否会将两个字长(32位机一个字长4个字节,2个字即8字节,64位机则一个字就是8字节)的对象头(如果是数组还有一个字长的数组长度)与对象的字段放置到一起还是单独存储的不确定,所以用于缓存行填充的long型变量的个数也无法具体确定,所以只能进行大致的估计。作者的这个示例是以32位机上,将对象头占用的8个字节也认为是和字段一起存储在一个缓存行为假设的,所以你会看到除了一个8字节的value,另外填充了6个long型字段,加上对象头的8个字节,刚好一个缓存行的大小64字节。

这种缓存行填充方法在早期是比较流行的一种解决办法,比较有名的Disruptor框架就采用了这种解决办法提高性能,Disruptor是一个线程内通信框架,用于线程里共享数据。与LinkedBlockingQueue类似,提供了一个高速的生产者消费者模型,广泛用于批量IO读写,在硬盘读写相关的程序中应用的十分广泛,Apache旗下的HBase、Hive、Storm等框架都有在使用Disruptor。

Java7 对伪共享的解决

同样出自的Martin Thompson博文(中文译文) ,由于Java7会淘汰或者是重新排列无用的字段,所以原来的填充long类型无用字段的办法在Java 7下就不奏效了(也有人说依然可行,可能与具体的JDK版本有关),但是伪共享依然会发生,为此,Martin Thompson也给出了新的解决办法,那就是将原来的VolatileLong对象修改为继承AtomicLong,或者将用于填充的long字段写到父类,让VolatileLong继承父类:

方案一:
public static class VolatileLong extends AtomicLong{

    public volatile long p1, p2, p3, p4, p5, p6 = 7L;
}

方案二:
abstract class AbstractPaddingObject{
    protected long p1, p2, p3, p4, p5, p6;
}

public class VolatileLong extends AbstractPaddingObject{
    public volatile long value =0L;
}

Java8 对伪共享的解决

时间进入到Java8时代后,Java官方已经提供了对伪共享的解决办法,那就是sun.misc.Contended 注解。 有了这个注解解决伪共享就变得简单多了:

@sun.misc.Contended  
public class VolatileLong {  
        volatile long v = 0L;  
}

要注意的是user classpath使用此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended 。其实@Contended注解还能用在字段上,具体下文分解。

参考文献

https://blog.csdn.net/qq_27680317/article/details/78486220

http://ifeve.com/falsesharing/

http://ifeve.com/false-sharing/

http://ifeve.com/false-shareing-java-7-cn/

http://ifeve.com/false-sharing-java-7/

http://www.cnblogs.com/liloke/archive/2011/11/20/2255737.html

原文作者:极致Linux内核

原文地址:https://zhuanlan.zhihu.com/p/577861520(版权归原文作者所有,侵权联系删除

回复

举报 使用道具

全部回复
暂无回帖,快来参与回复吧
主题 1545
回复 0
粉丝 2
扫码获取每晚技术直播链接
快速回复 返回顶部 返回列表