计算机知识宇宙
  • 介紹
  • 基础知识
    • 计算机系统
      • 进程与线程
        • 进程
        • 线程
        • 调度算法
        • 进程间通信
      • 锁
    • 计算机网络
    • 算法
  • 编程语言
    • Golang
      • 垃圾回收机制
  • 进阶实践
    • 云原生
      • 图解 Kubernetes
        • Deployment Controller 篇
        • Informer 篇(上)
        • QoS 篇
  • 附录
    • 概念
由 GitBook 提供支持
在本页
  • 总结
  • 乐观锁&悲观锁
  • 互斥锁&自旋锁
  • 互斥锁
  • 自旋锁
  • 读写锁
  • 读优先锁
  • 写优先锁
  • 公平读写锁
  • 参考

这有帮助吗?

  1. 基础知识
  2. 计算机系统

锁

上一页进程间通信下一页计算机网络

最后更新于4年前

这有帮助吗?

在多线程访问资源时,资源竞争是不可避免的,而这会导致数据的错乱,而应对资源竞争比较常见的一种办法就是使用锁。

总结

开发中最常见的时互斥锁,但是互斥锁加锁失败时,会触发线程切换,当加锁失败的线程再次加锁成功的中,会有两次线程上下文切换的开销,性能损耗比较大。

如果明确知道被锁住的代码执行时间很短,那么就要选择开销较小的自旋锁,自旋锁不会主动产生线程切换,而是一直「忙等待」,直到获取到锁。那么如果被锁住的代码执行时间很短,那么忙等待的时间也很短。

在能区分读操作和写操作的场景,很适合用读写锁,它允许多个线程同时持有读锁,提高了读的并发性。根据读写的重要性,有可分为读优先锁和写优先锁,但都可能造成读或写的饥饿问题。为了避免这个问题,于是就有了公平读写锁,它用队列把请求锁的线程排队,并保证先入先出的原则来对线程加锁,这样保证了某种线程不会被饿死,通过性也更好。

互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景选择一种来进行实现。

互斥锁、自旋锁、读写锁都属于悲观锁,悲观锁认为并发访问共享资源时,冲突的概率非常高,所以在访问共享资源前,都需要先加锁。

相反,如果访问共享资源冲突概率低,就可以使用乐观锁,在访问共享资源时,不用先加锁,修改完共享资源后,再验证这段时间内有没有发生冲突,如果没有其他线程修改资源,那么操作完成,如果有其他资源已经在修改这个资源,就放弃本次操作。

如果冲突的概率上升,乐观锁就不适用了,应为解决冲突的重试成本非常高。

必须要注意的是,不管使用哪种锁,加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行的速度会比较快,然后在选择合适的锁,那么速度就更快了。

乐观锁&悲观锁

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

互斥锁、自旋锁、读写锁都属于悲观锁

乐观锁则是,先修改资源。再验证修改资源时是否有发生冲突

  • 如果没有,则操作完成

  • 如果有,则放弃本次操作

乐观锁全程没有加锁,所以叫无锁编程,但是一旦发生冲突,重试成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景下,才考虑乐观锁。

互斥锁&自旋锁

互斥锁与自旋锁是最底层的两种锁,很多高级锁都是基于它们实现的。

加锁的目的是为了保证共享资源在任何时间内,只有一个线程访问,避免多线程访问造成的数据错乱。

当一个线程加锁后,其他线程加锁就是失败,互斥锁与自旋锁的区别在于他们处理失败的方式不同:

  • 互斥锁加锁失败后,线程会释放 CPU 给他们线程

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

互斥锁

互斥锁属于 「独占锁」 :在 A 线程加锁成功后,B 线程就会加锁失败,于是 B 线程的 CPU 就会释放给其他线程,B 线程的代码自然就会被阻塞。

互斥锁加速失败而阻塞的现象由操作系统内核实现。加锁失败后,内核会将线程设置为「睡眠」状态,等到锁被释放后,内核才会唤醒线程:

所以在加锁失败后,会从用户态陷入内核态,进行线程切换,虽然简化了使用锁的难度,但是也增加了开销。

