Go 的加锁方式

269 阅读15分钟

在 go 中处理并发时有多种加锁方式,常见的有以下五种:

  1. Mutex
  2. RWMutex
  3. WaitGroup
  4. Once
  5. Redis SetNX

本文将会对以上5种加锁方式从源码层面进行分析,来说明其原理以及适用的场景

Mutex

Mutex 结构体由 state 和 sema 组成,state 表示当前锁的状态,用于控制 gorountine 是否可以获取到锁;sema 表示信号量,用于控制 gorountine 的阻塞和唤醒

type Mutex struct {
	state int32
	sema  uint32
}

Mutex 主要实现了三个方法:Lock()、TryLock()、UnLock()

Lock()

Lock 用于获取锁,其基本逻辑为:

  • 尝试获取锁,如果获取不到,为了避免频繁切换上下文,会先让当前的gorountine进入自旋,当其他gorountine释放锁以后,会先让陷入自旋的gorountine获取到锁,而不是唤醒其他被阻塞的gorountine
  • 为了避免有些gorountine一直陷入阻塞状态不被唤醒,就会将该gorountine设置为饥饿状态,饥饿状态的gorountine在获取锁时会有最高的优先级

下面对源码进行分析:

  1. 尝试获取锁,当m.state的值为 0 时,则获取锁成功,直接返回。此处CompareAndSwapInt32是一个原子操作,获取到锁以后,将m.state的值置为mutexLocked。若m.state的值不为0,则说明此时有处于加锁或饥饿状态的 gorountine,继续调用m.lockSlow()方法
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
		if race.Enabled {
			race.Acquire(unsafe.Pointer(m))
		}
		return
	}
	// Slow path (outlined so that the fast path can be inlined)
	m.lockSlow()
  1. 初始化一些后续需要用到的变量
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state

3.判断当前的gorountine能否进入自旋,old&(mutexLocked|mutexStarving) == mutexLocked用于判断当前是否有加锁或饥饿状态的gorountine。

  • 如果没有加锁状态的gorountine,则无需自旋,直接获取锁;
  • 如果有饥饿状态的gorountine,则当前gorountine停止自旋,保证饥饿状态的gorountine先获取到锁。
if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
	// Active spinning makes sense.
	// Try to set mutexWoken flag to inform Unlock
	// to not wake other blocked goroutines.
	if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
		atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
		awoke = true
	}
	runtime_doSpin()
	iter++
	old = m.state
	continue
}

!awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 用于判断当前是否有未处于阻塞状态的gorountine, 如果没有则将当前的gorountine设置为唤醒状态 atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken)。 设置为唤醒状态是为了避免再唤醒其他阻塞的gorountine,与当前还未进入到阻塞状态的gorountine竞争锁,减少上下文切换的性能损耗。此处唤醒的逻辑是在 unLock 中实现:

if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
	return
}

当存在处于加锁状态、唤醒状态或者饥饿状态的gorountine时,不再唤醒其他阻塞的gorountine(具体逻辑可以参看UnLock源码分析)

  1. 根据相应条件设置当前gorountine的状态,同时清除唤醒标记位
new := old
if old&mutexStarving == 0 {
	new |= mutexLocked
}
if old&(mutexLocked|mutexStarving) != 0 {
	new += 1 << mutexWaiterShift
}
if starving && old&mutexLocked != 0 {
	new |= mutexStarving
}
if awoke {
	// The goroutine has been woken from sleep,
	// so we need to reset the flag in either case.
	if new&mutexWoken == 0 {
		throw("sync: inconsistent mutex state")
	}
	new &^= mutexWoken
}
  1. 通过原子操作尝试修改m.state的值,修改成功则会尝试去获取锁,修改失败表示与其他gorountine竞争失败,进入新一轮循环,继续上述操作。
if atomic.CompareAndSwapInt32(&m.state, old, new) {
	...
} else {
	old = m.state
}
  1. 如果修改m.state状态成功且当前没有处于加锁状态或者饥饿状态的gorountine,则结束循环直接获取到锁
