自旋锁与互斥锁
前言
在编程中经常需要使用到互斥. 互斥就是, 这个事情只能有一个人干, 我正在做着的时候, 别人要想做这件事就得等我做完了.
互斥的实现是通过锁的机制, 也就是我把这块锁上了, 别人就进不来了, 等我做完再把锁释放掉.
但是, 前辈们已经证明了, 要想单纯的在软件层面上实现锁的机制是很难的, 即使是简单的一条加1的操作, 在CPU
执行时也需要如下几步:
- 将变量从内存读到寄存器
- 寄存器中的值加1
- 将寄存器中的值写回内存
而中间任何一步发生切换, 都可能导致锁机制的失败. 因此, 在软件层面的实现代价是很高的(感兴趣的可搜一下: Peterson 算法).
造成其困难的原因是什么呢? 因为无法保证单条 CPU 指令的原子性. 既然软件不够, 那就硬件来凑咯.
于是, CPU
提供了lock
指令, 可以保证单条指令的原子性. 而有了硬件的支持,锁的实现就简单的多了. 比如在有一条指令xchg
用来对两个变量进行交换, 那么就可以将锁放到一个全局变量中, 规定谁换到锁了就持有, 用完再放回去. 很简单的实现了锁的机制. (至于硬件上是如何实现的, 我确实不甚了解, 因此这里按下不表)
好, 现在能够很容易的实现锁了, 但是既然能拿到锁, 那也就有可能拿不到锁. 如果没有拿到锁, 怎么办呢? 两种应对方案既: 自旋/互斥, 他们也是其他锁(读写锁/乐观锁/悲观锁)的底层实现.
自旋锁
在获取锁失败的情况下, 立刻再次尝试获取. 大概这样:
int locked = 0;
void lock() {
// 将1放入 locked 变量
// 若 locked 中存放的是 1, 则说明当前已经有其他人获取了, 继续等待
while (xchg(&locked, 1)) ;
}
void unlock() {
// 使用完后, 将变量置为0
xchg(&locked, 0);
}
既, 线程会不停的尝试获取锁.
但是, 忙等会导致如下问题:
- 线程在空转, 浪费 CPU 性能
- 争抢锁的线程越多, CPU 的利用率越低
- 假设有100个线程在争抢锁, 其中一个线程拿到了, 那么剩下99个都在空转
- 如果抢到锁的线程被操作系统调度走了, 那么剩下的全部线程(99个)都在空转
互斥锁
自旋锁的问题其实就出在忙等上, 假设没有拿到锁的话, 就将线程暂时休眠, 等到锁被释放了再将其唤醒, 这样不就能够避免性能的浪费了嘛.
但是, 线程的调度靠线程自己是无法完成的. 需要操作系统帮忙调度.
既然进行了线程调度, 那必然就需要进行线程的上下文切换了.
但是, 如果锁的占用时间比线程上下文切换的时间还要短呢? 这边线程上下文切换还没完成, 那边锁已经释放了, 这不就会导致运行效率的降低了么.
结合
自旋锁的问题是忙等会浪费 CPU 性能, 而互斥锁的问题是若锁的持有时间极短会导致运行效率的降低.
也就是说
- 在占有锁的时间较短时, 自旋锁的开销更小. (可以立即获取锁)
- 在占有锁的时间较长时, 互斥锁的开销更小. (CPU 不用空转)
那么有没有一种既不会浪费 CPU 性能, 又不会降低线程运行效率的办法呢? 有,
- 通过自旋尝试获取锁
- 若获取失败, 则转为互斥
这样可以令大部分情况在首次获取锁时便能拿到, 无需线程切换. 在较少的情况下, 会造成部分性能的浪费. 但是整体性能是提高了的.
最后, 我们在日常上层开发的时候, 其实很少考虑获取锁的实现方式是自旋还是互斥, 更多考虑的是读写锁还是什么. 底层已经为我们选择了最合适的方式.