写点什么

互斥锁 vs 自旋锁

用户头像
行者AI
关注
发布于: 2021 年 01 月 27 日

本文首发于:行者AI


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


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

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


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


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

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


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


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

2. 互斥锁


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


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



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


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


  • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 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】,与行者一起讨论吧!


发布于: 2021 年 01 月 27 日阅读数: 29
用户头像

行者AI

关注

行者AI,为游戏插上人工智能的翅膀。 2020.12.18 加入

行者AI(成都潜在人工智能科技有限公司)专注于人工智能在游戏领域的研究和应用,凭借自研算法,推出游戏AI、智能内容审核、数据平台等产品服务。

评论

发布
暂无评论
互斥锁 vs 自旋锁