跳转至

06 锁:如何根据业务场景选择合适的锁?

你好,我是陶辉。

上一讲我们谈到了实现高并发的不同方案,这一讲我们来谈谈如何根据业务场景选择合适的锁。

我们知道,多线程下为了确保数据不会出错,必须加锁后才能访问共享资源。我们最常用的是互斥锁,然而,还有很多种不同的锁,比如自旋锁、读写锁等等,它们分别适用于不同的场景。

比如高并发场景下,要求每个函数的执行时间必须都足够得短,这样所有请求才能及时得到响应,如果你选择了错误的锁,数万请求同时争抢下,很容易导致大量请求长期取不到锁而处理超时,系统吞吐量始终维持在很低的水平,用户体验非常差,最终“高并发”成了一句空谈。

怎样选择最合适的锁呢?首先我们必须清楚加锁的成本究竟有多大,其次我们要分析业务场景中访问共享资源的方式,最后则要预估并发访问时发生锁冲突的概率。这样,我们才能选对锁,同时实现高并发和高吞吐量这两个目标。

今天,我们就针对不同的应用场景,了解下锁的选择和使用,从而减少锁对高并发性能的影响。

互斥锁与自旋锁:休眠还是“忙等待”?

我们常见的各种锁是有层级的,最底层的两种锁就是互斥锁和自旋锁,其他锁都是基于它们实现的。互斥锁的加锁成本更高,但它在加锁失败时会释放CPU给其他线程;自旋锁则刚好相反。

当你无法判断锁住的代码会执行多久时,应该首选互斥锁,互斥锁是一种独占锁。什么意思呢?当A线程取到锁后,互斥锁将被A线程独自占有,当A没有释放这把锁时,其他线程的取锁代码都会被阻塞。

阻塞是怎样进行的呢?对于99%的线程级互斥锁而言,阻塞都是由操作系统内核实现的(比如Linux下它通常由内核提供的信号量实现)。当获取锁失败时,内核会将线程置为休眠状态,等到锁被释放后,内核会在合适的时机唤醒线程,而这个线程成功拿到锁后才能继续执行。如下图所示:

互斥锁通过内核帮忙切换线程,简化了业务代码使用锁的难度。

但是,线程获取锁失败时,增加了两次上下文切换的成本:从运行中切换为休眠,以及锁释放时从休眠状态切换为运行中。上下文切换耗时在几十纳秒到几微秒之间,或许这段时间比锁住的代码段执行时间还长。而且,线程主动进入休眠是高并发服务无法容忍的行为,这让其他异步请求都无法执行。

如果你能确定被锁住的代码执行时间很短,就应该用自旋锁取代互斥锁。

自旋锁比互斥锁快得多,因为它通过CPU提供的CAS函数(全称Compare And Swap),在用户态代码中完成加锁与解锁操作。

我们知道,加锁流程包括2个步骤:第1步查看锁的状态,如果锁是空闲的,第2步将锁设置为当前线程持有。

在没有CAS操作前,多个线程同时执行这2个步骤是会出错的。比如线程A执行第1步发现锁是空闲的,但它在执行第2步前,线程B也执行了第1步,B也发现锁是空闲的,于是线程A、B会同时认为它们获得了锁。

CAS函数把这2个步骤合并为一条硬件级指令。这样,第1步比较锁状态和第2步锁变量赋值,将变为不可分割的原子指令。于是,设锁为变量lock,整数0表示锁是空闲状态,整数pid表示线程ID,那么CAS(lock, 0, pid)就表示自旋锁的加锁操作,CAS(lock, pid, 0)则表示解锁操作。

多线程竞争锁的时候,加锁失败的线程会“忙等待”,直到它拿到锁。什么叫“忙等待”呢?它并不意味着一直执行CAS函数,生产级的自旋锁在“忙等待”时,会与CPU紧密配合 ,它通过CPU提供的PAUSE指令,减少循环等待时的耗电量;对于单核CPU,忙等待并没有意义,此时它会主动把线程休眠。

如果你对此感兴趣,可以阅读下面这段生产级的自旋锁,看看它是怎么执行“忙等待”的:

while (true) {
  //因为判断lock变量的值比CAS操作更快,所以先判断lock再调用CAS效率更高
  if (lock == 0 &&  CAS(lock, 0, pid) == 1) return;

  if (CPU_count > 1 ) { //如果是多核CPU,“忙等待”才有意义
      for (n = 1; n < 2048; n <<= 1) {//pause的时间,应当越来越长
        for (i = 0; i < n; i++) pause();//CPU专为自旋锁设计了pause指令
        if (lock == 0 && CAS(lock, 0, pid)) return;//pause后再尝试获取锁
      }
  }
  sched_yield();//单核CPU,或者长时间不能获取到锁,应主动休眠,让出CPU
}

