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

最底层的两种锁就是互斥锁和自旋锁,有很多高级的锁都是基于他们来实现的。你可以认为它们是各种锁的地基,所以我们必须清楚它们两之间的区别和应用。

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

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

  • 互斥锁加锁失败后,线程会释放CPU, 给其他线程
  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁

互斥锁是一种独占锁,比如当线程A加锁成功后,此时互斥锁以已经被线程A独占了,只要线程A没有释放手中的锁,线程B加锁就会失败,于是就会释放CPU让给其他线程。既然线程B释放掉了CPU, 自然线程B加锁的代码就会被阻塞。

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

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

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

  • 当线程加锁失败时,内核会把线程的状态从运行状态设置为睡眠状态,然后把CPU切换给其他线程运行
  • 接着,当锁被释放时,之前睡眠状态的线程会变为就绪状态,然后内核会在合适的时间,把CPU切换给线程运行

线程的上下文切换的是什么?当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存保持不动,只需要切换线程的私有数据,寄存器等不共享的数据。

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

所以,如果你能确定被锁住的代码执行时间很短(一般很难判断执行时间的长短),就不应该使用互斥锁,而应该使用自旋锁。

自旋锁是通过CPU提供的CAS函数(Compare And Swap), 在用户态完成加锁和解锁操作,不会主动产生线程的上下文切换(所以线程的上下文切换只是发生在内核态?),所以相比互斥锁来说,会快一些,开销也会小一些。

一般加锁的过程,包含两个步骤:

  1. 查看锁的状态,如果锁是空闲的,则执行第二部
  2. 将锁设置为当前线程持有

CAS函数就把这两个步骤合并成一条硬件级指令,形成云子操作。这样就保证了这两个步骤是不可分割的,要么一次性执行完成两个步骤,要么两个步骤都不执行。

使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会忙等待,直到它拿到锁。这里的忙等待,可以用while循环实现,不过最好使用CPU提供的PAUSE指令来实现忙等待,因为可以减少循环等待的耗电量。

自旋锁是比较简单的一种锁,一直自旋,利用CPU的周期,直到锁是可用的。在单核的CPU上,需要使用抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程),否则,自旋锁在单CPU上无法使用,因为一个自旋的线程永远不会放弃CPU(如果CPU被占用,别的线程不能执行完其逻辑,并释放锁,会造成死锁)。

自旋锁开销小,在多核系统下一般不会主动产生线程切换,适合异步,协程等在用户态切换请求的编程方式。但是如果被锁住的代码执行时间过长,自旋的线程会长时间占用CPU资源,所以自旋的时间和被锁住的代码执行的时间是成正比的。

他们两个是锁的最基本处理方式,更高级的锁都会选择其中一个时间,比如读写锁可以选择互斥锁,也可以基于自旋锁实现。

读写锁

当写锁从字面意思我们也可以知道,它由读锁和写锁两部分构成,如果只是读取共享资源用读锁锁定,如果要修改共享资源用写锁。

所以,读写锁适用于能明确区分读操作和写操作的场景。

读写锁的工作原理是:

  • 当写锁没有被线程持有时,多个线程能够并发地持有读锁,这大大地提高了共享资源的访问效率,因为读锁是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据
  • 但是,一旦写锁被线程持有之后,读线程获取读锁的操作会被阻塞,而且其他写线程获取写锁的操作也会被阻塞

所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。

读写锁在读多写少的场景,能发挥出优势。

另外,根据实现的不同,读写锁可以分为读优先锁和写优先锁。

读优先锁期望的是,读锁能够被更多的线程持有,以便提高读线程的并发性,它的工作方式是,当读线程A先持有了读锁,写线程B在获取写锁的时候会被阻塞,并且在阻塞的过程中,后续来的读线程c仍然可以成功获取读锁,最后到读线程A和C释放读锁后,写线程B才可以成功获取写锁,如下图所示:
image.png

而写优先锁是优先服务写线程,其他工方式是,当读锁A先持有读锁,写线程B在获取写锁的时候,会被阻塞,并且在阻塞的过程中,后续来的读线程C获取读锁会失败,于是读线程C将被阻塞在获取读锁读操作,这样只要读线程A释放读锁后,写线程B就可以成获取读锁,如下图:
image.png
读优先锁对于读线程并发性更好,但也不是没有问题,我们试想一下,如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就会造成写线程饥饿的现象。

写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程不会被饿死。

既然不管优先读还是优先写,对方都有可能出现饿死的问题,那么我们就不能偏袒任何一方,需要搞个公平读写锁。

公平读写锁比较简单的一种实现方式是:用队列吧获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁就好,这样读线程仍然可以并发,也不会出现饥饿的现象。

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。

而乐观锁做事比较乐观,它假定冲突的概率较低,它的工作方式是,先修改资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。

举一个不太恰当的例子:在线文档。

我们都知道在线文档可以同时多人编辑,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这对用户体验相当不友好。

那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完成提交之后才验证修改的内容是否有冲突。

怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。

服务端要怎么验证是否冲突了呢?通常方案为:

  • 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档的时候会记录下服务器返回文档版本号
  • 当用户提交修改的时候,发给服务器的请求会带上原始文档的版本号进行比较,如果版本号一致则修改成功,否则提交失败

实际上,我们常见的SVN和Git也是用了乐观锁和悲观锁,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生冲突的地方,需要我们自己修改后,再重新提交。