互斥锁、自旋锁、读写锁...理清它们的区别和应用
生活中,我们会用锁来保护自身的财产不被人偷走,但今天讲的“锁”,可不是这个用途。
在编程世界里,如何用好锁是程序员的基本素养之一。多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,通常为了解决这一问题,都会在访问共享资源之前加锁。最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。
如果选择了错误的锁,在一些高并发的场景下,可能会降低系统的性能,影响用户体验。为了选择合适的锁,不仅需要清楚知道加锁的成本开销有多大,还需要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。对症下药,才能减少锁对高并发性能的影响。
读写锁(RWMutex)
读写锁是针对于读写操作的互斥锁。它与普通的互斥锁最大的不同是,它可以分别针对读操作和写操作进行锁定和解锁操作。读写锁遵循的访问控制规则与互斥锁有所不同。在读写锁管辖的范围内,它允许任意个读操作的同时进行。但在同一时刻,它只允许有一个写操作在进行。并且,在某一个写操作被进行的过程中,读操作的进行也是不被允许的。也就是说,读写锁控制下的多个写操作之间都是互斥的,并且写操作与读操作之间也都是互斥的。但多个读操作之间却不存在互斥关系。
- 读锁: 所有的 goroutine 都可以同时读, 但不允许写
- 写锁: 只允许一个goroutine 写, 其他的goroutine 不允许读也不允许写
用golang 来演示读写锁的作用,如下:
互斥锁(Mutex)
使用互斥锁(Mutex,全称 mutual exclusion)是为了来保护一个资源不会因为并发操作而引起冲突,导致数据不准确。
互斥锁与自旋锁的区别
最底层的两种就是「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现,可以认为它们是各种锁的地基,所以得清楚它俩之间的区别和应用。
加锁的目的是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
当已经有一个线程加锁后,其他线程加锁则会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的。
互斥锁与自旋锁的开销成本
互斥锁加锁失败后,线程会释放 CPU ,给其他线程;旋锁加锁失败后,线程会忙等待,直到它拿到锁;互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,就可以继续执行。
乐观锁与悲观锁
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程。
举个例子:
在线文档是可以同时多人编辑,如果使用了悲观锁,只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档,用户体验当然不好。
实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。
怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。
服务端是如何解决这种冲突的?
由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号一致则修改成功,否则提交失败。
实际上,常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。
乐观锁虽然去除了加锁解锁的操作,但一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。