互斥锁 vs 自旋锁

本文首发于:行者AI数据库

锁在生活中用处很直接,好比给电瓶车加锁就是防止被偷。在编程世界里,「锁」就五花八门了,它们有着各自不一样的开销和应用场景。在存在数据竞争的场景,若是选对了锁,能大大提升系统性能,不然会互相拖后腿,性能急剧下降。编程

加锁的目的就是保证共享资源在任意时间内,只有一个线程能够访问,以此避免数据共享致使错乱的问题。最底层就是两种锁:「互斥锁」和「自旋锁」,其余高级锁,如读写锁、悲观锁、乐观锁等都是基于它们实现的。架构

1. 互斥锁和自旋锁:谁更轻松高效?

想知道它们谁更高效,要先了解它们在作同一件事情的行为有何不一样。假设有一个线程加锁成功,其余线程加锁天然会失败,失败线程的处理方式以下:异步

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

持有互斥锁的线程在看到锁已经有主了以后,就会礼貌的退出,等待以后锁释放时本身被系统唤醒;而自旋锁呢,它竟然在反复的询问锁使用完了没有,这实在是... 我写个while循环反复争夺资源,那不就是自旋锁咯?不会吧,不会吧,不会真的有人用自旋锁吧?谁更轻松高效这不是一目了然吗?性能

其实吧,自旋锁也没那么不堪,使用场景还挺多,在不少场合比互斥锁更好用,我要在本文给自旋锁洗地。至于怎么洗,那须要详细说说它们各自的原理,工程方面的选择,还真就是这么神奇。url

2. 互斥锁

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

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

img

互斥锁加锁失败,就会从用户态陷入内核态,内核帮咱们切换线程,这简化了互斥锁使用的难度,但也存在性能开销。线程

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

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

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

上下文切换须要几十纳秒到几微秒之间,若是锁住的代码执行时间极短(常见状况),那花在两次上下文切换的时间就会远多于锁住代码的执行时长。并且,线程的私有数据已经在CPU的cache上都预热好了,这一出一进,数据可能就凉透了,以后反复的cache miss那可就真的酸爽。因此,锁住的代码执行只须要几纳秒的话,为啥不持有CPU继续自旋等待呢?

3. 互斥锁的原理

上面的互斥锁都基于一个假设: 这锁小明拿了,其余人都不可能再染指,除非小明不要了。咦! 这是咋作到的?

先考虑单核场景:能不能硬件作一种加锁的原子操做呢?能! “test and set”指令就是作这个事情的,由于本身是一条硬件指令,最小执行单位,绝对不可能被打断。有了”test and set"原子指令,单核环境下,锁的实现问题获得了圆满的解决。

那么多核环境呢?简单嘛,仍是“test and set”不就得了,这是一条指令,原子的,不会有问题的。真的吗?单独一条指令可以保证该指令在单个核上执行过程当中不会被中断,可是两个核同时执行这个指令呢?再想一想,硬件执行时仍是得从内存中读取lock,判断并设置状态到内存,貌似这个过程也不是那么原子嘛,这可真是套娃啊。那多个核执行怎么办呢?首先咱们得明白这个地方的关键点,关键点是两个核会并行操做内存并且从操做内存这个调度来看“test and set”不是原子的,须要先读内存而后再写内存,若是咱们保证这个内存操做不能并行,那就回归单核场景了呀!恰好,硬件提供了锁内存总线的机制,咱们在锁内存总线的状态下执行test and set操做,就能保证同时只有一个核来test and set,从而避免了多核下发生的问题。

在x86 平台上,CPU提供了在指令执行期间对总线加锁 的手段。CPU芯片上有一条引线#HLOCK pin,若是汇编语言的程序中在一条指令前面加上前缀"LOCK" ,通过汇编之后的机器代码就使CPU在执行这条指令的时候把#HLOCK pin的电位拉低,持续到这条指令结束时放开,从而把总线锁住,这样同一总线上别的CPU就暂时不能经过总线访问内存了,保证了这条指令在多处理器环境中的原子性。

可以和 LOCK 指令前缀一块儿使用的指令以下所示:

