当前位置: 首页 > 科技观察

面试官:说说互斥锁、自旋锁、读写锁、悲观锁、乐观锁的应用场景

时间:2023-03-20 22:26:10 科技观察

转载本文请联系小林编码公众号。前言日常生活中使用的锁都比较简单粗暴。上锁的基本目的是防止外人进入、电动车被盗等。但生活并非没有错误。比如锁着的电动车在“广西-偷格瓦拉”面前毫无用处。”否则,他这辈子怎么可能兼职呢?一个伟大的人,一定有一个伟大的地方。在编程世界里,“锁”有很多种,加锁的开销和应用场景每个锁也可能不一样,如何用好锁也是程序员的基本素养之一,在高并发场景下,如果选择合适的锁,系统的性能会得到很大的提升,反之性能会很差因此需要了解各种锁的开销和应用场景,接下来说说这几种常见的锁:多线程文本访问共享资源时,无法解决资源竞争导致的数据混乱问题避免了。因此,为了解决这个问题,我们通常在访问共享资源之前加锁rite锁、乐观锁等,不同类型的锁自然适用于不同的场景。如果选错了锁,在一些高并发的场景下,可能会降低系统的性能,用户体验会很差。因此,为了选择合适的锁,我们不仅需要清楚知道加锁的代价是多少,还需要分析业务场景下访问共享资源的方式,进而考虑并发访问的冲突概率共享资源。只有对症下药,才能降低锁对高并发性能的影响。然后,针对不同的应用场景,说说“互斥锁、自旋锁、读写锁、乐观锁、悲观锁”的选择和使用。互斥锁和自旋锁:谁更轻松?最下面的两个是“互斥锁和自旋锁”。许多高级锁都是基于它们实现的。你可以把它们看成是各种锁基础,所以我们一定要清楚它们之间的区别和应用。加锁的目的是保证共享资源在任何时候都只能被一个线程访问,这样就可以避免多线程带来的共享数据混乱的问题。当一个线程已经锁定时,其他线程将无法锁定。互斥锁和自旋锁对于加锁失败的处理方式不同:互斥锁失败后,线程会释放CPU,给其他线程;自旋锁加锁失败后,线程会忙等待,直到获得锁;互斥量是一种“独占锁”,例如当线程A加锁成功后,互斥量一直被线程A独占,只要线程A不释放手上的锁,线程B就无法释放锁,所以它会将CPU释放给其他线程。既然线程B释放了CPU,那么线程B去加锁的代码自然会阻塞。由于互斥锁失败而阻塞的现象是由操作系统内核实现的。当锁失效时,内核会让线程进入“休眠”状态。锁释放后,内核会在适当的时候唤醒线程。当线程成功获取到锁后,就可以继续执行了。如下图所示:因此,当互斥锁失效后,会从用户态掉到内核态,让内核帮我们切换线程。虽然简化了使用锁的难度,但是有一定的性能开销成本。那么这个间接费用是多少?会有两种线程上下文切换成本:当线程锁失效时,内核会将线程的状态从“运行”状态设置为“睡眠”状态,然后将CPU切换到其他线程运行;然后,当锁被释放时,之前“休眠”的线程就会变成“就绪”,然后内核会在合适的时候将CPU切换到该线程。什么是线程上下文切换?当两个线程属于同一个进程时,由于共享虚拟内存,切换时虚拟内存等资源保持不变,只需要切换线程的私有数据、寄存器等共享数据即可。上下切换的耗时有大佬统计过,大概是几十纳秒到几微秒。如果你锁定的代码执行时间比较短,上下文切换的时间可能比你锁定的代码执行时间长。它会更长。因此,如果可以确定被加锁代码的执行时间很短,就不要使用互斥量,而应该使用自旋锁,否则就使用互斥量。自旋锁是CPU提供的CAS功能(CompareAndSwap)。加锁和解锁操作都是在“用户态”完成的,不会主动产生线程上下文切换,所以会比互斥锁更快。也更小。一般加锁过程包括两步:第一步是检查锁的状态,如果锁是空闲的,则执行第二步;第二步,设置锁由当前线程持有;CAS函数将这两个步骤合并为一条硬件级指令,形成一条原子指令,保证了这两个步骤密不可分。要么两个步骤同时执行,要么两个步骤都不执行。在使用自旋锁时,当多个线程竞争一个锁时,没有加锁的线程会“忙着等待”,直到获得锁。这里的“忙等待”可以通过while循环等待来实现,但是最好使用CPU提供的PAUSE指令来实现“忙等待”,因为这样可以减少循环等待时的功耗。自旋锁是最简单的一种锁,它会一直旋转并使用CPU周期,直到锁可用为止。需要注意的是,在单核CPU上,需要抢占式调度器(即一个线程不断被时钟打断去运行其他线程)。否则,自旋锁在单个CPU上是无用的,因为自旋线程永远不会放弃CPU。自旋锁开销较小,在多核系统中一般不会主动产生线程切换。适用于用户态异步、协程切换请求等编程方式。但是,如果被锁定的代码执行时间过长,自旋线程就会被阻塞。它长期占用CPU资源,因此自旋时间与锁定代码的执行时间“成正比”。我们需要清楚地知道这一点。自旋锁和互斥量的使用层次类似,但实现层次完全不同:当锁失效时,互斥量使用“线程切换”来处理,而自旋锁使用“忙等待”来处理。这两种是锁最基本的处理方式,更高级的锁会选择其中一种来实现。例如,读写锁可以通过互斥锁实现,也可以基于自旋锁实现。读写锁:读写有优先级区分?我们也可以知道读写锁的字面意思。它由“读锁”和“写锁”两部分组成。如果只读共享资源,使用“读锁”加锁,如果要修改共享资源,使用“写锁”加锁。所以读写锁适用于读操作和写操作可以明确区分的场景。读写锁的工作原理是:当“写锁”没有被线程持有时,多个线程可以并发持有读锁,大大提高了共享资源的访问效率,因为“读锁”是用于读访问共享资源的场景,所以多个线程同时持有读锁不会破坏共享资源的数据。但是,一旦“写锁”被线程持有,读线程获取读锁的操作就会被阻塞,其他写线程获取写锁的操作也会被阻塞。所以写锁是独占锁,因为任何时候只有一个线程可以持有写锁,类似于互斥锁和自旋锁,而读锁是共享锁,因为读锁可以同时被多个线程持有同一时间。了解了读写锁的工作原理后,我们可以发现,在读多写少的场景下,读写锁可以发挥优势。另外,根据不同的实现方式,读写锁可以分为“读优先锁”和“写优先锁”。读优先级锁的期望是读锁可以被更多的线程持有,以提高读线程的并发性。其工作原理如下:当读线程A先持有读锁,写线程B获取写锁时,会被阻塞,阻塞过程中,后续的读线程C仍能成功获取到读锁,并且直到读线程A和C释放读锁,写线程B才能成功获取到读锁。如下图所示:写优先锁是为写线程优先服务的,其工作方式是:当读线程A先持有读锁时,写线程B在获取到写锁时会被阻塞锁,而在阻塞过程中,后续传入的读锁线程C将无法获取读锁,因此读锁线程C会阻塞在获取读锁的操作中,这样只要读线程A释放了读锁读锁,写线程B可以成功获取到读锁。如下图所示:读优先级锁对读线程的并发性更好,但也不是没有问题。试想一下,如果一直有读线程获取读锁,那么写线程将永远无法获取写锁,从而导致写线程“饿死”。写优先级锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被“饿死”。由于无论读锁优先还是写锁优先,都有可能被对方饿死,所以我们不会偏袒任何一方,打造“公平的读写锁”。一种比较简单的公平读写锁的方式是:使用一个队列,将获取锁的线程排队。写线程和读线程都可以按照先进先出的原则加锁,这样读线程仍然可以并发,不会出现“饥饿”的现象。互斥锁和自旋锁是最基本的锁。读写锁可以根据场景选择这两种锁中的一种来实现。乐观锁和悲观锁:做事的心态有什么区别?上面说的互斥锁、自旋锁、读写锁都是悲观锁。悲观锁更悲观。认为多个线程同时修改共享资源的概率比较高,所以容易发生冲突。因此,在访问共享资源之前,先锁定它们。相反,如果多个线程同时修改共享资源的概率比较低,可以使用乐观锁。乐观锁定更乐观地工作。它假定冲突的可能性非常低。它的工作方式是:先修改共享资源,然后验证这期间是否有冲突。如果没有其他线程正在修改资源,则操作完成。如果发现其他线程修改了这个资源,则放弃操作。放弃后如何重试,与业务场景密切相关。虽然重试的代价很高,但是如果冲突的概率足够低的话还是可以接受的。可以看出,乐观锁的心态是不管发生什么,先改变资源。另外,你会发现乐观锁是全程不加锁的,所以也叫无锁编程。这是一个示例场景:在线文档。我们都知道在线文档是可以多人同时编辑的。如果使用悲观锁,只要一个用户正在编辑文档,此时其他用户将无法打开同一个文档。这当然是一种糟糕的用户体验。多人同时编辑的实现其实是使用了乐观锁,允许多个用户打开同一个文档进行编辑,编辑提交后才校验修改的内容是否有冲突。什么是冲突?这是一个例子。例如,用户A先在浏览器中编辑文档,然后用户B也在浏览器中打开同一个文档进行编辑,但是用户B在用户A之前提交了修改。在这个过程中,用户A并不知道,当A提交修改的内容,那么A和B的并行修改就会冲突。服务器如何验证是否存在冲突?通常的解决方法是:由于冲突的概率比较低,所以先让用户编辑文档,但是浏览器会在下载文档时记录服务器返回的文档版本号;当用户提交修改时,发送给服务器的请求会携带原始文档版本号,服务器收到后会与当前版本号进行比较。如果版本号一致则修改成功,否则提交失败。其实我们常见的SVN和Git也是用到了乐观锁的思想。先让用户编辑代码,然后提交的时候,用版本号判断是否有冲突。哪里有冲突,就需要我们自己修改。然后重新提交。乐观锁虽然去掉了加锁和解锁的操作,但是一旦发生冲突,重试的代价非常大。所以只有在冲突概率很低,加锁代价很高的场景才考虑乐观锁。总结一下开发过程,最常见的就是mutex。当互斥锁锁定失败时,会使用“线程切换”来处理。当加锁失败的线程再次加锁成功时,会有两次线程上下文切换的代价,性能损失比较大。如果我们清楚的知道被加锁的代码执行时间很短,那么我们应该选择开销比较低的自旋锁,因为当自旋锁加锁失败时,不会主动产生线程切换,而是一直处于忙碌状态等待。直到获取到锁,如果被锁代码执行时间很短,则忙等待时间也相应短。如果能区分读操作和写操作的场景,那么读写锁更适合。允许多个读线程同时持有读锁,提高读的并发性。根据偏向于读者还是写者,可以分为读优先锁和写优先锁。读优先锁并发性强,但是写线程会被饿死,而写优先锁会优先写线程,读线程也有可能被饿死,为了避免饥饿问题,有一个公平的读写锁。它使用队列将请求锁的线程排队,保证先进先出的原则对线程进行加锁,保证了某个线程不会被饿死,通用性更好。互斥锁和自旋锁是最基本的锁。读写锁可以根据场景选择这两种锁中的一种来实现。另外,互斥锁、自旋锁、读写锁都是悲观锁。悲观锁认为在并发访问共享资源时,发生冲突的概率可能非常高,所以需要在访问共享资源前加锁。相反,如果并发访问共享资源时冲突概率很低,可以使用乐观锁。它的工作方式是在访问共享资源时,不需要先加锁。修改完共享资源后,验证这段时间如果资源没有冲突,如果没有其他线程在修改资源,那么操作就完成了。如果发现其他线程修改了资源,则放弃操作。但是一旦冲突概率增大,就不适合使用乐观锁了,因为解决冲突的重试成本非常高。不管使用什么样的锁,我们的加锁代码的作用范围都应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。此外,使用适当的锁,它会更快。原文链接:https://mp.weixin.qq.com/s/CqIXHowIDT1kxyBOO0x7TQ