if old&(mutexLocked|mutexStarving) == 0 {
	break // locked the mutex with CAS
}
  1. 若此时有其他已加锁或饥饿状态的gorountine,则获取锁失败,继续执行下述逻辑,让gorountine进入阻塞状态。queueLifo := waitStartTime != 0用于表示当前的gorountine是否是已唤醒过的,如果是已唤醒过的,再次阻塞时则会将其放在阻塞队列的头部。接下来会设置gorountine的等待开始时间,用于判断当前的gorountine是否应该进入阻塞队列。runtime_SemacquireMutex(&m.sema, queueLifo, 1)表示使 gorountine 进入阻塞队列,进入阻塞队列后,当前的gorountine就不再继续执行后续逻辑,等待其他gorountine解锁唤醒后才继续执行。
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
	waitStartTime = runtime_nanotime()
}
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
  1. gorountine被唤醒后会先判断是否有处于饥饿状态的gorountine(唤醒时会优先唤醒处于饥饿状态的gorountine,正常情况下当前的gorountine就是处于饥饿状态的gorountine),如果有则会让处于饥饿状态的gorountine立即获取锁。
if old&mutexStarving != 0 {
   // 判断异常情况
	if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
		throw("sync: inconsistent mutex state")
	}
   // 相当于加上了 mutexLocked 标志位的值,同时减掉一个等待者计数
	delta := int32(mutexLocked - 1<<mutexWaiterShift)
   // 当前 gorountine 不是饥饿状态或者当前已经没有其他的gorountine在等待时,直接将饥饿标记位置为0
	if !starving || old>>mutexWaiterShift == 1 {
		delta -= mutexStarving
	}
        
	atomic.AddInt32(&m.state, delta)
	break
}
awoke = true
iter = 0

m.state的变更逻辑合在一起看就等于 m.state+mutexLocked-1<<mutexWaiterShift-mutexStarving,那么delta = mutexLocked-1<<mutexWaiterShift-mutexStarving

if !starving || old>>mutexWaiterShift == 1 此处增加一个判断逻辑,而不是直接将 m.state 的标志位置空,是为了让其他处于饥饿状态的gorountine也能立即被执行,不需要再与其他gorountine竞争

特殊说明:

前三个值通过位运算的方式来标记当前是否有加锁、唤醒、饥饿状态的gorountine,mutexWaiterShift 表示当前等待获取锁的gorountine有多少个。

mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota

TryLock()

TryLock()的作用是尝试获取锁,如果获取不到也不会陷入阻塞状态,而是直接返回false

func (m *Mutex) TryLock() bool {
	old := m.state
    // 如果当前有获取到锁或者处于饥饿状态的 gorountine,则获取锁失败,返回 false
	if old&(mutexLocked|mutexStarving) != 0 {
		return false
	}
    // 尝试与其他gorountine竞争锁,获取成功则修改 state 的值,获取失败直接返回 false
	if !atomic.CompareAndSwapInt32(&m.state, old, old|mutexLocked) {
		return false
	}

	if race.Enabled {
		race.Acquire(unsafe.Pointer(m))
	}
	return true
}

UnLock()

Unlock 用于解除锁,其基本逻辑为:

  • 修改状态值的锁标志位,解除锁
  • 如果当前没有处于唤醒状态、饥饿状态或者得到锁的gorountine,则唤醒其他阻塞中的 gorountine

下面对源码进行分析:

  1. 首先会将 m.state 的锁标志位置为0,同时判断当前是否还有其他gorountine在尝试获取锁,如果有则继续调用 m.unlockSlow(new)进行进一步处理
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)
	}
}
  1. 校验异常情况。正常情况下 newmutexLocked 标志位已经变为了 0,(new+mutexLocked)&mutexLocked 理论上应该为 1,如果为 0 说明出现了加锁一次,解锁多次的异常情况
if (new+mutexLocked)&mutexLocked == 0 {
	fatal("sync: unlock of unlocked mutex")
}

  1. 尝试唤醒其他处于阻塞状态的 gorountine。如果 new&mutexStarving != 0,则说明当前有处于饥饿状态的 gorountine,则直接调用 runtime_Semrelease 方法唤醒阻塞中的 gorountine
if new&mutexStarving == 0 {
	old := new
	for {
		if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
			return
		}
		new = (old - 1<<mutexWaiterShift) | mutexWoken
		if atomic.CompareAndSwapInt32(&m.state, old, new) {
			runtime_Semrelease(&m.sema, false, 1)
			return
		}
		old = m.state
	}
} else {
	runtime_Semrelease(&m.sema, true, 1)
}

如果没有处于阻塞状态的 gorountine,则判断 old>>mutexWaiterShift 是否为 0,为 0 则说明此时没有在等待中的 gorountine 了,不需要再做唤醒操作,直接返回即可。

