zoukankan      html  css  js  c++  java
  • 从现代计算机低层看待性能和并发

    并发问题的本质是原子性有序性可见性
    造成原子性问题原因是操作系统增加了进程,线程,以分时复用CPU,进而均衡CPU与IO的速度差异,在Java中线程切换带来了原子性问题。
    造成可见性的问题原因是CPU添加了缓存,增加了访问速度,符合空间局部性原理,但是缓存却引发了可见性的问题。
    造成有序性的问题原因是编译优化指令的执行顺序,但是却引发了有序性问题。
    现在就让我们从计算机实现原理的角度上来看待这些问题。
     
    现在的计算机大多数都是冯诺依曼体系,即包含有运算器,控制器,存储器和输入输出设备。
    一个指令的执行大概经过了控制器从存储器获取指令,译码执行这3个阶段。我们把这3个阶段称为一个指令周期。
    但是这个阶段有个问题就是吞吐量有限和复杂指令耗时比较久的问题。比如这个指令的加载耗时,浮点数计算要比整数计算要复杂。
    针对这个问题,工程师们设计了指令流水线,就像我们现在的软件工程学那样,产品经理出了PRD就是设计极端,程序员开发就是开发阶段,测试人员进行测试就是测试阶段,每个阶段不一定非要等上个阶段的结果,程序员不一定等产品经理出了文档才干活。流水线架构的核心就是:前一个指令没开始执行,后面的指令就开始执行,注意这里是执行其他的指令,跟后面讲的预测和冒险还是有区别的。
    有了这个思路,那我们把职责再拆的细点,于是开发阶段就分为架构设计极端,前端开发阶段,后端开发阶段。。。这种形式我们称为多级流水线。
    对应到CPU就是如果一个步骤需要执行的时间较长,可以把这个步骤拆分成更多的步骤,让所有的步骤执行时间差不多长。
    这样每个具体岗位的人在没有上一阶段的活干的时候可以干别的活。
     
    那流水线级是不是拆的越细越好呢?
    你要明白,流水线技术不是为了缩短单指令的响应时间这个指标,虽然这个方案明显的增加了在运行很多指令时候的吞吐量。也就是说,分的细,让每个岗位都不闲着,可以最大化的提高利用率。
    所以呢,回到问题,是不是拆的越细越好,答案是不对。拆的越细,能耗就会增加;岗位分的越细,员工越来越多,老板的钱包扛不住啊。
     
    了解完指令执行速度快慢的问题,但是单指令周期没有变化。
    让我们先分析问题。
    一般我们的代码之间有先后顺序,编译成指令肯定也是如此,也就是说指令之间存在依赖问题,而依赖会造成阻塞。这个依赖问题业界称之为冒险问题。目前大佬们对已经对冒险问题进行了分类:数据冒险,结构冒险和控制冒险。
    数据冒险中的数据依赖性,一般有:先读后写,先写后读,写后再写这3种。
    结构冒险中就是指令和数据的问题。
    控制冒险就是在条件分支指令和无条件调转指令这种情况,不知道是执行下条指令,还是跳转到下一个内存地址。
     
    那分析出问题我们就可以考虑解决方案了。
    当出现数据冒险了,我们就等一等。专业术语叫做流水线停顿或者流水线冒泡。
    如果2个指令之间没有因果关系,我们就把后面没有依赖关系的指令提前执行,也就是乱序执行,对应到现实世界就是上菜的顺序可以不是菜单的顺序。而乱序执行是把双刃剑,加速指令执行的时候也会带来问题,像单例的双重检查锁的原因,volatile的防止指令重排,Java内存模型中的内存屏障,Happen-Before原则等等都跟指令重排(指令乱序执行)相关。
    如果两个指令之间有因果关系,那么上一条指令执行完直接把结果给下一条指令,就像接力赛一样下一棒可以抢跑,而不用再把数据写回相应的存储。这种方式叫操作数前推,JVM的栈执行也有这么一套优化机制。
    如果结构冒险了,就增加资源,比如现在的高速缓存就使用了哈弗架构,把一份缓存分为指令和数据2块,让他们可以同时访问,也就是说指令和数据可以同时获取,大大提高了访问的效率。
    如果不知道下一条指令是什么(即控制冒险),那我们就猜一猜,正所谓搏一搏单车变摩托。聪明的巨人们设计出了动态预测技术,大体逻辑你可以简单理解为:昨天下雨,今天下雨,明天也可能下雨。所以有些面试题会给你一个二维数组去遍历,问你哪个更快一些。所以我们让CPU提前加载数据或者指令到寄存器,寄存器提前从内存加载,内存会提前从硬盘加载,也是空间局部性和时间局部性的一种论证实现;和指令的乱序执行一样,这种预加载的方式也会带来伪共享问题,需要volatile保证线程间通信。而计算机体系提供了MESI和总线嗅探机制来支持解决这些问题。
     
    文章开头我们提到,并发问题的根源就是原子性,有序性和可见性。现在让我们总结一下
    有序性是编译优化或者CPU本身的乱序执行带来的问题,Java内存模型中的内存屏障(见<Java并发编程的艺术>P9)概念就是fix掉这个问题。
    可见性是你的CPU提前做了预判,预读数据到了缓存,符合空间局部性原理,但是也带来了伪共享的问题,而计算机体系中的MESI协议和总线嗅探机制帮助你解决掉了这个问题,也是Java的低层实现。
    那么我们来看看原子性问题。原子性指的是一个操作不被间断的被执行。从Java的角度来讲,new一个对象虽然是一行代码,但是他经历了类加载阶段,TLAB中划分内存,初始化数据类型,填充对象头,调用init方法,栈中建立对象引用这些个操作。
    这些个操作即使保证了有序性和可见性,但是还会出问题,问题就是下面要讲的内容。
     
    上面我们了解到单指令周期的优化手段,但是有些场景下流水线停顿和某一阶段的等待是无法避免的,解决这个问题,大佬门设计出了一些新的花样。
    超标量多发射的技术,一次从内存取出多条指令,分发给多个译码器,然后交付给不同的功能处理单元来计算,这样一个时钟周期里处理的指令就不止一条了。
    超线程技术,把物理核心伪装成2个逻辑核心,也就是双份寄存器,译码器和运算器还是一份,目的是为了在流水线停顿的时候,让另外一个线程去执行指令。这样,在译码器和ALU空闲的时候,让另外一个线程去执行。
    关于寄存器的知识我补充一下,程序运行的状态结果是存储在寄存器里的,CPU由大量的寄存器组成,寄存器由触发器或者锁存器组成的简单电路。触发器和锁存器就是两种不同原理的数字电路组成的逻辑门。N个触发器或者锁存器,组成一个N位(bit)的寄存器,能够保存N位的数据。64位的intel服务器,寄存器就是64位的。寄存器又分为PC计数器(存放下一条指令地址),指令寄存器(存放当前执行的指令),条件码寄存器,整数寄存器,浮点数寄存器等等。寄存器的实现方案就是栈,栈这种数据结构大量的被用来实现计算机的操作执行,方法调用。JVM的线程栈和栈帧就是参考了这种结构或者说参考了CPU设计。
     
    其实原子性问题,跟CPU的超线程,超标量和多发射技术相关,更多是源自于操作系统调度有关。操作系统增加了进程,线程,以分时复用CPU,进而均衡CPU与IO的速度差异。一个系统也不是只运行一个软件。在<Unix编程的艺术>说:Unix的本源用途——作为大中型计算机的通用分时系统。而当今的计算机系统,发展出了进程管理,文件管理,存储管理,设备管理和作业管理这五大管理功能。其中的进程管理,作业管理的本源就是分时调用。这里其实你也可以理解,一直被人诟病的,为什么Java要把线程托付给操作系统这么笨重的操作,因为这是操作系统吃饭的看家本领,这个时候你顺便想象一下什么是内核态和用户态,为什么会有内核态和用户态这么一个说法。
    有了多个核心(虚拟或者物理),再加上多发射和超标量技术,操作系统就可以加以利用,让多个软件有了同时利用的能力。但是这样远远不够,系统上的软件数量(资源问题)远远大于CPU的核心数量,不同硬件之间的速度(时间问题),操作系统不得不考虑软件之间的执行问题。
    于是,多个软件执行,操作系统分割时间,让不同时间点运行不同软件——也就是上文说到的进程管理和作业管理。所以这就带来了另外一个问题,线程上下文的切换。这个上下文你可以理解成我们刚才说过的寄存器保存指令的状态和结果。
    但是寄存器不能保存这么多软件的状态,所以要写回主存中去。当切换到新的任务时,要从主存把数据加载到高速缓存,再加载到寄存器。
    另外一个问题就是硬件之间不同的速度问题(资源限制)。推荐阅读<我是一个CPU:这个世界慢!死!了!> https://blog.51cto.com/u_13188467/2065321 这篇文章还是比较形象的。下面罗列了不同硬件之间的随机访问延时的数据让大家感受一下。
     

    存储器

    硬件介质

    随机访问延时

    L1 Cache

    SRAM

    1ns

    L2Cache

    SRAM

    4ns

    L3 Cache

    SRAM

     

    Memory

    DRAM

    100ns

    Disk

    SSD

    150微秒

    Disk

    HDD

    10毫秒

     
    寄存器:寄存器是有反馈电路和几个门电路组成,几乎和CPU同速。
    高速缓存:SRAM Static Random Access Memory,电路相对简单,一个比特的数据,需要 6~8 个晶体管,存储密度不高,不过访问速度较快。分为L1,L2,L3三级,L1,L2为核心独享。
    内存:DRAM Dynamic Random Access Memory,DRAM 被称为“动态”存储器,是因为 DRAM 需要靠不断地“刷新”,才能保持数据被存储起来。DRAM 的一个比特,只需要一个晶体管和一个电容就能存储。所以,在同样的物理空间里,存储密度更大。但是,因为数据是存储在电容里的,电容会不断漏电,所以需要定时刷新充电,才能保持数据不丢失。DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问延时也就更长。
    SSD:Solid-state disk SSD由裸片组成,一个裸片上有很多平面(GB),一个平面上会划分很多块(MB),一个块里面会有很多页(4KB),页里有很多SLC/MLC/TLC/QLC颗粒组成,这些颗粒可以简单的认为由电压计和电容组成(和DRAM类型)。SSD的写入要按块来擦除,所以擦的多了,寿命也会有影响。
    HHD:Hard Disk Drive 硬盘由盘面,磁头和悬臂组成,每次查询都要把盘面旋转到一个位置,然后悬臂移动到特定磁道的扇区。
    所以简单电路>复杂电路>物理旋转。
     
    CPU的执行速度太快可,为了节省时间(为了性能),丧心病狂的工程师们后来又搞出协处理器这种东西,协助CPU处理一些东西,哈哈,你这么慢让我小弟去干吧。其中最典型的就是DMA芯片。DMA(Direct Memory Access)直接内存访问,用来减少IO等待时间。在进行内存和 I/O 设备的数据传输的时候,我们不再通过 CPU 来控制数据传输,而直接通过 DMA 控制器(DMA Controller,简称 DMAC)。当数据传输很慢的时候,DMAC 可以等数据到齐了,再发送信号,给到 CPU 去处理,而不是让 CPU 在那里傻等。想起来没有,是不是这就是面试官常问你的什么是零拷贝。
     
    看到这里,我来看看出名的开源方案是怎么来提升性能的。
    Disruptor
    RingBufferPad中定义了p1-p7的变量,进行缓冲行填充,从而尽可能的用上CPU高速缓存,防止false share。
    RingBuffer底层是一个固定长度数组,数组的数据在内存里面会存在空间局部性。再有就是数组这种数据结构随机访问是相当快,内存布局也是连续的。
    无锁的RingBuffer。无锁的避免了上下文切换带来的重新读写存储,使用的CPU硬件支持的CAS的方式解决竞争问题。
     
     
     
     
     
     
     
    欢迎关注我的公众号:老张大魔王 >>> 不定时更新哦
  • 相关阅读:
    Java数组
    Java单例设计模式
    Java 14 祭出代码简化大器,Lombok 要被干掉了?
    来,教你去掉了烦人的 !=null
    Java 最坑爹的 10 大功能点!
    高级 Java 必须突破的 10 个知识点!
    不用找了,基于 Redis 的分布式锁实战来了!
    Spring 常犯的十大错误,打死都不要犯!
    JVM 与 Linux 的内存关系详解
    Java 中的 T,E,K,V, 别傻傻分不清楚!
  • 原文地址:https://www.cnblogs.com/dougest/p/14697342.html
Copyright © 2011-2022 走看看