面试官:讲讲互斥锁、自旋锁吧

在介绍悲观锁和乐观锁之前,我们先看一下什么是锁。

生活中:锁在我们身边无处不在,比如我出门玩去了需要把门锁上,比如我需要把钱放到保险柜里面,必须上锁以保证我财产的安全。

如何用好锁是程序员的基本素养之一。多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,通常为了解决这一问题,都会在访问共享资源之前加锁。最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。

如果选对了合适的锁,则会大大提高系统的性能

如果选择了错误的锁,在一些高并发的场景下,可能会降低系统的性能,影响用户体验。为了选择合适的锁,不仅需要清楚知道加锁的成本开销有多大,还需要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。对症下药,才能减少锁对高并发性能的影响。

加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。

互斥锁

互斥锁(Mutex,全称 mutual exclusion)是为了来保护一个资源不会因为并发操作而引起冲突,比如多个线程去访问资源,线程 A 加锁成功,此时互斥锁已经被线程 A 独占了,此时线程 B 加锁会失败,因为线程 A 并没有释放掉锁,于是释放 CPU 给其他线程,而线程 B 加锁的代码就会被阻塞。

对此Go语言提供了很是简单易用的Mutex,Mutex为一结构体类型,对外暴露两个方法Lock()和Unlock()分别用于加锁和解锁

src/sync/mutex.go:Mutex定义了互斥锁的数据结构:

Mutex结构体

type Mutex struct {
	state int32
	sema  uint32
}
  • state:表示互斥锁的状态,好比是否被锁定等
  • sema:表示信号量,协程阻塞等待该信号量,解锁的协程释放信号量从而唤醒等待信号量的协程

Mutex.state结构图:

**加粗样式**

上面定义4个的含义:

  • Waiter: 表示阻塞等待锁的线程个数,线程解锁时根据此值来判断是否须要释放信号量
  • Starving:饥饿状态, 0:表示正常状态,1:表示饥饿状态,说明有线程阻塞了超过1ms
  • Woken: 唤醒状态,0:表示未唤醒 1:表示已唤醒,正在加锁过程当中
  • Locked: 加锁状态,0:表示为加锁1:表示已加锁

Mutex方法

  • Lock() : 加锁方法
  • Unlock(): 解锁方法

Lock() 代码详解


// Lock mutex 的锁方法。
func (m *Mutex) Lock() {
    // 快速上锁.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }
    // 快速上锁失败,将进行操作较多的上锁动作。
    m.lockSlow()
}