BT, BTS, BTR, BTC (mem, reg/imm) XCHG, XADD (reg, mem / mem, reg) ADD, OR, ADC, SBB (mem, reg/imm) AND, SUB, XOR (mem, reg/imm) NOT, NEG, INC, DEC (mem)

4. 自旋锁

自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。须要注意,在单核 CPU 上,须要抢占式的调度器(即经过时钟中断一个线程,运行其余线程)。不然,自旋锁在单 CPU 上没法使用,由于一个自旋的线程永远不会放弃 CPU。

自旋锁开销少,在多核系统下通常不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但若是被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,因此自旋的时间和被锁住的代码执行的时间是成「正比」的关系,咱们须要清楚的知道这一点。

自旋锁与互斥锁使用层面比较类似,但实现层面上彻底不一样:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。这里的忙等待,能够用「while」循环实现,但最好不要这么干!!CPU提供了「PAUSE」指令来实现忙等待。

自旋锁

5. 自旋锁原理

自旋锁不就是不停的while循环去获取锁,还须要讲原理?等等,去获取锁状态的时候怎么保证数据原子性?难道又用互斥锁?若是真套一层互斥锁,那我就给自旋锁洗不了地了。显然在这里不能这么套娃!

反复尝试加锁的时候,包含两个步骤:

  • 第一步,查看锁的状态,若是锁是空闲的,则执行第二步;
  • 第二步,将锁设置为当前线程持有;

这个过程叫作「Compare And Swap」,简称「CAS」,它把上述两个步骤合并成一条硬件级指令,在「用户态」完成加锁和解锁操做,不会主动产生线程上下文切换,因此相比互斥锁来讲,会快一些,开销也小一些。

上面说,不推荐while循环获取锁,Intel CPU提供的「PAUSE」指令,「PAUSE」指令是什么?那它如何解决无脑while循环占用CPU且低效率的问题呢?

其实自旋锁不会主动释放CPU,因此不可能解决占用CPU的问题,但能让这个过程更省电,抢占锁效率更高。

「PAUSE」指令经过让CPU休息必定的时钟周期,在此休息期间,耗电几乎停滞。休息的时钟周期,不一样版本CPU不同,大概在几十到上百时钟周期之间。以5Ghz主频运行的CPU为例,一个时钟周期就是0.2纳秒。

休息的时钟周期不是越大越好。好比Intel新一代的Skylake架构中,初期「PAUSE」指令的休息周期高达140个时钟周期。这直接致使MySQL在理论上性能更好的CPU上,数据库性能跑出了比前几年CPU更糟糕的成绩,挤出的牙膏吸回去了!在随后的步进中下降了「PAUSE」的时钟周期到上一代的10个时钟周期,数据库展示的性能才恢复了牙膏厂该有的水准(每代性能提高一丢丢)。

另外一个优势跟流水线有关系,频繁的检测会让流水线上充满了读操做。另一个线程往流水线上丢入一个锁变量写操做的时候,必须对流水线进行重排,由于CPU必须保证全部读操做读到正确的值。流水线重排十分耗时,影响lock()的性能。设想一下,当一个得到锁的工做线程W从临界区退出,在调用unlock释放锁的时候,有若干个等待线程S都在自旋检测锁是否可用,此时W线程会产生一个store指令,若干个S线程会产生不少load指令,在store以后的load指令要等待store在流水线上执行完毕才能执行,因为处理器是乱序执行,在没有store指令以前,处理器对多个没有依赖的load是能够随机乱序执行的,当有了store指令以后,须要reorder从新排序执行,此时会严重影响处理器性能,按照intel的说法,会带来25倍的性能损失。Pause指令的做用就是减小并行load的数量,从而减小reorder时所耗时间。

6. 总结

互斥锁和自旋锁没有优略之分,工程中使用哪一种锁,主要仍是看使用场景(洗地操做)。

通常状况使用互斥锁。若是咱们明确知道被锁住的代码的执行时间很短(这样的场景最广泛,就算不广泛也要改代码让这种场景广泛),那咱们应该选择开销比较小的自旋锁,由于自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,那么若是被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。

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


PS:更多技术干货,快关注【公众号 | xingzhe_ai】,与行者一块儿讨论吧!

相关文章
相关标签/搜索