这里会出现两次线程上下文切换:

  1. 加锁失败,内核会把线程从「运行」设置为「睡眠」状态,然后把 CPU 切换给其他线程使用

  2. 当锁被释放后,内核又会把线程的「睡眠」变为「就绪」状态,之后才会在合适和时间,把 CPU 切换给线程运行

虽然是线程上下文切换,但是也需要切换线程的私有数据、寄存器等不共享的资源(虚拟内存是共享的,所以无需切换),有大佬统计过切换的耗时在几十纳秒到几微秒之间。如果锁住的代码执行时间比较短,那么就很不划算了。

所以,如果确定被锁住的代码执行时间很短,就不应该使用互斥锁,而是使用自旋锁了。

自旋锁

自旋锁采用 CPU 提供的 CAS 函数(Compare And Swap),在 「用户态」 完成加锁和解锁操作,不会主动触发上下文切换,所以较互斥锁,会快一些。

加锁一般分两步:

  1. 查看锁状态,如果锁是空闲的,执行第二步

  2. 将锁设置为当前线程持有

CAS 函数把这两个步骤合并为一条硬件级指令,形成原子指令,保证这两个步骤不可分割,会一次性执行完。

在使用自旋锁时,加锁失败会「忙等待」,直到它拿到锁。「忙等待」可以使用 while 循环实现,不过最好使用 CPU 提供的 PAUSE 指令实现,这样可以减少循环等待时的耗电量。

自旋锁是最简单的一种锁,利用 CPU 周期,一直自旋,直到锁可用。在单核 CPU 中,需要抢占式调度器(不断通过时钟中断一个线程,运行其他线程),否则自旋锁在 CPU 上无法使用,因为自旋的线程永远不会放弃 CPU 的占用。

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

自旋锁与互斥锁在使用上比较类似,但在实现上有很大差别:当加锁失败时,互斥锁触发「线程切换」,而自旋锁则是使用「忙等待」来应对。

读写锁

读写锁由「读锁」和「写锁」两部分构成,只读共享资源用「读锁」加锁,修改共享资源用「写锁」加速,读写锁适用于能明确区分读操作和写操作的场景。

读写锁的工作原理:

  • 当「写锁」没有被线程持有时,多个线程能够并发的持有「读锁」,大大提高了共享资源的访问率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时读锁也不会破坏共享资源的数据

  • 一旦「写锁」被线程持有,读线程的获取「读锁」的操作会被阻塞,而且其他写操作也会被阻塞

故「写锁」是独占锁,因为任何时刻都只能有一个线程持有「写锁」,类似互斥锁和自旋锁;而「读锁」是共享锁,因为可以被多个线程同时持有。所以读写锁在读多写少的场景,能发挥出优势。

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

读优先锁

在读优先锁,「读锁」能被更多的线程持有,以便提高读线程的并发性;其工作方式为:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候会被阻塞,并且在阻塞过程中,后续的读线程 C 仍然可以成功获取「读锁」,最后直到线程 A 和 C 释放读锁后,写线程 B 才能成功获取「写锁」。

写优先锁

在写优先锁中,优先服务的事写线程,其工作方式为:当读线程 A先持有了读锁,写线程 B 在获取「写锁」的时候会先被阻塞,并且在阻塞过程中,后续的读线程 C 获取「读锁」会失败,于是读线程 C 将被阻塞在获取「读锁」操作,这样只要读线程 A 释放「读锁」后,写线程 B 就可以成功获取写锁。

公平读写锁

  • 读优先锁对于读线程并发性更好,但是如果一直有读线程获取「读锁」,写线程就永远也获取不到读锁,造成写现场「饥饿」现象

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

不管读优先还是写优先都会出现饿死问题,这时就有了「公平读写锁」。

其中还比较简单的一种方式:获取锁的现场都进入队列,按照先进先出原则加锁,这样读线程仍然可以并发,也不会出现「饥饿」的现象。

而互斥锁和自旋锁是最基本的锁,读写锁可以根据场景选择其中一种来进行实现。

参考

互斥锁的实现
读优先锁

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