在之前对于并发编程这个模块的内容已经阐述了很多篇章了,而本章的目的则是为了对前述的内容做个补充,重点会围绕着锁的状态与并行处理的方式进行展开论述。
一、进程、线程、纤程、协程、管程概念理解
在现在你可能会经常看到进程、线程、纤程、协程、管程、微线程、绿色线程....一大堆xx程的概念,其实这些本质上都是为了满足并行执行、异步执行而出现的一些概念。
因为随着如今的科技越来越发达,计算机目前多以多核机器为主,所以之前单线程的串行执行方式注定无法%程度发挥出硬件该有的性能。同时,为了满足互联网时代中日益渐增的用户基数,我们开发的程序往往需要更优异的性能,更快的执行效率,更大的吞吐量才可。
为了方便理解,我们可以先把操作系统抽象为了一个帝国。并且为了方便理解这些概念,下面也不会太过官方死板的做概念介绍。
1.1、进程(Progress)
进程也就是平时所说的程序,比如在操作系统上运行一个谷歌浏览器,那么就代表着谷歌浏览器就是一个进程。进程是操作系统中能够独立运行的个体,并且也作为资源分配的基本单位,由指令、数据、堆栈等结构组成。安装好一个程序之后,在程序未曾运行之前也仅是一些文件存储在磁盘上,当启动程序时会向操作系统申请一定的资源,如CPU、存储空间和I/O设备等,OS为其分配资源后,会真正的出现在内存中成为一个抽象的概念:进程。
其实操作系统这个帝国之上,在运行时往往有着很多个进程存在,你可以把这些进程理解成一个个的工厂,根据各自的代码实现各司其职。如通过Java编写一个程序后运行在操作系统上,那么就相当于在OS帝国上注册了一家工厂,该工厂具体的工作则由Java代码的业务属性决定。
随着计算机硬件技术的不断进步,慢慢的CPU架构更多都是以多核的身份出现在市面上,所以对于程序而言,CPU利用率的要求会更高。但是进程的调度开销是比较大的,并且在并发中切换过程效率也很低,所以为了更高效的调度和满足日益复杂的程序需求,最终发明了线程。
1.2、线程(Thread)
在操作系统早期的时候其实并没有线程的概念,到了后来为了满足并发处理才推出的一种方案,线程作为程序执行的最小单位,一个进程中可以拥有多条线程,所有线程可以共享进程的内存区域,线程通常在运行时也需要一组寄存器、内存、栈等资源的支撑。现如今,程序之所以可以运行起来的根本原因就是因为内部一条条的线程在不断的执行对应的代码逻辑。
假设进程现在是OS帝国中的一个工厂,那么线程就是工厂中一个个工位上的工人。工厂之所以能够运转的根本原因就在于:内部每个工位上的工人都各司其职的处理自己分配到的工作。
多核CPU中,一个核心往往在同一时刻只能支持一个内核线程的运行,所以如果你的机器为八核CPU,那么理论上代表着同一时刻最多支持八条内核线程同时并发执行。当然,现在也采用了超线程的技术,把一个物理芯片模拟成两个逻辑处理核心,让单个处理器都能使用线程级并行计算,进而兼容多线程操作系统和软件,减少了CPU的闲置时间,提高的CPU的运行效率。比如四核八线程的CPU,在同一时刻也支持最大八条线程并发执行。
在OS中,程序一般不会去直接申请内核线程进行操作,而是去使用内核线程提供的一种名为LWP的轻量级进程(LightweightProcess)进行操作,这个也就是平时所谓的线程,也被成为用户级线程。
1.2.1、线程模型
在如今的操作系统中,用户线程与内核线程主要存在三种模型:一对一模型、多对一模型以及多对多模型。
一对一模型
一对一模型是指一条用户线程对应着内核中的一条线程,而Java中采用的就是这种模型,如下:
Java线程一对一模型一对一模型是真正意义上的并行执行,因为这种模型下,创建一条Java的Thread线程是真正的在内核中创建并映射了一条内核线程的,执行过程中,一条线程不会因为另外一条线程的原因而发生阻塞等情况。不过因为是直接映射内核线程的模式,所以数量会存在上限。并且同一个核心中,多条线程的执行需要频繁的发生上下文切换以及内核态与用户态之间的切换,所以如果线程数量过多,切换过于频繁会导致线程执行效率下降。
多对一模型
顾名思义,多对一模型是指多条用户线程映射同一条内核线程的情况,对于用户线程而言,它们的执行都由用户态的代码完成切换。
线程多对一模型
这种模式优点很明显,一方面可以节省内核态到用户态切换的开销,第二方面线程的数量不会受到内核线程的限制。但是缺点也很明显,因为线程切换的工作是由用户态的代码完成的,所以如果当一条线程发生阻塞时,与该内核线程对应的其他用户线程也会一起陷入阻塞。
多对多模型
多对多模型就可以避免上面一对一和多对一模型带来的弊端,也就是多条用户线程映射多条内核线程,这样即可以避免一对一模型的切换效率问题和数量限制问题,也可以避免多对一的阻塞问题,如下:
线程多对多模型
1.3、协程(Coroutines)
协程是一种基于线程之上,但又比线程更加轻量级的存在,这种由程序管理的轻量级线程也被称为用户空间线程,对于内核而言是不可见的。正如同进程中存在多条线程一样,线程中也可以存在多个协程。
协程在运行时也有自己的寄存器、上下文和栈,协程的调度完全由用户控制,协程调度切换时,会将寄存器上下文和栈保存到分配的私有内存区域中,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。
前面把线程比作了工厂工位上的固定工人,那么协程更多的就可以理解为:工厂中固定工位上的不固定工人。一个固定工位上允许有多个不同的工人,当轮到某个工人工作时,就把上一个工人的换下来,把这个要工作的工人换上去。或者当前工人在工作时要上厕所,那么就会先把当前工作的工人撤下去,换另一个工人上来,等这个工人上完厕所回来了,会再恢复它的工作。协程有些类似于线程的多对一模型。
1.4、纤程(Fiber)
纤程(Fiber)是Microsoft组织为了帮助企业程序的更好移植到Windows系统,而在操做系统中增加的一个概念,由操作系统内核根据对应的调度算法进行控制,也是一种轻量级的线程。
纤程和协程的概念一致,都是线程的多对一模型,但有些地方会区分开来,但从协程的本质概念上来谈:纤程、绿色线程、微线程这些概念都属于协程的范围。纤程和协程的区别在于:
纤程是OS级别的实现,而协程是语言级别的实现,纤程被OS内核控制,协程对于内核而言不可见。
1.5、管程(Monitors)
管程(Monitors)提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。
1.6、XX程小结
先如今各种程出现的根本原因是由于多核机器的流行,所以程序实现中也需要最大程度上考虑并行、并发、异步执行,在最大程序上去将硬件机器应有的性能发挥出来。以Java而言,本身多线程的方式是已经可以满足这些需求的,但Java中的线程资源比较昂贵,是直接与内核线程映射的,所以在上下文切换、内核态和用户态转换上都需要浪费很多的资源开销,同时也受到操作系统的限制,允许一个Java程序中创建的纤程数量是有限的。所以对于这种一对一的线程模型有些无法满足需求了,最终才出现了各种程的概念。
从实现级别上来看:进程、线程、纤程是OS级别的实现,而绿色线程、协程这些则是语言级别上的实现。从调度方式上而言:进程、线程、绿色线程属于抢占式执行,而纤程、协程则属于合作式调度。从包含关系上来说:一个OS中可以有多个进程,一个进程中可以有多条线程,而一条线程中则可以有多个协程、纤程、微线程等。
二、死锁、活锁与锁饥饿概念理解
在多核时代中,多线程、多进程的程序虽然大大提高了系统资源的利用率以及系统的吞吐量,但并发执行也带来了新的一系列问题:死锁、活锁与锁饥饿。
死锁、活锁与锁饥饿都是程序运行过程中的一种状态,而其中死锁与活锁状态在进程中也是可能存在这种情况的,接下来先简单阐述一下这些状态的含义。
2.1、何谓死锁(DeadLock)?
死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞争而造成相互等待的现象,若无外力作用则不会解除等待状态,它们之间的执行都将无法继续下去。举个栗子:
某一天竹子和熊猫在森林里捡到一把玩具弓箭,竹子和熊猫都想玩,原本说好一人玩一次的来,但是后面竹子耍赖,想再玩一次,所以就把弓一直拿在自己手上,而本应该轮到熊猫玩的,所以熊猫跑去捡起了竹子前面刚刚射出去的箭,然后跑回来之后便发生了如下状况:熊猫道:竹子,快把你手里的弓给我,该轮到我玩了....竹子说:不,你先把你手里的箭给我,我再玩一次就给你....最终导致熊猫等着竹子的弓,竹子等着熊猫的箭,双方都不肯退步,结果陷入僵局场面....。相信这个场景各位小伙伴多多少少都在自己小时候发生过,这个情况在程序中发生时就被称为死锁状况,如果出现后则必须外力介入,然后破坏掉死锁状态后推进程序继续执行。如上述的案例中,此时就必须第三者介入,把“违反约定”的竹子手中的弓拿过去给熊猫......
2.2、活锁(LiveLock)是什么?
活锁是指正在执行的线程或进程没有发生阻塞,但由于某些条件没有满足,导致反复重试-失败-重试-失败的过程。与死锁最大的区别在于:活锁状态的线程或进程是一直处于运行状态的,在失败中不断重试,重试中不断失败,一直处于所谓的“活”态,不会停止。而发生死锁的线程则是相互等待,双方之间的状态是不会发生改变的,处于所谓的“死”态。
死锁没有外力介入是无法自行解除的,而活锁状态有一定几率自行解除。
其实本质上来说,活锁状态就是指两个线程虽然在反复的执行,但是却没有任何效率。正如生活中那句名言:“虽然你看起来很努力,但结果却没有因为你的努力而发生任何改变”,也是所谓的做无用功。同样举个生活中的栗子理解:
生活中大家也都遇见过的一个事情:在一条走廊上两个人低头玩手机往前走,突然双方一起抬头都发现面对面快撞上了,然后双方同时往左侧跨了一步让开路,然后两个人都发现对方也到左边来了,两个人想着再回到右边去给对方让路,然后同时又向右边跨了一步,然后不断重复这个过程,再同时左边跨、右边跨、左边跨........这个栗子中,虽然双方都在不断的移动,但是做的却是无用功,如果一直这样重复下去,可能从太阳高照到满天繁星的时候,双方还是没有走出这个困境。这个状态又该如何打破呢?主要有两种方案,一种是单方的,其中有一方打破“同步”的频率。另一种方案则是双方之间先沟通好,制定好约定之后再让路,比如其中一方开口说:你等会儿走我这边,我往那边走。而另一方则说:好。
在程序中,如果两条线程发生了某些条件的碰撞后重新执行,那么如果再次尝试后依然发生了碰撞,长此下去就有可能发生如上案例中的情况,这种情况就被称为协同导致的活锁。
比如同时往某处位置写入数据,但同时只能允许一条线程写入数据,所以在写入之前会检测是否有其他线程存在,如果有则放弃本次写入,过一段时间之后再重试。而此时正好有两条线程同时写入又相互检测到了对方,然后都放弃了写入,而重试的时间间隔都为1s,结果1s后这两条线程又碰头了,然后来回重复这个过程.....
当然,在程序中除开上述这种多线程之间协调导致的活锁情况外,单线程也会导致活锁产生,比如远程RPC调用中就经常出现,A调用B的RPC接口,需要B的数据返回,结果B所在的机器网络出问题了,A就不断的重试,最终导致反复调用,不断失败。
活锁解决方案
活锁状态是有可能自行解除的,但时间会久一点,不过在编写程序时,我们可以尽量避免活锁情况发生,一方面可以在重试次数上加上限制,第二个方面也可以把重试的间隔时间加点随机数,第三个则是前面所说的,多线程协同式工作时则可以先在全局内约定好重试机制,尽量避免线程冲突发生。
2.3、啥又叫锁饥饿(LockStarving)?
锁饥饿是指一条长时间等待的线程无法获取到锁资源或执行所需的资源,而后面来的新线程反而“插队”先获取了资源执行,最终导致这条长时间等待的线程出现饥饿。
ReetrantLock的非公平锁就有可能导致线程饥饿的情况出现,因为线程到来的先后顺序无法决定锁的获取,可能第二条到来的线程在第十八条线程获取锁成功后,它也不一定能够成功获取锁。
锁饥饿这种问题可以采用公平锁的方式解决,这样可以确保线程获取锁的顺序是按照请求锁的先后顺序进行的。但实际开发过程中,从性能角度而言,非公平锁的性能会远远超出公平锁,非公平锁的吞吐量会比公平锁更高。
当然,如果你使用了多线程编程,但是在分配纤程组时没有合理的设置线程优先级,导致高优先级的线程一直吞噬低优先级的资源,导致低优先级的线程一直无法获取到资源执行,最终也会使低优先级的线程产生饥饿。
三、死锁产生原因/如何避免死锁、排查死锁详解
关于锁饥饿和活锁前面阐述的内容便已足够了,不过对于死锁这块的内容,无论在面试过程中,还是在实际开发场景下都比较常见,所以再单独拿出来分析一个段落。
在前面提及过死锁的概念:死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞争而造成相互等待的现象。而此时可以进一步拆解这句话,可以得出死锁如下结论:
①参与的执行实体(线程或进程)必须要为两个或两个以上。
②参与的执行实体都需要等待资源方可执行。
③参与的执行实体都均已占据对方等待的资源。
④死锁情况下会占用大量资源而不工作,如果发生大面积的死锁情况可能会导致程序或系统崩溃。
3.1、死锁产生的四个必要条件
而诱发死锁的根本从前面的分析中可以得知:是因为竞争资源引起的。当然,产生死锁存在四个必要条件,如下:
①互斥条件:指分配到的资源具备排他使用性,即在一段时间内某资源只能由一个执行实体使用。如果此时还有其它执行实体请求资源,则请求者只能等待,直至占有资源的执行实体使用完成后释放才行。
②不可剥夺条件:指执行实体已持有的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
③请求与保持条件:指运行过程中,执行实体已经获取了至少一个资源,但又提出了新的资源请求,而该资源已被其它实体占用,此时当前请求资源的实体阻塞,但在阻塞时却不释放自己已获得的其它资源,一直保持着对其他资源的占用。
④环状等待条件:指在发生死锁时,必然存在一个执行实体的资源环形链。比如:线程T1等待T2占用的一个资源,线程在等待线程T3占用的一个资源,而线程则在等待占用的一个资源,最终形成了一个环状的资源等待链。
以上是死锁发生的四个必要条件,只要系统或程序内发生死锁情况,那么这四个条件必然成立,只要上述中任意一条不符合,那么就不会发生死锁。
3.2、系统资源的分类
操作系统以及硬件平台上存在各种各样不同的资源,而资源的种类大体可以分为永久性资源、临时性资源、可抢占式资源以及不可抢占式资源。
3.2.1、永久性资源
永久性资源也被称为可重复性资源,即代表着一个资源可以被执行实体(线程/进程)重复性使用,它们不会因为执行实体的生命周期改变而发生变化。比如所有的硬件资源就是典型的永久性资源,这些资源的数量是固定的,执行实体在运行时即不能创建,也不能销毁,要使用这些资源时必须要按照请求资源、使用资源、释放资源这样的顺序操作。
3.2.2、临时性资源
临时性资源也被称为消耗性资源,这些资源是由执行实体在运行过程中动态的创建和销毁的,如硬件中断信号、缓冲区内的消息、队列中的任务等,这些都属于临时性资源,通常是由一个执行实体创建出来之后,被另外的执行实体处理后销毁。比如典型的一些消息中间件的使用,也就是生产者-消费者模型。
3.2.3、可抢占式资源
可抢占式资源也被称为可剥夺性资源,是指一个执行实体在获取到某个资源之后,该资源是有可能被其他实体或系统剥夺走的。可剥夺性资源在程序中也比较常见,如:
进程级别:CPU、主内存等资源都属于可剥夺性资源,系统将这些资源分配给一个进程之后,系统是可以将这些资源剥夺后转交给其他进程使用的。
线程级别:比如Java中的ForkJoin框架中的任务,分配给一个线程的任务是有可能被其他线程窃取的。
可剥夺性资源还有很多,诸如上述过程中的一些类似的资源都可以被称为可剥夺性资源。
3.2.4、不可抢占式资源
同样,不可抢占式资源也被称为不可剥夺性资源,不可剥夺性是指把一个执行实体获取到资源之后,系统或程序不能强行收回,只能在实体使用完后自行释放。如:
进程级别:磁带机、打印机等资源,分配给进程之后只能由进程使用完后自行释放。
线程级别:锁资源就是典型的线程级别的不可剥夺性资源,当一条线程获取到锁资源后,其他线程不能剥夺该资源,只能由获取到锁的线程自行释放。
3.2.5、资源引发的死锁问题
前面曾提到过一句,死锁情况的发生必然是因为资源问题引起的,而在上述资源中,竞争临时性资源和不可剥夺性资源都可能引起死锁发生,也包括如果资源请求顺序不当也会诱发死锁问题,如两条并发线程同时执行,持有资源M1,线程持有M2,而又在请求,又在请求,两者都会因为所需资源被占用而阻塞,最终造成死锁。
当然,也并非只有资源抢占会导致死锁出现,有时候没有发生资源抢占,就单纯的资源等待也会造成死锁场面,如:服务A在等待服务B的信号,而服务恰巧也在等待服务的信号,结果也会导致双方之间无法继续向前推进执行。不过从这里可以看出:A和B不是因为竞争同一资源,而是在等待对方的资源导致死锁。
对于这个例子有人可能会疑惑,这不是活锁情况吗?答案并非如此,因为活锁情况讲究的是一个“活”字,而上述这个案例,双方之间都是处于相互等待的“死”态。
3.3、死锁案例分析
上述对于死锁的理论进行了大概阐述,下来来个简单例子感受一下死锁情景:
publicclassDeadLockimplementsRunnable{publicbooleanflag=true;//静态成员属于class,是所有实例对象可共享的privatestaticObjecto1=newObject(),o2=newObject();publicDeadLock(booleanflag){this.flag=flag;}
Overridepublicvoidrun(){if(flag){synchronized(o1){System.out.println("线程:"+Thread.currentThread().getName()+"持有o1....");try{Thread.sleep();}catch(Exceptione){e.printStackTrace();}System.out.println(+Thread.currentThread().getName()+"等待o2....");synchronized(o2){System.out.println("true");}}}if(!flag){synchronized(o2){System.out.println(+Thread.currentThread().getName()+"持有o2....");try{Thread.sleep();}catch(Exceptione){e.printStackTrace();}System.out.println(+Thread.currentThread().getName()+"等待o1....");synchronized(o1){System.out.println("false");}}}}publicstaticvoidmain(String[]args){Threadt1=newThread(newDeadLock(true),"T1");Threadt2=newThread(newDeadLock(false),"T2");//因为线程调度是按时间片切换决定的,//所以先执行哪个线程是不确定的,也就代表着://后面的t1.run()可能在t2.run()之前运行t1.start();t2.start();}}//运行结果如下:/*线程:T1持有o1....线程:T2持有o2....线程:T2等待o1....线程:T1等待o2....*/如上是一个简单的死锁案例,在该代码中:
当flag==true时,先获取对象o1的锁,获取成功之后休眠ms,而发生这个动作的必然是t1,因为在main方法中,我们将任务的flag显式的置为了true。
而当线程睡眠时,t2线程启动,此时任务的flag=false,所以会去获取对象o2的锁资源,然后获取成功之后休眠。
此时线程睡眠时间结束,线程被唤醒后会继续往下执行,然后需要获取对象的锁资源,但此时已经被持有,此时会阻塞等待。
而此刻线程也从睡眠中被唤醒会继续往下执行,然后需要获取对象的锁资源,但此时已经被持有,此时会阻塞等待。
最终导致线程t1、t2相互等待对象的资源,都需要获取对方持有的资源之后才可继续往下执行,最终导致死锁产生。
3.4、死锁处理
对于死锁的情况一旦出现都是比较麻烦的,但这也是设计并发程序避免不了的问题,当你想要通过多线程编程技术提升你的程序处理速度和整体吞吐量时,对于死锁的问题也是必须要考虑的一项,而处理死锁问题总的归纳来说可以从如下四个角度出发:
①预防死锁:通过代码设计或更改配置来破坏掉死锁产生的四个条件其中之一,以此达到预防死锁的目的。
②避免死锁:在资源分配的过程中,尽量保证资源请求的顺序性,防止推进顺序不当引起死锁问题产生。
③检测死锁:允许系统在运行过程中发生死锁情况,但可设置检测机制及时检测死锁的发生,并采取适当措施加以清除。
④解除死锁:当检测出死锁后,便采取适当措施将进程从死锁状态中解脱出来。
3.4.1、预防死锁
前面提过,预防死锁的手段是通过破坏死锁产生的四个必要条件中的一个或多个,以此达到预防死锁的目的。
破坏“互斥”条件
在程序中将所有“互斥”的逻辑移除,如果一个资源不能被独占使用时,那么死锁情况必然不会发生。但一般来说在所列的四个条件中,“互斥”条件是不能破坏的,因为程序设计中必须要考虑线程安全问题,所以“互斥”条件是必需的。因此,在死锁预防里主要是破坏其他几个必要条件,不会去破坏“互斥”条件。
破坏“不可剥夺”条件
破坏“不可剥夺性”条件的含义是指取消资源独占性,一个执行实体获取到的资源可以被别的实体或系统强制剥夺,在程序中可以这样设计:
①如果占用资源的实体下一步资源请求失败,那么则释放掉之前获取到的所有资源,后续再重新请求这些资源和另外的资源(和分布式事务的概念有些类似)。
②如果一个实体需要请求的资源已经被另一个实体持有,那么则由程序或系统将该资源释放,然后让给当前实体获取执行。这种方式在Java中也有实现,就是设置线程的优先级,优先级高的线程是可以抢占优先级低的资源先执行的。
破坏“请求与保持”条件
破坏“请求与保持”条件的意思是:系统或程序中不允许出现一个执行实体在获取到资源的情况下再去申请其他资源,主要有两种方案:
①一次性分配方案:对于执行实体所需的资源,系统或程序要么一次性全部给它,要么什么都不给。
②要求每个执行实体提出新的资源申请前,释放它所占有的资源。
但总归来说,这种情况也比较难满足,因为程序中难免会有些情况下要占用多个资源后才能一起操作,就比如最简单的数据库写入操作,在Java程序这边需要先获取到锁资源后才能通过连接对象进行操作,但获取到的连接对象在往DB表中写入数据的时候还需要再和DB中其他连接一起竞争DB那边的锁资源方可真正写表。
破坏“环状等待链”条件
破坏“环状等待链”条件实际上就是要求控制资源的请求顺序性,防止请求顺序不当导致的环状等待链闭环出现。
这个点主要是在编码的时候要注意,对于一些锁资源的获取、连接池、RPC调用、MQ消费等逻辑,尽量保证资源请求顺序合理,避免由于顺序性不当引起死锁问题出现。
预防死锁小结
因为预防死锁的策略需要实现会太过苛刻,所以如果真正的在程序设计时考虑这些方面,可能会导致系统资源利用率下降,也可能会导致系统/程序整体吞吐量降低。
总的来说,预防死锁只需要在系统设计、进程调度、线程调度、业务编码等方面刻意