在使用层面上,自旋锁与互斥锁很相似,实现层面上它们又完全不同。自旋锁开销少,在多核系统下一般不会主动产生线程切换,很适合在用户态切换请求的编程方式,有助于高并发服务充分利用多颗CPU。但如果被锁住的代码执行时间过长,CPU资源将被其他线程在“忙等待”中长时间占用。

当取不到锁时,互斥锁用“线程切换”来面对,自旋锁则用“忙等待”来面对。这是两种最基本的处理方式,更高级别的锁都会选择其中一种来实现,比如读写锁就既可以基于互斥锁实现,也可以基于自旋锁实现。

下面我们来看一看读写锁能带来怎样的性能提升。

允许并发持有的读写锁

如果你能够明确区分出读和写两种场景,可以选择读写锁。

读写锁由读锁和写锁两部分构成,仅读取共享资源的代码段用读锁来加锁,会修改资源的代码段则用写锁来加锁。

读写锁的优势在于,当写锁未被持有时,多个线程能够并发地持有读锁,这提高了共享资源的使用率。多个读锁被同时持有时,读线程并不会修改共享资源,所以它们的并发执行不会产生数据错误。

而一旦写锁被持有后,不只读线程必须阻塞在获取读锁的环节,其他获取写锁的写线程也要被阻塞。写锁就像互斥锁和自旋锁一样,是一种独占锁;而读锁允许并发持有,则是一种共享锁。

因此,读写锁真正发挥优势的场景,必然是读多写少的场景,否则读锁将很难并发持有。

实际上,读写锁既可以倾向于读线程,又可以倾向于写线程。前者我们称为读优先锁,后者称为写优先锁。

读优先锁更强调效率,它期待锁能被更多的线程持有。简单看下它的工作特点:当线程A先持有读锁后,即使线程B在等待写锁,后续前来获取读锁的线程C仍然可以立刻加锁成功,因为这样就有A、C 这2个读线程在并发持有锁,效率更高。

我们再来看写优先的读写锁。同样的情况下,线程C获取读锁会失败,它将被阻塞在获取锁的代码中,这样,只要线程A释放读锁后,线程B马上就可以获取到写锁。如下图所示:

读优先锁并发性更好,但问题也很明显。如果读线程源源不断地获取读锁,写线程将永远获取不到写锁。写优先锁可以保证写线程不会饿死,但如果新的写线程源源不断地到来,读线程也可能被饿死。

那么,能否兼顾二者,避免读、写线程饿死呢?

用队列把请求锁的线程排队,按照先来后到的顺序加锁即可,当然读线程仍然可以并发,只不过不能插队到写线程之前。Java中的ReentrantReadWriteLock读写锁,就支持这种排队的公平读写锁。

如果不希望取锁时线程主动休眠,还可以用自旋锁实现读写锁。到底应该选择“线程切换”还是“忙等待”方式实现读写锁呢?除去读写场景外,这与选择互斥锁和自旋锁的方法相同,就是根据加锁代码执行时间的长短来选择,这里就不再赘述了。

乐观锁:不使用锁也能同步

事实上,无论互斥锁、自旋锁还是读写锁,都属于悲观锁。

什么叫悲观锁呢?它认为同时修改资源的概率很高,很容易出现冲突,所以访问共享资源前,先加上锁,总体效率会更优。然而,如果并发产生冲突的概率很低,就不必使用悲观锁,而是使用乐观锁。

所谓“乐观”,就是假定冲突的概率很低,所以它采用的“加锁”方式是,先修改完共享资源,再验证这段时间内有没有发生冲突。如果没有其他线程在修改资源,那么操作完成。如果发现其他线程已经修改了这个资源,就放弃本次操作。

至于放弃后如何重试,则与业务场景相关,虽然重试的成本很高,但出现冲突的概率足够低的话,还是可以接受的。可见,乐观锁全程并没有加锁,所以它也叫无锁编程。

无锁编程中,验证是否发生了冲突是关键。该怎么验证呢?这与具体的场景有关。