func (m *Mutex) lockSlow() {
  var waitStartTime int64  // 记录当前 goroutine 的等待时间
  starving := false // 是否饥饿
  awoke := false // 是否被唤醒
  iter := 0 // 自旋次数
  old := m.state // 当前 mutex 的状态
  for {
    // 当前 mutex 的状态已上锁,并且非饥饿模式,并且符合自旋条件
    if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
      // 当前还没设置过唤醒标识
      if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
        atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
        awoke = true
      }
      runtime_doSpin()
      iter++
      old = m.state
      continue
    }
    new := old
    // 如果不是饥饿状态,则尝试上锁
    // 如果是饥饿状态,则不会上锁,因为当前的 goroutine 将会被阻塞并添加到等待唤起队列的队尾
    if old&mutexStarving == 0 {
      new |= mutexLocked
    }
    // 等待队列数量 + 1
    if old&(mutexLocked|mutexStarving) != 0 {
      new += 1 << mutexWaiterShift
    }
    // 如果 goroutine 之前是饥饿模式,则此次也设置为饥饿模式
    if starving && old&mutexLocked != 0 {
      new |= mutexStarving
    }
    //
    if awoke {
      // 如果状态不符合预期,则报错
      if new&mutexWoken == 0 {
        throw("sync: inconsistent mutex state")
      }
      // 新状态值需要清除唤醒标识,因为当前 goroutine 将会上锁或者再次 sleep
      new &^= mutexWoken
    }
    // CAS 尝试性修改状态,修改成功则表示获取到锁资源
    if atomic.CompareAndSwapInt32(&m.state, old, new) {
      // 非饥饿模式,并且未获取过锁,则说明此次的获取锁是 ok 的,直接 return
      if old&(mutexLocked|mutexStarving) == 0 {
        break
      }
      // 根据等待时间计算 queueLifo
      queueLifo := waitStartTime != 0
      if waitStartTime == 0 {
        waitStartTime = runtime_nanotime()
      }
      // 到这里,表示未能上锁成功
      // queueLife = true, 将会把 goroutine 放到等待队列队头
      // queueLife = false, 将会把 goroutine 放到等待队列队尾
      runtime_SemacquireMutex(&m.sema, queueLifo, 1)
      // 计算是否符合饥饿模式,即等待时间是否超过一定的时间
      starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
      old = m.state
      // 上一次是饥饿模式
      if old&mutexStarving != 0 {
        if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
          throw("sync: inconsistent mutex state")
        }
        delta := int32(mutexLocked - 1<<mutexWaiterShift)
        // 此次不是饥饿模式又或者下次没有要唤起等待队列的 goroutine 了
        if !starving || old>>mutexWaiterShift == 1 {
          delta -= mutexStarving
        }
        atomic.AddInt32(&m.state, delta)
        break
      }
      // 此处已不再是饥饿模式了,清除自旋次数,重新到 for 循环竞争锁。
      awoke = true
      iter = 0
    } else {
      old = m.state
    }
  }if race.Enabled {
    race.Acquire(unsafe.Pointer(m))
  }
}

大致的流程:

  • 首先,如果 mutex 的 state = 0,即没有谁在占有资源,也没有阻塞等待唤起的 goroutine。则会调用 CAS 方法去尝试性占有锁,不做其他动作
  • 如果不符合 m.state = 0,则进一步判断是否需要自旋
  • 当不需要自旋又或者自旋后还是得不到资源时,此时会调用 runtime_SemacquireMutex 信号量函数,将当前的 goroutine 阻塞并加入等待唤起队列里
  • 当有锁资源释放,mutex 在唤起了队头的 goroutine 后,队头 goroutine 会尝试性的占有锁资源,而此时也有可能会和新到来的 goroutine 一起竞争
  • 当队头 goroutine 一直得不到资源时,则会进入饥饿模式,直接将锁资源交给队头 goroutine,让新来的 goroutine 阻塞并加入到等待队列的队尾里
  • 对于饥饿模式将会持续到没有阻塞等待唤起的 goroutine 队列时,才会解除

Unlock() 代码详解

// Unlock 对 mutex 解锁.
// 如果没有上过锁,缺调用此方法解锁,将会抛出运行时错误。
// 它将允许在不同的 Goroutine 上进行上锁解锁
func (m *Mutex) Unlock() {
    if race.Enabled {
        _ = m.state
        race.Release(unsafe.Pointer(m))
    }

    // 快速尝试解锁
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        // 快速解锁失败,将进行操作较多的解锁动作。
        m.unlockSlow(new)
    }
}

func (m *Mutex) unlockSlow(new int32) {
  // 非上锁状态,直接抛出异常
  if (new+mutexLocked)&mutexLocked == 0 {
    throw("sync: unlock of unlocked mutex")
  }
  // 正常模式
  if new&mutexStarving == 0 {
    old := new
    for {
      // 没有需要唤起的等待队列
      if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
        return
      }
      // 唤起等待队列并数量-1
      new = (old - 1<<mutexWaiterShift) | mutexWoken
      if atomic.CompareAndSwapInt32(&m.state, old, new) {
        runtime_Semrelease(&m.sema, false, 1)
        return
      }
      old = m.state
    }
  } else {
    //饥饿模式,将锁直接给等待队列的队头 goroutine
    runtime_Semrelease(&m.sema, true, 1)
  }
}

