在介绍悲观锁和乐观锁之前,我们先看一下什么是锁。
锁
生活中:锁在我们身边无处不在,比如我出门玩去了需要把门锁上,比如我需要把钱放到保险柜里面,必须上锁以保证我财产的安全。
如何用好锁是程序员的基本素养之一。多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,通常为了解决这一问题,都会在访问共享资源之前加锁。最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。
如果选对了合适的锁,则会大大提高系统的性能
如果选择了错误的锁,在一些高并发的场景下,可能会降低系统的性能,影响用户体验。为了选择合适的锁,不仅需要清楚知道加锁的成本开销有多大,还需要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。对症下药,才能减少锁对高并发性能的影响。
加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
互斥锁
互斥锁(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 ,给其他线程
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁
自旋锁与互斥锁使用上比较相似,但实现上完全不同:当加锁失败时,互斥锁用线程切换来应对,自旋锁则用忙等待来应对。