比如说在线文档。Web中的在线文档是怎么实现多人编辑的?用户A先在浏览器中编辑某个文档,之后用户B也打开了相同的页面开始编辑,可是,用户B最先编辑完成提交,这一过程用户A却不知道。当A提交他改完的内容时,A、B之间的并行修改引发了冲突。

Web服务是怎么解决这种冲突的呢?它并没有限制用户先拿到锁后才能编辑文档,这既因为冲突的概率非常低,也因为加解锁的代价很高。Web中的方案是这样的:让用户先改着,但需要浏览器记录下修改前的文档版本号,这通过下载文档时,返回的HTTP ETag头部实现。

当用户提交修改时,浏览器在请求中通过HTTP If-Match头部携带原版本号,服务器将它与文档的当前版本号比较,一致后新的修改才能生效,否则提交失败。如下图所示(如果你想了解这一过程的细节,可以阅读 《Web协议详解与抓包实战》第28课):

乐观锁除了应用在Web分布式场景,在数据库等单机上也有广泛的应用。只是面向多线程时,最后的验证步骤是通过CPU提供的CAS操作完成的。

乐观锁虽然去除了锁操作,但是一旦发生冲突,重试的成本非常高。所以,只有在冲突概率非常低,且加锁成本较高时,才考虑使用乐观锁。

小结

这一讲我们介绍了高并发下同步资源时,如何根据应用场景选择合适的锁,来优化服务的性能。

互斥锁能够满足各类功能性要求,特别是被锁住的代码执行时间不可控时,它通过内核执行线程切换及时释放了资源,但它的性能消耗最大。需要注意的是,协程的互斥锁实现原理完全不同,它并不与内核打交道,虽然不能跨线程工作,但效率很高。(如果你希望进一步了解协程,可以阅读[第5讲]。)

如果能够确定被锁住的代码取到锁后很快就能释放,应该使用更高效的自旋锁,它特别适合基于异步编程实现的高并发服务。

如果能区分出读写操作,读写锁就是第一选择,它允许多个读线程同时持有读锁,提高了并发性。读写锁是有倾向性的,读优先锁很高效,但容易让写线程饿死,而写优先锁会优先服务写线程,但对读线程亲和性差一些。还有一种公平读写锁,它通过把等待锁的线程排队,以略微牺牲性能的方式,保证了某种线程不会饿死,通用性更佳。

另外,读写锁既可以使用互斥锁实现,也可以使用自旋锁实现,我们应根据场景来选择合适的实现。

当并发访问共享资源,冲突概率非常低的时候,可以选择无锁编程。它在Web和数据库中有广泛的应用。然而,一旦冲突概率上升,就不适合使用它,因为它解决冲突的重试成本非常高。

总之,不管使用哪种锁,锁范围内的代码都应尽量的少,执行速度要快。在此之上,选择更合适的锁能够大幅提升高并发服务的性能!

思考题

最后,留给你一道思考题,上一讲我们提到协程中也有各种锁,你觉得协程中可以用自旋锁或者互斥锁吗?如果不可以,那协程中的锁是怎么实现的?欢迎你在留言区与我探讨。

感谢阅读,如果你觉得这节课对你有一些启发,也欢迎把它分享给你的朋友。

