Go 中的加锁方式

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

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

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

Mutex

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

go 复制代码
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()方法
scss 复制代码
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. 初始化一些后续需要用到的变量
go 复制代码
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state

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

  • 如果没有加锁状态的gorountine,则无需自旋,直接获取锁;
  • 如果有饥饿状态的gorountine,则当前gorountine停止自旋,保证饥饿状态的gorountine先获取到锁。
scss 复制代码
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 中实现:

sql 复制代码
if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
	return
}

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

  1. 根据相应条件设置当前gorountine的状态,同时清除唤醒标记位
csharp 复制代码
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竞争失败,进入新一轮循环,继续上述操作。
sql 复制代码
if atomic.CompareAndSwapInt32(&m.state, old, new) {
	...
} else {
	old = m.state
}
  1. 如果修改m.state状态成功且当前没有处于加锁状态或者饥饿状态的gorountine,则结束循环直接获取到锁
kotlin 复制代码
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解锁唤醒后才继续执行。
ini 复制代码
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立即获取锁。
ini 复制代码
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有多少个。

ini 复制代码
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota

TryLock()

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

go 复制代码
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)进行进一步处理
go 复制代码
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 说明出现了加锁一次,解锁多次的异常情况
scss 复制代码
if (new+mutexLocked)&mutexLocked == 0 {
	fatal("sync: unlock of unlocked mutex")
}
  1. 尝试唤醒其他处于阻塞状态的 gorountine。如果 new&mutexStarving != 0,则说明当前有处于饥饿状态的 gorountine,则直接调用 runtime_Semrelease 方法唤醒阻塞中的 gorountine
sql 复制代码
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

go 复制代码
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 一节。

scss 复制代码
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

kotlin 复制代码
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 要加写锁
scss 复制代码
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
scss 复制代码
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
	race.Enable()
	fatal("sync: RUnlock of unlocked RWMutex")
}
  1. 如果rw.readerWait减 1 后值为0,也就是当前没有 gorountine 持有读锁,此时释放写锁信号量,唤醒处于阻塞状态需要获取写锁的gorountine
scss 复制代码
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 抢占写锁
scss 复制代码
rw.w.Lock()
  1. rw.readerCount减去一个很大的值,声明此时有 gorountine 需要加写锁,此时其他 gorountine 不能加读锁
go 复制代码
const rwmutexMaxReaders = 1 << 30

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

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

TryLock()

TryLock() 用于尝试获取写锁

下面对源码进行分析:

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

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

WaitGroup

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

go 复制代码
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
css 复制代码
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)
go 复制代码
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,因此直接返回即可
ini 复制代码
if v > 0 || w == 0 {
	return
}
  1. 校验是否存在并发调用 Add() 的情况,Add() 的并发调用可能会导致计数器混乱,影响唤醒 gorountine 的功能
scss 复制代码
if wg.state.Load() != state {
    panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
  1. 如果此时计算器数量等于 0 且等待的 gorountine 数量大于 0,重置 wg.state 的值,同时唤醒所有阻塞的gorountine
scss 复制代码
wg.state.Store(0)
for ; w != 0; w-- {
	runtime_Semrelease(&wg.sema, false, 0)
}

Done

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

scss 复制代码
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,不需要等待直接返回即可
go 复制代码
state := wg.state.Load()
v := int32(state >> 32)
w := uint32(state)
if v == 0 {
    return
}
  1. 原子性的将 wg.state 的值加 1(修改的是低32位的值),若修改成功,则使当前的 gorountine 陷入阻塞状态,等待计数器值为 0 时被唤醒
scss 复制代码
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结构体,用于加解锁

bash 复制代码
type Once struct {
	done atomic.Uint32
	m    Mutex
}

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

下面对源码进行分析:

  1. done用于控制当前函数是否执行过,done为 0 才去执行该函数,不为 0 则表示已经执行过,直接返回即可
scss 复制代码
func (o *Once) Do(f func()) {
	if o.done.Load() == 0 {
		o.doSlow(f)
	}
}
  1. 通过加锁的机制来避免并发情况下多次执行函数问题,当函数执行完成后会修改done的值,从而保证该函数不会多次执行
scss 复制代码
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 读写共享数据(包括变量、数据库等)

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

相关推荐
Pandaconda6 分钟前
【新人系列】Golang 入门(十三):结构体 - 下
后端·golang·go·方法·结构体·后端开发·值传递
Serverless社区3 小时前
MCP 正当时:FunctionAI MCP 开发平台来了!
go
楽码5 小时前
检查go语言变量内存结构
后端·go·计算机组成原理
快乐源泉8 小时前
【设计模式】适配器,已有功能扩展?你猜对了
后端·设计模式·go
zhuyasen19 小时前
首个与AI深度融合的Go开发框架sponge,解决Cursor/Trae等工具项目级开发痛点
后端·低代码·go
mayl1 天前
sync.Mutex 原理浅析
go
快乐源泉1 天前
【设计模式】状态模式,为何状态切换会如此丝滑?
后端·设计模式·go
我爱拉臭臭1 天前
趣味编程之go与rust的爱恨情仇
rust·go
迷茫运维路1 天前
K8S+Prometheus+Consul+alertWebhook实现全链路服务自动发现与监控、告警配置实战
运维·kubernetes·go·prometheus·consul
用户422190773431 天前
golang源码调试
go