CPU Cache的优化:解决伪共享问题

无锁的缓存框架: Disruptor
除了使用CAS和提供了各种不同的等待策略来提高系统的吞吐量外。Disruptor大有将优化进行到底的气势,它甚至尝试解决CPU缓存的伪共享问题。什么是伪共享问题呢?我们知道,为了提高CPU的速度,CPU有一个高速缓存Cache。在高速缓存中,读写数据的最小单位为缓存行(Cache Line),它是从主存(memory) 复制到缓字(Cache) 的最小单位,一般为32字节到128字节。如果两个变量存放在一个缓存行中时,在多线程访问中,可能会相互影响彼此的性能。如图5.4所示,假设X和Y在同一个缓存行。运行在CPU1上的线程更新了X,那么CPU2上的缓存行就会失效,同一行的Y即使没有修改也会变成无效,导致Cache无法命中。接着,如果在CPU2.上的线程更新了Y,则导致CPU1上的缓存行又失效(此时,同一行的X又变得无法访问)。这种情况反反复复发生,无疑是一个潜在的性能杀手。如果CPU经常不能命中缓存,那么系统的吞吐量就会急剧下降。为了使这种情况不发生,一-种可行的做法就是在x变量的前后空间都先占据一定的位置(把它叫做padding吧,用来填充用的)。这样,当内存被读入缓存中时,这个缓存行中,只有x一个变量实际是有效的,因此就不会发生多个线程同时修改缓存行中不同变量而导致变量全体
失效的情况,如图5.5 所示。
在这里插入图片描述
为了实现这个目的,我们可以这么做:

public class FalseSharing implements Runnable {

    public final static int NUM_THREADS = 4; // change
    public final static long ITERATIONS = 50L * 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.currentTimeMillis();
        runTest();
        System.out.println("duration =" + (System.currentTimeMillis() - 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();
        }
    }

    @Override
    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, p7; //comment out
    }

}

这里我们使用两个线程,因为我的计算机是4核的,大家可以根据自己的硬件配置修改参数NUM_ THREADS (第2行)。我们准备一个数组longs (第6行),数组元素个数和线程数量一致。每个线程都会访问自己对应的longs中的元素(从第42行、第27行和第14行可以看到这一点)。
最后,最关键的一点就是VolatileLong。 在第48行,准备了7个long型变量用来填充缓存。实际上,只有VolatileLong.value 是会被使用的。而那些pl、p2 等仅仅用于将数组中第一个VolatileLong.value和第二个VolatileLong.value分开,防止它们进入同一个缓存行。
这里,我使用JDK7 64位的Java虚拟机,执行, 上述程序,输出如下:
duration = 1007
这说明系统花费了5秒钟完成所有的操作。如果我注释掉第48行,也就是允许系统中两个
VolatileLong.value放置在同一个缓存行中,程序输出如下:
duration = 4756
很明显,第48行的填充对系统的性能是非常有帮助的。
**注意:**由于各个JDK版本内部实现不一致,在某些JDK版本中(比如JDK8),会自动优化不使用的字段。这将直接导致这种padding的伪共享解决方案失效。更多详细内容大家可以参考后续有关LongAddr的介绍。
Disruptor框架充分考虑了这个问题,它的核心组件Sequence会被非常频繁的访问(每次入队,它都会被加1),其基本结构如下:

class LhsPadding {
    protected long p1, p2, p3, p4, p5, p6, p7;
}

class Value extends LhsPadding {
    protected volatile long value;
}

class RhsPadding extends Value {
    protected long p9, p10, p11, p12, p13, p14, p15;
}

public class Sequence extends RhsPadding {
//省略具体实现
}

虽然在Sequence中,主要使用的只有value。但是,通过LhsPadding和RhsPadding,在这
个value的前后安置了一些占位空间,
使得value可以无冲突的存在于缓存中。
此外,对于Disruptor的环形缓冲区RingBuffer,它内部的数组是通过以下语句构造的:

this. entries = new object [sequencer . getBufferSize() +2 *BUFFER PAD];

大家注意,实际产生的数组大小是缓冲区实际大小再加上两倍的BUFFER PAD。这就相当于在这个数组的头部和尾部两段各增加了BUFFER_ PAD个填充,使得整个数组被载入Cache时不会受到其他变量的影响而失效。

猜你喜欢

转载自blog.csdn.net/qq_35119422/article/details/83146969