精选留言(15)
  • book尾汁 👍(25) 💬(7)

    总结下,我的理解 不对的欢迎指出: 四种IO模型 同步阻塞: 调用了阻塞的系统调用,内核会将线程置于休眠状态,并进行调度 同步非阻塞: 调用了非阻塞的系统调用,系统调用会立刻返回结果.通过重试的方式来不断获取结果,直到满足条件 异步阻塞: 会通过注册回调函数来实现结果的通知,注册完成线程会被内核挂起进入休眠状态 异步非阻塞: 同上,但注册完成后线程可以继续执行. 同步: 等待结果的返回才能进行下一步操作 异步:不一直等待结果的返回,而是通过向IO调度框架注册回调函数的方式来进行通知.通过框架来实现 阻塞: 不会立刻返回结果,此时线程会被内核挂起,让出cpu 非阻塞: 立刻返回结果 异步非阻塞放在一起用才能起到并发的作用. 同步非阻塞也可以实现并发. 同步与异步是针对编程方式的 阻塞与非阻塞的因为系统调用的实现方式导致的 协程: 将异步的注册回调函数以及非阻塞的系统调用来封装成一个阻塞的协程,即将等待回调时通过线程中的上下文切换来实现线程的无感知切换,感觉有点类似于异步阻塞,但阻塞是用户态的,也就是是由用户态来进行线程的虚拟的休眠(通过线程上下文的切换) 锁 两种基本锁 自旋锁 : 通过CAS函数来实现,将观察锁的状态与获取锁合并为一个硬件级的指令,通过在用户态来观察锁的状态,并进行获取锁,来避免因获取锁失败导致的线程休眠.获取锁失败会忙等待,即过一段时间在去获取锁(通过循环实现等待时间),通过pause指令来减少循环等待时的耗电量. 互斥锁: 一种独占锁,获取失败的线程会被内核置为休眠状态. 其他锁都是通过这两种锁实现的 读写锁 读优先锁 写优先锁 乐观锁:乐观锁并没有加锁,而是通过执行完成后的版本号对比来实现

    2020-05-12

  • 那时刻 👍(10) 💬(3)

    Go里的自旋锁需要自己实现,方便协程调度。协程使用自旋锁的时候,这是spinLock 的Lock方法 for !atomic.CompareAndSwapUint32(sl, 0, 1) { runtime.Gosched() } 其中runtime.Gosched,是把阻塞的协程调度出去,这样调度器可以执行其他协程。

    2020-05-12

  • myrfy 👍(7) 💬(1)

    “自旋锁开销少,在多核系统下一般不会主动产生线程切换,很适合异步、协程等在用户态切换请求的编程方式,有助于高并发服务充分利用多颗 CPU。“ 这段描述是不是不太准确?协程在用户态由协程框架调度,自旋锁只会阻塞当前协程,导致其他协程不能获得执行权。 协程框架下的sleep等函数,都是框架提供的而不是操作系统提供的,其工作原理是将当前协程的唤醒时间告知协程框架调度器后主动让出当前协程,让其他协程有机会运行,当每一个协程发生主动切换时,协程框架会检测是否有等待唤醒的协程,若有则会按照一定的策略唤醒睡眠的协程。

    2020-05-12

  • abc 👍(5) 💬(2)

    老师,我有一个问题请教一下,就是多线程对一个数组进行读写,除了加锁之外,怎么深度优化呢?

    2020-08-05

  • Robust 👍(2) 💬(5)

    自旋锁属于悲观锁吗?

    2020-05-20

  • test 👍(2) 💬(1)

    协程有些操作是使用线程池来实现的,需要加锁

    2020-05-11

  • 范闲 👍(1) 💬(1)

    用户态的协程不能用互斥或者自旋,会进入内核态与其设计初衷相悖。Python里面用的yield

    2020-05-18

  • 而立斋 👍(1) 💬(1)

    看的很带劲,就好像吃了个美妙的西瓜。

    2020-05-13

  • 战斗机二虎🐯 👍(0) 💬(1)

    发现这段自旋锁代码用的nginx的实现😁

    2020-06-23

  • 小喵喵 👍(0) 💬(1)

    老师,锁为什么分类那么多,是从哪些维度来分的,比如从数据库来分,可以分为悲观和乐观锁,以及为什么这么分类呢?

    2020-05-12

  • book尾汁 👍(0) 💬(1)

    老师文中提到自旋锁通过在用户态来实现观察锁加锁解锁的操作,感觉协程中的锁可以通过CAS函数来实现,如果获取锁失败则进行协程的切换.

    2020-05-12

  • 王超 👍(59) 💬(0)

    老师,有一个建议,是否可以在每一讲的第一个评论里讲解一下上一讲的思考问题

    2020-05-15

  • 分清云淡 👍(19) 💬(0)

    CPU 提供的 PAUSE 指令,减少循环等待时的耗电量---- 这个减少耗电量只是一个小原因,更主要的是为了避免 cache ping-pong. mysql的innodb在抢锁失败后还将 pause * innodb_spin_wait_delay(也就是pause更久) ,然后再再次抢锁。 不同的cpu型号 pause 的指令周期也不一样,这样换了cpu的话 innodb的这种设计问题很大。

    2020-05-18

  • 忆水寒 👍(8) 💬(2)

    首先要知道,协程是用户态运行的,所以减少内核切换。而自旋锁和互斥锁其实都是有内核控制线程的访问,所以协程肯定不是使用互斥锁Mutex和自旋锁SpinLock实现的。 我想,协程之间的互相协作肯定也是有一种协商机制,之前在一篇博客看到好像是使用yield实现的,从而共享CPU使用权。

    2020-05-11

  • fancion 👍(4) 💬(0)

    协程的实现不是锁吧。是消息驱动排队处理的吧,基于akka思想

    2020-05-28