计算机科学中抽象的好处与问题—伪共享实例分析

默认分类,编程 2018-06-02 访问: 77 次

David John Wheeler有一句名言“计算机科学中的任何问题都可以通过加上一层间接层来解决”一层不够就再加一层。后半句是我加的 (* ̄︶ ̄) ,虽然有点玩笑的意思,但是也的确能说明一些问题。计算机科学的确是靠着一层又一层的抽象与封装解决了巨量的问题。

我们来简单回顾一下:
开始的时候是程序员直接输入二进制指令来操纵硬件的,不仅性能低下还很耗费用户时间;
于是后来出现了操作系统,用文件、进程与线程、地址空间抽象了磁盘、CPU与内存,统一和简化了硬件访问方式;
机器语言对用户不友好,于是便出现了汇编语言、中级语言(如C)、高级语言(如Java)的包装,其最终执行还是要转化为机器语言;
裸高级语言大家还用得不爽,觉得开发效率低,于是又出现了各种框架(如Spring、Hibernate)
......

这样一层一层抽象包装下来,我们要想实现一个功能比如定时写文件等已经变成了很简单的事,只需要几行代码就搞定了。
但是抽象层数过多就会导致我们顶层的用户有时候会出现一些莫名其妙的问题,我们用一个实际的案例伪共享来说明一下

public class FalseSharing {

    private static AtomicLong time = new AtomicLong(0);

    public static void main(String... args) throws InterruptedException {
        int testNum = 50;
        for (int i = 0 ; i< testNum;i++){// 测试50次
            Thread thread = new Thread(new Job());
            thread.start();
            thread.join();
        }
        System.out.println(time.get()/1000/testNum + " us,avg");
    }

    static class Job implements Runnable{
        @Override
        public void run() {
            int number = 8;
            int iterationNumber = 20000;
            CountDownLatch countDownLatch = new CountDownLatch(number);
            Obj[] objArray = new Obj[number];
            for (int i = 0;i < number;i++) {
                objArray[i] = new Obj();
            }

            long start = System.nanoTime();
            for (int i = 0;i < number;i++){
                int ii = i;
                Thread thread = new Thread(new Runnable() {
                    int iterationNumberInner = iterationNumber;
                    @Override
                    public void run() {
                        while (iterationNumberInner-->0){
                            objArray[ii].aLong+=1L;
                        }
                        countDownLatch.countDown();
                    }
                });
                thread.start();
            }
            try {
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            long end = System.nanoTime();
            time.getAndAdd(end-start);

        }
    }

    @Contended
    private static final class Obj{
        private volatile long aLong = 8L;//8Bytes
//        private volatile long a=2L,b=2L,c=2L,d=2L,e=2L,f=2L,g=2L;//*****
    }
}

全部代码在此,为了避免Java JIT(这也是一层抽象)的影响,我们每次执行都要加参数-Xint来强制使用解释模式
在我的机器上(4core,8processor,Core-i7),直接运行这段代码得到结果14594 us,avg这个级别,在结果1基础上把//*****一行取消注释得到结果23916 us,avg 这个级别,在结果1基础上运行参数加上-XX:-RestrictContended使得@Contended起作用就能得到结果33466 us,avg

这时候顶层用户就会莫名奇妙了,怎么多了几个字段运行时间反而减小了?怎么加上@Contended后时间就更短了?
从Java代码这一层次的抽象来看,完全是没有问题的,那么问题究竟在哪呢?

我们知道一个CPU中的每个核是有自己的Cache的,高级别的L1是自己私有的,更低级别的L2、L3等可能是私有的,也可能是不同核共享的。这些不同级别的缓存(一次访问时间在几个ns左右)是用来弥补CPU的快速(一个周期通常零点几个ns)和内存访问的慢速(一次访问时间在几十个ns)之间的鸿沟的,而且是以CacheLine Size: N Bytes(Core-i7是64)为基本单位的,依据局部性原理一次性把内存中该访问变量周围的N Bytes内容拷贝到Cache中,如果一个对象不够N Bytes,就有可能和几个对象共用一个CacheLine,这样一个线程刷新Cacheline就会导致其他线程的缓存失效,要去更低级别的Cache甚至内存访问,就大大降低了访问速度。

这样回到刚才的问题,多加几个字段能在一定程度上增大该对象所占空间,减小共用CacheLine的几率,所以访问时间减少了,而@Contended则使得一个对象一个CacheLine,直接帮我们避免了伪共享,所以访问时间更少了。要解决这个问题,光知道Java这一层抽象(语法、JDK API等)是不可能的,还得懂操作系统、甚至CPU芯片原理这些层抽象才行。

再比如说,JVM帮我们把C/C++的手动内存管理封装了一层抽象做到内存自动管理从而解放了我们,我们当然用得很爽,但是如果我们不懂这一层的抽象与封装,那么程序OOM的时候就只能傻眼了。

最后总结一下,计算机科学中的任何问题都可以通过加上一层间接层来解决,这是很正确的,但是也正是因为一层一层的抽象和包装,导致出了问题后很难定位,你都不知道问题究竟是出现在哪一层。所以要想提高技术水平不仅要知其然(看得见最顶层的包装)也要知其所以然(看得见底层的包装),每一层如果都懂或者说了解一些,那么出了问题很大程度上都可以凭直觉定位,即使不能凭直觉也可以通过各种手段debug,只会最顶层的抽象很多时候就只能望bug兴叹了。

访问原文,来自MageekChiu



本文由 mageek 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。
此文章共有条评论, 人参与 |Powerd By Angboo