另一方面,也会通过 old&(mutexLocked|mutexWoken|mutexStarving) 是否为 0 来判断当前是否有处于唤醒状态或者已经获取锁的 gorountine(在修改state状态解锁后,其他gorountine就可以尝试获取锁了,因此此处可能会存在 mutexLocked 或者 mutexStarving 标志位不为空的情况),如果有则不需要再做唤醒操作,直接返回即可。

如果此时仍有处于等待中的 gorountine,则尝试去修改 state 的状态,通过唤醒阻塞中的 gorountine。(old - 1<<mutexWaiterShift) | mutexWoken 表示将等待中的 gorountine 减1,同时设置唤醒标志位,避免继续唤醒其他 gorountine(通过(old&mutexWoken)!=0判断实现)

RWMutex

RWMutex 结构体主要由 w、writerSem、readerSem、readerCount、readerWait 五个值组成,w即为Mutex结构体,用于加解锁;writerSemreaderSem

type RWMutex struct {
	w           Mutex        // held if there are pending writers
	writerSem   uint32       // semaphore for writers to wait for completing readers
	readerSem   uint32       // semaphore for readers to wait for completing writers
        
	readerCount atomic.Int32 // number of pending readers
	readerWait  atomic.Int32 // number of departing readers
}

RWMutex 主要实现了三个方法:RLock()、TryRLock()、RUnlock()、Lock()、TryLock()、Unlock()

RLock()

RLock() 用于获取读锁,其基本逻辑为:

  1. 将读锁计数器加 1,若此时没有 gorountine 在获取或持有写锁,直接获取读锁成功
  2. 若此时有 gorountine 在获取或持有写锁,将当前的 gorountine 放在阻塞队列中等待唤醒

下面对源码进行分析:

rw.readerCount读锁计数器加 1,如果此时rw.readerCount小于 0,说明此时有其他 gorountine 在尝试获取写锁,此时不能再加读锁,具体逻辑可参看 Lock 一节。

if rw.readerCount.Add(1) < 0 {
	// A writer is pending, wait for it.
	runtime_SemacquireRWMutexR(&rw.readerSem, false, 0)
}

此时会将当前需要获取读锁的 gorountine 放在阻塞队列中,等待获取到写锁的 gorountine 被唤醒,唤醒逻辑可参考 UnLock 一节

TryRLock()

TryRLock() 用于尝试获取读锁,获取成功返回 true,获取失败返回false

下面对源码进行分析:

判断readerCount是否小于 0,小于 0 则表示当前有需要获取或持有写锁的 gorountine,获取读锁失败,直接返回 false。若不小于 0,则获取读锁成功,同时修改readerCount的值,返回 true

for {
	c := rw.readerCount.Load()
	if c < 0 {
		......
		return false
	}
	if rw.readerCount.CompareAndSwap(c, c+1) {
		......
		return true
	}
}

RUnlock

RUnlock() 用于释放读锁,其基本逻辑为:

  1. 将读锁计数器减 1
  2. 若此时有 gorountine 需要获取写锁,在校验没有出现异常情况且持有读锁的gorountine数量为0后,唤醒在阻塞队列中等待获取写锁的 gorountine

下面对源码进行分析:

  1. rw.readerCount(读锁计数器)的值减 1,同时判断其值是否小于 0,小于 0 则表示有 gorountine 要加写锁
if r := rw.readerCount.Add(-1); r < 0 {
	// Outlined slow-path to allow the fast-path to be inlined
	rw.rUnlockSlow(r)
}
  1. 校验异常情况:
  • r+1==0表示在未加锁的情况下进行了解锁,因此直接 fatal
  • r+1 == -rwmutexMaxReaders也表示在未加锁的情况下进行了解锁,因此直接 fatal
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
	race.Enable()
	fatal("sync: RUnlock of unlocked RWMutex")
}
  1. 如果rw.readerWait减 1 后值为0,也就是当前没有 gorountine 持有读锁,此时释放写锁信号量,唤醒处于阻塞状态需要获取写锁的gorountine
if rw.readerWait.Add(-1) == 0 {
	runtime_Semrelease(&rw.writerSem, false, 1)
}

Lock

Lock() 用于获取写锁,其基本逻辑为:

  1. 尝试获取互斥锁
  2. 互斥锁获取成功后,修改rw.readerCount的值,用于声明当前有 gorountine 需要加写锁,此时其他 gorountine 不能再加读锁
  3. 如果此时仍有 gorountine 持有读锁,则将当前 gorountine 放在阻塞队列中等待读锁释放后将其唤醒