相对于lock()方法, Unlock() 则比较简单,会先进行快速的解锁,即没有等待唤起的 goroutine,则不需要继续做其他动作。

如果当前是正常模式,则简单的唤起队头 Goroutine。如果是饥饿模式,则会直接将锁交给队头 Goroutine,然后唤起队头 Goroutine,让它继续运行。

下面我们分析一下加锁和解锁的过程,加锁分红功和失败两种状况,成功的话直接获取锁,失败后当前线程被阻塞,一样,解锁时跟据是否有阻塞线程也有两种处理。

加解锁过程

加锁(Lock)

第一种情况:当前只有一个线程在加锁,没有其余线程操作

在这里插入图片描述
在加锁过程先去判断 Locked 标志位是否为 0,如果为 0 就把 Locked 置为 1,表示已经加锁成功。从上图可见,加锁成功后,只是 Locked 位置为 1,其余状态位没发生变化。

第二种情况:假设线程 B 想要加锁,但是锁已经被其他线程独占了

在这里插入图片描述
从上图可以看到,当线程 B 对一个已被占用的锁再次加锁时,Waiter 计数器增长为 1,此时线程 B 将被阻塞,直到Locked 值变为 0 后才会被唤醒。

解锁(Unlock)

第一种情况:当前只有一个线程在解锁,没有其余线程阻塞

在这里插入图片描述
因为没有其余线程阻塞等待加锁,所以解锁时只需要将 Locked 置为 0 就可以,不需要释放信号量。

第二种情况:加锁姐锁过程,有多个线程被阻塞了

在这里插入图片描述
线程 A 解锁过程分为两个步骤,一是把 Locked 置为 0,二是查看到 Waiter > 0,因此释放一个信号量,唤醒一个阻塞的协程,被唤醒的线程 B 把 Locked 置为 1,因而线程 B 得到锁。

上面只是说了 Waiter 和 Locked ,这里也说一下其他两个 starvation 和 Woken 作用

starvation状态

自旋过程当中能抢到锁,必定意味着同一时刻有线程释放了锁,释放锁时若是发现有阻塞等待的协程。还会释放一个信号量来唤醒一个等待线程,被唤醒的线程获得 CPU 后开始运行,此时发现锁已被抢占了,本身只好再次阻塞,不过阻塞前会判断自上次阻塞到本次阻塞通过了多长时间,若是超过1ms的话,会将Mutex标记为"饥饿"模式,而后再阻塞。

处于饥饿模式下,不会启动自旋过程,也即一旦有线程释放了锁,那么必定会唤醒其他线程,被唤醒的线程将会成功获取锁,同时也会把等待计数减1。

Woken状态

Woken 状态用于加锁和解锁过程的通讯,举个例子,同一时刻,两个线程一个在加锁,一个在解锁,在加锁的协程可能在自旋过程当中,此时把 Woken 标记为1,用于通知解锁线程没必要释放信号量了。

自旋锁

自旋过程

加锁时,若是当前 Locked 为 1,说明该锁当前由其余线程独占了,尝试加锁的线程并非立刻转入阻塞,而是会持续的检测 Locked 是否变为 0,这个过程即为自旋过程。

什么是自旋锁

当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别的线程占用,那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)。

解决自旋锁 CPU 占用

自旋锁的目的是占着 CPU 资源不进行释放,这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁,等到获取锁立即进行处理。但是如何去选择自旋时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!JDK在1.6 引入了适应性自旋锁,适应性自旋锁意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间。

自旋锁的优点

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!

自旋锁的缺点

如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 CPU 做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 CPU 的线程又不能获取到 CPU,造成 CPU 的浪费。所以这种情况下我们要关闭自旋锁。

互斥锁、自旋锁对于加锁失败后的处理方式

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

自旋锁与互斥锁使用上比较相似,但实现上完全不同:当加锁失败时,互斥锁用线程切换来应对,自旋锁则用忙等待来应对。

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值