Loading

自旋锁与互斥锁

前言

在编程中经常需要使用到互斥. 互斥就是, 这个事情只能有一个人干, 我正在做着的时候, 别人要想做这件事就得等我做完了.

互斥的实现是通过锁的机制, 也就是我把这块锁上了, 别人就进不来了, 等我做完再把锁释放掉.

但是, 前辈们已经证明了, 要想单纯的在软件层面上实现锁的机制是很难的, 即使是简单的一条加1的操作, 在CPU执行时也需要如下几步:

  1. 将变量从内存读到寄存器
  2. 寄存器中的值加1
  3. 将寄存器中的值写回内存

而中间任何一步发生切换, 都可能导致锁机制的失败. 因此, 在软件层面的实现代价是很高的(感兴趣的可搜一下: 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 性能, 又不会降低线程运行效率的办法呢? 有,

  1. 通过自旋尝试获取锁
  2. 若获取失败, 则转为互斥

这样可以令大部分情况在首次获取锁时便能拿到, 无需线程切换. 在较少的情况下, 会造成部分性能的浪费. 但是整体性能是提高了的.

最后, 我们在日常上层开发的时候, 其实很少考虑获取锁的实现方式是自旋还是互斥, 更多考虑的是读写锁还是什么. 底层已经为我们选择了最合适的方式.

posted @ 2022-05-04 13:52  烟草的香味  阅读(34)  评论(0编辑  收藏  举报