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


前言

生活中用到的锁,用途都比较简单粗暴,上锁基本是为了防止外人进来、电动车被偷等等。程序员

但生活中也不是没有 BUG 的,好比加锁的电动车在「广西 - 窃·格瓦拉」面前,锁就是形同虚设,只要他愿意,他就能够轻轻松松地把你电动车给「顺走」,否则打工怎么会是他这辈子不可能的事情呢?牛逼之人,必有牛逼之处。编程

那在编程世界里,「锁」更是五花八门,多种多样,每种锁的加锁开销以及应用场景也可能会不一样。浏览器

如何用好锁,也是程序员的基本素养之一了。服务器

高并发的场景下,若是选对了合适的锁,则会大大提升系统的性能,不然性能会下降。微信

因此,知道各类锁的开销,以及应用场景是颇有必要的。多线程

接下来,就谈一谈常见的这几种锁:并发


正文

多线程访问共享资源的时候,避免不了资源竞争而致使数据错乱的问题,因此咱们一般为了解决这一问题,都会在访问共享资源以前加锁。异步

最经常使用的就是互斥锁,固然还有不少种不一样的锁,好比自旋锁、读写锁、乐观锁等,不一样种类的锁天然适用于不一样的场景。函数

若是选择了错误的锁,那么在一些高并发的场景下,可能会下降系统的性能,这样用户体验就会很是差了。高并发

因此,为了选择合适的锁,咱们不只须要清楚知道加锁的成本开销有多大,还须要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突几率。

对症下药,才能减小锁对高并发性能的影响。

那接下来,针对不一样的应用场景,谈一谈「互斥锁、自旋锁、读写锁、乐观锁、悲观锁」的选择和使用。

互斥锁与自旋锁:谁更轻松自如?

最底层的两种就是会「互斥锁和自旋锁」,有不少高级的锁都是基于它们实现的,你能够认为它们是各类锁的地基,因此咱们必须清楚它俩之间的区别和应用。

加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就能够避免多线程致使共享数据错乱的问题。

当已经有一个线程加锁后,其余线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不同的:

  • 互斥锁加锁失败后,线程会释放 CPU ,给其余线程;

  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;

互斥锁是一种「独占锁」,好比当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,因而就会释放 CPU 让给其余线程,既然线程 B 释放掉了 CPU,天然线程 B 加锁的代码就会被阻塞

对于互斥锁加锁失败而阻塞的现象,是由操做系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,因而就能够继续执行。以下图:

因此,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮咱们切换线程,虽然简化了使用锁的难度,可是存在必定的性能开销成本。

那这个开销成本是什么呢?会有两次线程上下文切换的成本

  • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,而后把 CPU 切换给其余线程运行;

  • 接着,当锁被释放时,以前「睡眠」状态的线程会变为「就绪」状态,而后内核会在合适的时间,把 CPU 切换给该线程运行。

线程的上下文切换的是什么?当两个线程是属于同一个进程,由于虚拟内存是共享的,因此在切换时,虚拟内存这些资源就保持不动,只须要切换线程的私有数据、寄存器等不共享的数据。

上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,若是你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。

因此,若是你能肯定被锁住的代码执行时间很短,就不该该用互斥锁,而应该选用自旋锁,不然使用互斥锁。

自旋锁是经过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操做,不会主动产生线程上下文切换,因此相比互斥锁来讲,会快一些,开销也小一些。

通常加锁的过程,包含两个步骤:

  • 第一步,查看锁的状态,若是锁是空闲的,则执行第二步;

  • 第二步,将锁设置为当前线程持有;

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 也是用了乐观锁的思想,先让用户编辑代码,而后提交的时候,经过版本号来判断是否产生了冲突,发生了冲突的地方,须要咱们本身修改后,再从新提交。

乐观锁虽然去除了加锁解锁的操做,可是一旦发生冲突,重试的成本很是高,因此只有在冲突几率很是低,且加锁成本很是高的场景时,才考虑使用乐观锁。


总结

开发过程当中,最多见的就是互斥锁的了,互斥锁加锁失败时,会用「线程切换」来应对,当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。

若是咱们明确知道被锁住的代码的执行时间很短,那咱们应该选择开销比较小的自旋锁,由于自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,那么若是被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。

若是能区分读操做和写操做的场景,那读写锁就更合适了,它容许多个读线程能够同时持有读锁,提升了读的并发性。根据偏袒读方仍是写方,能够分为读优先锁和写优先锁,读优先锁并发性很强,可是写线程会被饿死,而写优先锁会优先服务写线程,读线程也可能会被饿死,那为了不饥饿的问题,因而就有了公平读写锁,它是用队列把请求锁的线程排队,并保证先入先出的原则来对线程加锁,这样便保证了某种线程不会被饿死,通用性也更好点。

互斥锁和自旋锁都是最基本的锁,读写锁能够根据场景来选择这两种锁其中的一个进行实现。

另外,互斥锁、自旋锁、读写锁都属于悲观锁,悲观锁认为并发访问共享资源时,冲突几率可能很是高,因此在访问共享资源前,都须要先加锁。

相反的,若是并发访问共享资源时,冲突几率很是低的话,就可使用乐观锁,它的工做方式是,在访问共享资源时,不用先加锁,修改完共享资源后,再验证这段时间内有没有发生冲突,若是没有其余线程在修改资源,那么操做完成,若是发现有其余线程已经修改过这个资源,就放弃本次操做。

可是,一旦冲突几率上升,就不适合使用乐观锁了,由于它解决冲突的重试成本很是高。

无论使用的哪一种锁,咱们的加锁的代码范围应该尽量的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了。


絮叨

这周末忙里偷闲了下,看了三部电影,简单说一下感觉。

首先看了「利刃出鞘」,这部电影是悬疑类型,也是豆瓣高分电影,电影虽然没有什么大场面,可是单纯靠缜密的剧情铺设,全程无尿点,结尾也各类翻转,若是喜欢悬疑类电影朋友,不妨抽个时间看看。

再来,看了「花木兰」,这电影我特喵没法可说,烂片中的战斗鸡,演员都是中国人却全在说英文(导演是美国迪士尼的),这种感受就很奇怪很别扭,比如你看西游记、水浒传英文版那样的别扭。别扭也就算了,关键剧情平淡无奇,各类无厘头的地方,反正看完以后,我很是后悔把我生命中很是珍贵的 2 个小时献给了它,若是能重来,我选择用这 2 小时睡觉。

最后,固然看了「信条」,诺兰用巨资拍摄出来的电影,花钱买飞机来撞,画面很是震撼,能够说很是有诚意了。诺兰钟爱时间的概念,此次则以时间倒流方式来呈现,很是的烧脑,反正我看完后脑壳懵懵的,我就是要这种感受,嘻嘻。

你们好,我是小林,一个专为你们图解的工具人,若是以为文章对你有帮助,欢迎分享给你的朋友,咱们下次见!


推荐阅读

多个线程为了同个资源打起架来了,该如何让他们安分?

本文分享自微信公众号 - 小林coding(CodingLin)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索