下面对源码进行分析:

  1. 加上mutex互斥锁,避免其他 gorountine 抢占写锁
rw.w.Lock()
  1. rw.readerCount减去一个很大的值,声明此时有 gorountine 需要加写锁,此时其他 gorountine 不能加读锁
const rwmutexMaxReaders = 1 << 30

r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
  1. r != 0表示当前还有 gorountine 持有读锁rw.readerWait.Add(r)!=0有两个用途:
  • 用于修改 readerWait的值,表示当前还有多少 gorountine 持有读锁
  • 避免在并发情况下rw.readerWait的值被修改(例如读锁已被释放),导致当前的 gorountine 一直处于阻塞状态无法被唤醒
if r != 0 && rw.readerWait.Add(r) != 0 {
	runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
}

如果当前仍有 gorountine 持有读锁,则将当前想要获取写锁的 gorountine 放到阻塞队列中等待唤醒,可参考 RUnlock 一节

TryLock()

TryLock() 用于尝试获取写锁

下面对源码进行分析:

尝试获取互斥锁,获取互斥锁成功后,判断当前是否有持有读锁的 gorountine,如果有则释放互斥锁,同时返回false。如果没有则获取写锁成功,修改 readerCount 的值同时返回 true

if !rw.w.TryLock() {
	......
	return false
}
if !rw.readerCount.CompareAndSwap(0, -rwmutexMaxReaders) {
	rw.w.Unlock()
	......
	return false
}
return true

Unlock

Unlock() 用于释放写锁,其基本逻辑为:

  1. 将读锁计数器减去 rwmutexMaxReaders,声明此时可以获取读锁
  2. 若此时没有异常情况,唤醒所有在阻塞队列中想要获取读锁的 gorountine
  3. 释放互斥锁

下面对源码进行分析:

  1. rw.readerCount的值减去rwmutexMaxReaders,声明此时其他gorountine可以去获取读锁了
r := rw.readerCount.Add(rwmutexMaxReaders)
  1. 校验重复解锁的异常情况
if r >= rwmutexMaxReaders {
	race.Enable()
	fatal("sync: Unlock of unlocked RWMutex")
}
  1. 唤醒所有处于阻塞队列中需要获取读锁的 gorountine,使其获取到读锁
for i := 0; i < int(r); i++ {
	runtime_Semrelease(&rw.readerSem, false, 0)
}
  1. 释放互斥锁
rw.w.Unlock()

RLocker

RLocker 方法返回了一个Locker接口,该接口是一个通用化的接口,很多结构体都实现了该接口,例如 Mutex

RLocker 方法将RWMutex类型强制转换为了rlocker类型,rlocker重新实现了 Lock()UnLock()方法,提供了一个使用Locker接口可以直接获取读锁的方式

type Locker interface {
	Lock()
	Unlock()
}

func (rw *RWMutex) RLocker() Locker {
	return (*rlocker)(rw)
}

type rlocker RWMutex

func (r *rlocker) Lock()   { (*RWMutex)(r).RLock() }
func (r *rlocker) Unlock() { (*RWMutex)(r).RUnlock() }

WaitGroup

WaitGroup 结构体主要由 statesema 两个值组成,state 表示锁的状态,其中高32位表示计数器,低32位表示等待的 goroutine 数量,计数器为 0 时会唤醒所有等待中的 gorountine

type WaitGroup struct {
	noCopy noCopy

	state atomic.Uint64 // high 32 bits are counter, low 32 bits are waiter count.
	sema  uint32
}

WaitGroup 主要实现了三个方法:Add()Done()Wait()

Add()

Add(delta) 方法用于增加阻塞的 gorountine 的个数,其基本逻辑为:

  1. wg.state 的高32位增加 delta,delta 可以是正值也可以是负值
  2. 校验一些异常情况,保证 waitGroup 的使用方式是“先 Add 后 Wait”,避免计数器混乱,影响唤醒 gorountine 的功能
  3. 若计算器数量(高32位)等于 0 且等待的 gorountine 数量(低32位)大于 0,则可以直接唤醒所有阻塞中的 gorountine,使其继续执行后续的代码逻辑

下面对源码进行分析:

  1. wg.state 的高 32 位原子性的增加 delta
