美文网首页
浅谈所谓的并发

浅谈所谓的并发

作者: SAikeilee | 来源:发表于2020-06-18 16:50 被阅读0次

    浅谈并发

    前言:之所以写这篇文章,是想自己总结一下对并发的理解,看看一篇文章能不能把并发说清楚!跟耗子大佬想法一样,还是建议大家多去阅读经典的书籍,尽量少碎片化学习,同时如果有出错之处可以被大家指出。

    想了很久,什么是并发的基础,基于Java来说,或者很多第一印象就是JUC包,CAS,但其实这些只是工具,各种的容器,CAS如果基于Intel也只是CMPXCHG的硬件原语,我其实更偏向于把这些称之为工具-tool,就好像战争中的剑或者刃,如果使用者是一个丝毫不懂武道的文人,是绝对发挥不出刃的威力的。工具永远是工具,而并发编程是一种思想,不限定于语言。

    因此,我认为并发是基于OS的多道程序设计,也就是先有多道程序设计才会有并发一说,在一开始计算机发展的单机单进程计算机是不存在并发一说,并发是用来解决IO和CPU之间的不协调的,或者这样说有点高大上,简单来说就是充分压榨CPU性能的一种思想。

    多任务处理(英语:Computer multitasking)是指计算机同时运行多个程序的能力。多任务的一般方法是运行第一个程序的一段代码,保存工作环境;再运行第二个程序的一段代码,保存环境;……恢复第一个程序的工作环境,执行第一个程序的下一段代码……现代的多任务,每个程序的时间分配相对平均。 -- wikipedia

    而硬件基础就现代来说更多的是DMA、CPU、内存,在这基础就会诞生好几种IO,BIO、NIO、AIO,基于硬件和一堆奇特的数据结构以及算法,更充分地去提高资源利用率,把单机的利用效率提高,例如著名的C10K问题。

    并发与并行

    有很多人会把并发和并行混淆,把并发和并行的概念混在一起说,认为并发=并行。——这种想法其实是错误的。

    首先说一下并行:

    • 并行的基础是多CPU(先不说目前超线程技术——一个CPU同时运行多个线程)

      • 如果没有多CPU,是不存在并行一说的,并行其实就是同一时间节点存在多个执行流(执行流其实就是通常所说的Thread,几个CPU几个执行流)去执行task任务,执行同一部分的代码块。

      • 换言之,可以理解为在某一时间节点(例如今天2.),多个人去做职责相同的事情(类似或者相同的事情,例如几个人都在一起玩一样的游戏)。

    • 并发的基础是多道程序设计

      • 首先明确一点,CPU资源是很宝贵的,而并发的原理就是当CPU需要等待某些资源的时候(IO,设备传输、Linux来说一切皆文件,外接的设备分为流设备、块设备,流设备著名代表就是网络传输,块设备更多的是硬盘),智能的调度程序会先让其他需要CPU资源去运算的线程(执行流)先霸占CPU进行运算,避免因等待外部资源而浪费CPU资源,造成效率下降。

      • 那多道程序设计又是啥?多道程序设计在操作系统的体现其实就是调度程序,如Linux的调度程序把CPU资源抽象成一个个的时间片,每个执行流只占有一部分时间片,拥有当前时间片就是当前使用CPU的执行流。那么,拥有现在或者未来一段时间时间片的线程(执行流)宏观上来说状态都是TASK_RUNNING(Linux线程的状态之一),也就是它都是运行着的(只要底层切换以及运算够快,上层就是无感知的),这也是在单核CPU的机器上,同时运行多个程序的奥秘,你只是被CPU给欺骗了。只要我运行得够快,你就不知道我有没有切换。说句题外话,这也是硬件+程序配合的结果,使用足够多的寄存器空间去辅助保证切换效率,反观也是空间和时间这对矛盾在计算机体系的体现。

    并发问题的根源

    既然并发程序可以大大提高资源利用率,这里请大家记住一句话,Every coin has two sides,没有十全十美的解决方案。

    并发程序同时会带来很多问题,最常见的就是运行效果与预想不一致,死锁等。

    那么为什么会出现这样的问题?下面将从几方面去看一看,变量的可见性、代码的原子性、代码的有序性(禁止重排序)。

    处理器缓存

    严格来说,这个问题只会出现在多CPU的场景下。

    但是目前相信家用机或者服务器基本不会只配备单CPU,即使你跟我说阿里云我还是能租到到单CPU的机器,不知道你有没有认真留意过,基本你能租到的机器一般叫VM(虚拟机),物理机器租赁几乎越来越少了,VM宿主们还是多CPU的机器,只不过调度程序粒度更大而已,数据中心本身就是一个粒度更大的操作系统(可以参考阿里的飞天系统概念)。同时,你租到的还有目前大热的容器(如著名的docker),其实只是虚拟化的程度(或者方式)不一样而已,底层还是多台多CPU的机器,不过在基础上进行了抽象以及隔离。

    现代计算机体系结构下,主存(也就是你所说的内存条)是不会直接与CPU通信的,中间必然隔着多层缓存(L1、L2甚至L3等),为什么要如此设置?详见下面

    image.png image-20200616165256878

    先说CPU的速度,拿2.6Ghz来说,每秒执行2.6*10^9个指令,也就是说一个指令需要0.38ns,而主存需要100ns,大概是263倍,也就是CPU那里过一天,主存大概能过大半年了,两者直接通信,受短板效应影响CPU效率大大降低,作为CPU这你受得了吗?于是乎就有了中间的L1、L2等多级缓存作为缓冲区,作为通信的过渡。

    每个CPU都有自己的缓存,意味着当多个CPU执行同一部分代码的时候,一个变量在多个CPU下就有多个副本了,同时CPU缓存对于不同CPU来说是不可见的,于是乎变量之间就会产生不一致性问题。

    线程切换

    上文说到其实目前大部分操作系统调度算法基于分时技术,多个进程(实际上每个进程至少有一个或多个thread执行流,linux中线程等同于lwp轻量级进程,可以将此处进程理解为日常所谓的线程,下面不再说明)以“时间多路复用”方式运行。CPU时间被分成“片(slice)”,单处理器在任一时刻只能运行一个进程,如果当前运行进程的时间片或时限(quantum)到期,该进程还没有运行完毕,进程切换就可以发生,分时依赖于定时中断,因此对进程透明。 ——《深入理解Linux内核》

    其实上面就意味着多个线程的代码其实需要分配时间片交替执行,当多个线程运行不同的代码确实不存在问题,但是假如一段需要原子执行的代码被并发执行,原子代码还未执行完成发生了线程切换,这时候运行的结果就不一定跟预想一致。

    上文例子可以参考,JDK中的HashMap中进行resize扩容的元素迁移中的循环迁移行为造成环形链表,假如两个线程同时进行resize,在执行语句next = e.next发生线程切换,造成变量错乱从而形成环形链表,具体请查阅资料。

    编译重排序、CPU指令并行、乱序执行(内存操作顺序)

    当编译器编译或者CPU执行代码时候,在前后没有“数据依赖”情况下“聪明”的编译器或者CPU都会对代码顺序进行调整,以提高性能,但调整后代码执行结果需要按照as-if-serail原则,也就是跟单线程执行结果一致。

    乱序执行的作用?为什么要乱序执行?

    ——进行重排序以更好地利用寄存器或者缓存提高性能

    假设指令ABC,AC使用了寄存器X,B没有使用寄存器X,同时三指令之间没有数据依赖,那么编译器或者CPU就会先执行C以免去寄存器的重复装配。

    可是多线程执行结果呢?这个是不被保证的,下面可以看一段双重检查创建单例代码

    <pre spellcheck="false" class="md-fences md-end-block ty-contain-cm modeLoaded" lang="java" cid="n53" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: var(--monospace); font-size: 0.9em; display: block; break-inside: avoid; text-align: left; white-space: normal; background-image: inherit; background-size: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(231, 234, 237); border-top-left-radius: 3px; border-top-right-radius: 3px; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; padding: 8px 4px 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; background-position: inherit inherit; background-repeat: inherit inherit;">public class Singleton {
    static Singleton instance;
    static Singleton getInstance(){
    if (instance == null) {
    synchronized(Singleton.class) {
    if (instance == null)
    instance = new Singleton();
    }
    }
    return instance;
    }
    }</pre>

    <pre spellcheck="false" class="md-fences md-end-block ty-contain-cm modeLoaded" lang="java" cid="n73" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: var(--monospace); font-size: 0.9em; display: block; break-inside: avoid; text-align: left; white-space: normal; background-image: inherit; background-size: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(231, 234, 237); border-top-left-radius: 3px; border-top-right-radius: 3px; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; padding: 8px 4px 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; background-position: inherit inherit; background-repeat: inherit inherit;">public class Singleton {
    static volatile Singleton instance;//也就是此处
    static Singleton getInstance(){
    if (instance == null) {
    synchronized(Singleton.class) {
    if (instance == null)
    instance = new Singleton();
    }
    }
    return instance;
    }
    }</pre>

    并发编程网

    geek的《并发编程实战》

    《Java并发编程技术》

    最后,列一下参考出处,但可能存在遗漏:

    如何理解channel是第一类对象?并发编程时候从channel出发,多利用channel去协调执行流的执行顺序,而不是像Java一样二话不说直接加个锁,让执行流们抢个头破血流的。

    相对于Actor模型,CSP中channel是第一类对象,它不关注发送消息的实体,而关注与发送消息时使用的channel。这就是所谓显式通信。

    不要通过共享内存来通信,而要通过通信来实现内存共享。

    Do not communicate by sharing memory; instead, share memory by communicating.

    不知道大家有没有看过这句话:

    Channel——管道,跟OS中的管道有点类似,Go中的Channel其实出自CSP通信模型中的Channel。

    此处不打算对GoRoutine深入解析,因为与本文内容无关,而GoRoutine原理也只是在Application用户态利用协程的概念自己实现调度,减少上下文切换,提升运行速度,同时加WorkStealing平衡多Core效率。不知道大家有没有发现,当底层复杂的逻辑满足不了需求时候,或许换种思维方式,把复杂逻辑抽离到上层,保持底层简单逻辑,只要上层实现更轻量,效率就会大大提升。这同时也是QUIC的设计美学,也是Google常用的套路。

    众所周知,Go的两大语言核心——GoRoutine和Channel

    Go的显式通信解决方案

    • 有人说ReentrantLock的性能比监视器锁好,其实JDK1.6后引入了偏向锁、轻量级锁后性能基本持平

    • ReentrantLock多了一个Condition的概念,其实就是上文所说的条件变量。而Synchronized是不存在这个概念的。

    说到这里,还是有必要科普下Java中监视器锁和DungLea大牛JUC包中的AQS设计出来的锁(例如ReentrantLock)的区别:

    image-20200618110438744

    管程还引入了条件变量的概念,每个条件变量都有一个等待队列,当条件变量不为真,线程先进入条件变量的等待队列,而不是总的入口等待队列

    如何解决同步?管程模型中,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。

    image-20200618110041555

    如何解决互斥?现在来看一个队列queue的例子,支持入队enq()和出队deq(),如果线程A和线程B想访问共享变量queue,只能通过封装好的而且管程提供具有互斥的enq()和deq()操作。

    管程解决并发问题核心就是:管理共享变量以及对共享变量的操作过程,让他们支持并发

    管程的历史上出现三种模型,Hasen、Hoare和MESA模型,而现在广泛应用的是MESA模型,Java也是采用的MESA模型。

    管程 —— Java的隐式通信解决放哪

    最后,介绍一个通用的并发编程模型,相信也是相对更著名或者最广为人知的,它是属于隐式通信、显式同步的一种。

    1. 显式通信,隐式同步 —— 以消息传递先沟通好顺序以达到同步的效果

    2. 隐式通信,显式同步 —— 争抢以获取顺序达到同步,其实是隐式的通信

    上面所有的通信方式其实可以分为两类

    • 锁机制

      • 互斥锁

      • 读写锁

      • 条件变量

    • 信号signal

    • 信号量semaphore

    线程通信:

    • 管道(也就是命令常用的|),命名管道

    • 消息队列

    • 共享内存

    • 信号

    • 信号量

    • 套接字Socket(不同机器进程通信)

    进程通信:

    先回顾一下操作系统的知识

    为什么这样说呢?这里可以讲个大白话,当你家门口只够一个人通过,但是想进入的人却有好几个,几个人如果不进行沟通(通信),一直堵在门口,是不是大家都进不去?

    其实两大核心问题也就是一个问题,归根到底就是进程、线程的通信、协调(同步)问题

    • 互斥,同一时刻保证互斥资源的单一准入性

    • 同步,线程如何通信、同步

    并发领域其实有两大核心问题:

    上层如何处理

    此处不对操作系统内核中的内存屏障深入解析,如果有必要请移步到linux内核中内存屏障

    1. 禁止编译器以及CPU对设定内存屏障对共享变量操作指令进行重排序

    2. 保证缓存的可见性(把缓存中变量强行flush回主存),同时缓存一致性协议MESI(intel)会保证数据最终一致

    首先说一下内存屏障存在的作用:

    内存屏障

    1. 语言层面——循环+CAS操作

      • 循环+CAS操作(基于硬件原语CMPXCHG,下面会讲到)去保证执行具有原子性,下面给出一段Java代码进行计数器实现

      • 这里需要特别说明一下,循环+CAS操作是语言层面保证原子性的手段,但不限于语言(多说一句,很多人太拘泥于语言了,语言只是思想的表现形式,举个例子,你敢说Vue的尤大写Java会比你差)

        <pre spellcheck="false" class="md-fences md-end-block ty-contain-cm modeLoaded" lang="java" cid="n85" mdtype="fences" style="box-sizing: border-box; overflow: visible; font-family: var(--monospace); font-size: 0.9em; display: block; break-inside: avoid; text-align: left; white-space: normal; background-image: inherit; background-size: inherit; background-attachment: inherit; background-origin: inherit; background-clip: inherit; background-color: rgb(248, 248, 248); position: relative !important; border: 1px solid rgb(231, 234, 237); border-top-left-radius: 3px; border-top-right-radius: 3px; border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; padding: 8px 4px 6px; margin-bottom: 15px; margin-top: 15px; width: inherit; background-position: inherit inherit; background-repeat: inherit inherit;">private AtomicInteger atomicI = new AtomicInteger(0);
        private void safeCount() {
        for(;;) {
        int i = atomicI.get();
        boolean sunc = atomicI.compareAndSet(i, ++i);
        if(sunc) {
        break;
        }
        }
        }</pre>

      • 为什么这里没有把各种锁进行解析,而是单独拉出循环+CAS操作来进行讨论?

        • 其实很多语言的锁机制实现的更粗粒度的原子操作的基础就是循环+CAS。

        • 锁,一般解释为把互斥资源保护起来,开放唯一的入口,通过额外的一个事物对象(锁)去保证入口的单一准入性?

        • 那么就会诞生一个问题——如何去保证入口的单一准入性,那就是同一时刻保证只能有一个线程霸占互斥的资源?——循环+CAS

        • Java的synchronized原语中的偏向锁、轻量级锁、重量级锁都是利用循环+CAS去获取锁(争夺monitor对象,CAS更换对象头的markword)

    2. 硬件层面

      看到这里不知道你有没有刨根问底的精神,那么CAS的底层总得要保证CAS硬件原语的原子性操作吧,上层可以保证原子性,底层如果不能保证原子性那上层的功夫岂不是白搭?

      CPU保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理读取一个字节时候,其他处理不能访问这个字节的访问地址,这就是CPU自动保证基本的内存操作原子性。而Pentium6和最新的CPU能自动保证单处理器对同一缓存行进行16/32/64位的操作是原子的。

      而这里主要讲述多CPU之间更复杂的内存原子操作(跨多个缓存行、跨总线宽度、跨页表),例如i++,CPU其实提供了两种解决方案:

      • 锁定总线

        • CPU是多个的,但是主存只有一个,一对多的关系,之间通信肯定需要一个东西去链接。这个东西就是通常所说的——总线,总线会去协调CPU和主存的通信,不能在同一时刻多个CPU存取同一个数据(一个字节),这就是总线的仲裁,把一些必要的并行操作给串行(实际上设计复杂,为了提升效率,还有会有两个著名的架构SMP和NUMA,这里暂时不讨论NUMA,NUMA其实就是类似数据库为了提高性能进行分库分表嘛),这就是上面说的CPU自动保证基本的内存操作原子性。

        • 那么既然总线负责多CPU与主存通信,那么我们把总线给锁掉是不是就能保证读改写共享变量的原子性了。这就是所谓的总线锁。使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器请求将被阻塞,处理器可以独占共享内存。

      • 锁定缓存

        锁定总线意味着所有CPU在锁定时间内不能对内存进行读写操作,那么性能开销是不是有点大?那么我们可不可以换个思路,把锁的粒度变小(其实ConcurrentHashMap的锁也由一开始的Segment段锁到现在的桶锁,减小锁的粒度,提高效率)。

        • 频繁使用的内存会缓存在处理器L1、L2、L3等高速缓存上,其实原子操作可以直接在处理器内部缓存进行,不需要总线那么重粒度那么粗的锁。在Pentium6和现在的处理器可以使用“缓存锁定”保证原子性。

        • “缓存锁定”其实是指内存区域如果被缓存在cpu缓存行,CPU执行锁回写主存时候,不声言LOCK#总线锁,而是修改内部的内存地址,而本身CPU的缓存一致性机制(CPU缓存会嗅探系统内存和其他CPU缓存,保持数据一致,如果嗅探到有CPU写内存地址,会使缓存行无效)会阻止同时修改由两个以上CPU缓存的内存区域数据,当其他处理回写已被锁定的缓存行数据,会使缓存行无效。

    下面将从两方面去讲述原子性的保证,这里的原子性希望大家与上层代码的原子性分开,这里更多地是保证基本指令操作的原子性,也就是底层硬件对原子性的担保,而上层的各种锁应用(基于管程)也是得以下层硬件原子性保障才可以实现原子性的操作。因此,注意区分两个原子性,一个是偏向于代码业务逻辑事务执行的原子性,例如数据库的事务提交,一个是底层指令执行的保障以及语言层面锁的设计基础。

    保证操作原子性

    底层如何去保障

    所以一般都会给instance 加上volatile修饰,volatile在开始老版本JDK作用仅仅是处理了处理器缓存,后来发现volatile功能太轻了,在新规范JSR-133给加上禁止重排序语义。

    单线程执行问题也不大,但是多线程呢?假设线程A完成2操作后发生线程切换,这时候线程B来了,判断第一个条件instance != null,直接返回空的对象,这样问题就大了去了。

    1. 分配一块内存 M;

    2. 将 M 的地址赋值给 instance 变量;

    3. 最后在内存 M 上初始化 Singleton 对象。

    但实际优化后执行顺序是:

    1. 分配内存M

    2. 在内存中实例化对象Singleton

    3. 把M的地址赋值给变量instance

    我们再来看一下new操作,jvm规范中:

    假设两个线程同时去取Singleton的实例并发现instance为null所以进行实例创建,两个线程会对锁进行竞争,只有一个能获得锁,假设线程A拿到了想要的锁,开始创建实例,然后进行解锁操作,线程B接着取得锁发现instance != null(第二处),最后两个线程都取到单一的实例对象。看上去很完美。

    相关文章

      网友评论

          本文标题:浅谈所谓的并发

          本文链接:https://www.haomeiwen.com/subject/vewuxktx.html