MelonBlog

CPU缓存的可见性问题

现代计算机能够有如此高的性能,CPU缓存的作用功不可没,作为一个开发者不能仅仅只知道缓存的好处,我们还需要知道缓存带来的问题,缓存带来的最大的问题就是可见性问题,今天用一片文章来讲讲我对缓存可见性问题的理解。

CPU的三级缓存

用一张表格来表示三级缓存的差异


速度(时钟周期)容量访问限制
L1高速缓存132k+32k核心独享
L2高速缓存4256k核心独享
L3高速缓存106mb所有核心共享
内存6016G(PC)所有硬件共享

离CPU核心越近的缓存,速度更快,容量更小,这些是常识。这里有一个小知识点,L1 和 L2是核心独享的,而L3是所有核心共享的,所以并不是我们通常理解的L1~L3,就是3个缓存,实际上L1和L2的数量是根据CPU的核心数量来的。

并且L1高速缓存其实还分为2个部分,指令部分和数据部分。

image

cpu读取数据时,会逐级查询高速缓存,如果3级高速缓存都未命中,会从主存里读取数据或者指令,并且将读取到的数据或者指令缓存到各级缓存。

所以高速缓存的命中率其实就和性能直接挂钩了,换句话说,作为一个程序员,想要优化一个软件的性能,就可以从提升缓存的命中率作为切入点,具体怎么提升缓存命中率,可以引入另外一个话题——局部性原理(单独写一篇博客来聊)。

软件所处的空间决定了软件的性能

下面这张图就清晰展示了,因为软件所操作的数据来自不同的空间,所以软件的延迟差异就非常大

image

缓存行

思考一个问题:cpu每次从主存里读取指令或者数据时,读取多少呢?如果是一条一条的读那不是时间都花在路上(总线io)了吗?

这个问题的答案其实就是缓存行(Cache Lines)。

在大多数系统里,缓存行的大小为64个字节,所以cpu每次从主存读取数据,都是读取一个64个字节的数据块。意味着无论cpu读取哪一个地址的数据或者指令时,都会连带相邻的63个字节的数据一起读取。并且cpu还会预读更多的cache line到高速缓存,所以程序的局部性原理显得更加重要了。

验证cache line的存在

通过一个算法来验证这个猜想,通过单步和跳步的方式来遍历一个数组,看看花费的时间差异:

public class CacheLine {
    private static final int ARRAY_SIZE = 64 * 1024 * 1024;
    private static final int[] array = new int[ARRAY_SIZE];
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0, n = array.length; i < n; i++) {
            array[i] *= 3;
        }
        System.out.println(System.currentTimeMillis() - start);
        for (int i = 0, n = array.length; i < n; i += 16) {
            array[i] *= 3;
        }
        System.out.println(System.currentTimeMillis() - start);
    }
}

运行结果:

34
47

通过结果可以看出来,遍历这个数组的时间差异并没有16倍这么大。这里跳步选择使用16的原因是16个int的大小就是64个字节。

缓存一致性问题

设想一个场景,假设有一个双核的cpu,这2个核心分别处理2个线程,并且这2个线程会访问和修改同一个cache line,这时候这个cache line会同时缓存5份。

我们知道每一个cpu核心都有独立的L1和L2高速缓存,L3是所有核心共享的,所以这个共享的cache line会存在在每一个核心的L1和L2高速缓存里,并且L3高速缓存同时也会存储这个cache line。

image

试想一下,假如线程1(core 1)修改了cache line里面的数据,那么线程2(core 2)在没有额外帮助的情况下,是不知道cache line的变化的。这就是这篇文章标题讲的可见性问题

所以,一个程序如果碰到了缓存的可见性问题,那么程序的运行结果就没办法得到保证,所以需要找到办法来规避这个问题。

后面会写一篇博客讲讲解决缓存一致性问题的办法——MESI