state := wg.state.Add(uint64(delta) << 32)
  1. wg.state 的高 32 位(计数器数量)设置为 v,低 32 位(等待的gorountine数量)设置为 w,然后校验异常情况:
  • 计数器的数量不能小于 0(v < 0
  • 防止 Add() 和 Wait() 并发调用导致的数据竞争,同时确保 WaitGroup 的使用遵循"先 Add 后 Wait"的顺序以及避免计数器被意外重置或错误递增(w!= 0 && delta > 0 && v == int32(delta)
v := int32(state >> 32)
w := uint32(state)
if v < 0 {
	panic("sync: negative WaitGroup counter")
}
if w != 0 && delta > 0 && v == int32(delta) {
	panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
  1. 若计算器数量大于 0 或者等待的 gorountine 数量等于 0,则说明当前未达到唤醒所有gorountine的条件或没有需要唤醒的 gorountine,因此直接返回即可
if v > 0 || w == 0 {
	return
}
  1. 校验是否存在并发调用 Add() 的情况,Add() 的并发调用可能会导致计数器混乱,影响唤醒 gorountine 的功能
if wg.state.Load() != state {
    panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
  1. 如果此时计算器数量等于 0 且等待的 gorountine 数量大于 0,重置 wg.state 的值,同时唤醒所有阻塞的gorountine
wg.state.Store(0)
for ; w != 0; w-- {
	runtime_Semrelease(&wg.sema, false, 0)
}

Done

Done()方法就是 Add(-1)

func (wg *WaitGroup) Done() {
	wg.Add(-1)
}

Wait

Wait() 方法用于阻塞 gorountine ,只有计数器为 0 唤醒所有等待中的 gorountine 时,才会结束阻塞,继续执行后续代码。其基本逻辑为:

  1. wg.state 的低 32 位加 1,增加等待中的 gorountine 数量
  2. 阻塞当前的 gorountine(一般在使用的过程中都只会调用一次 Wait()方法,也就是只有主 gorountine 会被阻塞

下面对源码进行分析:

  1. 初始化变量,将 wg.state 的高 32 位(计数器数量)设置为 v,低 32 位(等待的gorountine数量)设置为 w,如果 v 为 0,说明当前计数器为 0,不需要等待直接返回即可
state := wg.state.Load()
v := int32(state >> 32)
w := uint32(state)
if v == 0 {
    return
}
  1. 原子性的将 wg.state 的值加 1(修改的是低32位的值),若修改成功,则使当前的 gorountine 陷入阻塞状态,等待计数器值为 0 时被唤醒
for {
    ......
	if wg.state.CompareAndSwap(state, state+1) {
		runtime_Semacquire(&wg.sema)
		if wg.state.Load() != 0 {
			panic("sync: WaitGroup is reused before previous Wait has returned")
		}
		......
		return
	}
}

被唤醒时 wg.state 的值一定会被置为 0,因此需要进一步校验wg.state.Load() != 0 的情况

Once

Once 结构体主要由donem两个值组成,done表示锁的状态,m就是Mutex结构体,用于加解锁

type Once struct {
	done atomic.Uint32
	m    Mutex
}

Once 主要实现了 Do() 方法,用于保证函数只会被执行一次

下面对源码进行分析:

  1. done用于控制当前函数是否执行过,done为 0 才去执行该函数,不为 0 则表示已经执行过,直接返回即可
func (o *Once) Do(f func()) {
	if o.done.Load() == 0 {
		o.doSlow(f)
	}
}
  1. 通过加锁的机制来避免并发情况下多次执行函数问题,当函数执行完成后会修改done的值,从而保证该函数不会多次执行
func (o *Once) doSlow(f func()) {
	o.m.Lock()
	defer o.m.Unlock()
	if o.done.Load() == 0 {
		defer o.done.Store(1)
		f()
	}
}

Redis SetNX

Redis SetNX 是分布式锁的实现方式,主要依赖 redis 的单线程执行来实现分布式并发情况下加解锁

适用场景对比

锁名称适用场景
Mutex适用于非分布式场景的加解锁,保证同一时刻只有一个 gorountine 读写共享数据(无法处理并发读写数据库的场景)
RWMutex适用于读多写少的场景,在该场景下性能高于 Mutex,同时还保证了不会读到脏数据
Once适用于一次函数只能执行一次的场景
WaitGroup适用于多个 gorountine 可以同时执行,但必须等所有 gorountine 都执行完才能执行后续步骤的场景
Redis SetNX适用于分布式场景的加解锁,保证同一时刻只有一个 gorountine 读写共享数据(包括变量、数据库等)

后续补充竞态与信号量相关概念