在 go 中处理并发时有多种加锁方式,常见的有以下五种:
- Mutex
- RWMutex
- WaitGroup
- Once
- 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在获取锁时会有最高的优先级
下面对源码进行分析:
- 尝试获取锁,当
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()
- 初始化一些后续需要用到的变量
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源码分析)
- 根据相应条件设置当前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
}
- 通过原子操作尝试修改
m.state
的值,修改成功则会尝试去获取锁,修改失败表示与其他gorountine竞争失败,进入新一轮循环,继续上述操作。
sql
if atomic.CompareAndSwapInt32(&m.state, old, new) {
...
} else {
old = m.state
}
- 如果修改
m.state
状态成功且当前没有处于加锁状态或者饥饿状态的gorountine,则结束循环直接获取到锁
kotlin
if old&(mutexLocked|mutexStarving) == 0 {
break // locked the mutex with CAS
}
- 若此时有其他已加锁或饥饿状态的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
- 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
下面对源码进行分析:
- 首先会将
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)
}
}
- 校验异常情况。正常情况下
new
的mutexLocked
标志位已经变为了 0,(new+mutexLocked)&mutexLocked
理论上应该为 1,如果为 0 说明出现了加锁一次,解锁多次的异常情况
scss
if (new+mutexLocked)&mutexLocked == 0 {
fatal("sync: unlock of unlocked mutex")
}
- 尝试唤醒其他处于阻塞状态的 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
结构体,用于加解锁;writerSem
和readerSem
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,若此时没有 gorountine 在获取或持有写锁,直接获取读锁成功
- 若此时有 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
- 若此时有 gorountine 需要获取写锁,在校验没有出现异常情况且持有读锁的gorountine数量为0后,唤醒在阻塞队列中等待获取写锁的 gorountine
下面对源码进行分析:
- 将
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)
}
- 校验异常情况:
r+1==0
表示在未加锁的情况下进行了解锁,因此直接 fatalr+1 == -rwmutexMaxReaders
也表示在未加锁的情况下进行了解锁,因此直接 fatal
scss
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
race.Enable()
fatal("sync: RUnlock of unlocked RWMutex")
}
- 如果
rw.readerWait
减 1 后值为0,也就是当前没有 gorountine 持有读锁,此时释放写锁信号量,唤醒处于阻塞状态需要获取写锁的gorountine
scss
if rw.readerWait.Add(-1) == 0 {
runtime_Semrelease(&rw.writerSem, false, 1)
}
Lock
Lock() 用于获取写锁,其基本逻辑为:
- 尝试获取互斥锁
- 互斥锁获取成功后,修改
rw.readerCount
的值,用于声明当前有 gorountine 需要加写锁,此时其他 gorountine 不能再加读锁 - 如果此时仍有 gorountine 持有读锁,则将当前 gorountine 放在阻塞队列中等待读锁释放后将其唤醒
下面对源码进行分析:
- 加上
mutex
互斥锁,避免其他 gorountine 抢占写锁
scss
rw.w.Lock()
- 将
rw.readerCount
减去一个很大的值,声明此时有 gorountine 需要加写锁,此时其他 gorountine 不能加读锁
go
const rwmutexMaxReaders = 1 << 30
r := rw.readerCount.Add(-rwmutexMaxReaders) + rwmutexMaxReaders
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() 用于释放写锁,其基本逻辑为:
- 将读锁计数器减去
rwmutexMaxReaders
,声明此时可以获取读锁 - 若此时没有异常情况,唤醒所有在阻塞队列中想要获取读锁的 gorountine
- 释放互斥锁
下面对源码进行分析:
- 将
rw.readerCount
的值减去rwmutexMaxReaders
,声明此时其他gorountine可以去获取读锁了
css
r := rw.readerCount.Add(rwmutexMaxReaders)
- 校验重复解锁的异常情况
scss
if r >= rwmutexMaxReaders {
race.Enable()
fatal("sync: Unlock of unlocked RWMutex")
}
- 唤醒所有处于阻塞队列中需要获取读锁的 gorountine,使其获取到读锁
css
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
- 释放互斥锁
scss
rw.w.Unlock()
WaitGroup
WaitGroup 结构体主要由 state
和 sema
两个值组成,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 的个数,其基本逻辑为:
- 将
wg.state
的高32位增加 delta,delta 可以是正值也可以是负值 - 校验一些异常情况,保证 waitGroup 的使用方式是"先 Add 后 Wait",避免计数器混乱,影响唤醒 gorountine 的功能
- 若计算器数量(高32位)等于 0 且等待的 gorountine 数量(低32位)大于 0,则可以直接唤醒所有阻塞中的 gorountine,使其继续执行后续的代码逻辑
下面对源码进行分析:
- 将
wg.state
的高 32 位原子性的增加 delta
css
state := wg.state.Add(uint64(delta) << 32)
- 将
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")
}
- 若计算器数量大于 0 或者等待的 gorountine 数量等于 0,则说明当前未达到唤醒所有gorountine的条件或没有需要唤醒的 gorountine,因此直接返回即可
ini
if v > 0 || w == 0 {
return
}
- 校验是否存在并发调用 Add() 的情况,Add() 的并发调用可能会导致计数器混乱,影响唤醒 gorountine 的功能
scss
if wg.state.Load() != state {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
- 如果此时计算器数量等于 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 时,才会结束阻塞,继续执行后续代码。其基本逻辑为:
- 将
wg.state
的低 32 位加 1,增加等待中的 gorountine 数量 - 阻塞当前的 gorountine(一般在使用的过程中都只会调用一次 Wait()方法,也就是只有主 gorountine 会被阻塞)
下面对源码进行分析:
- 初始化变量,将
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
}
- 原子性的将
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
结构体主要由done
和m
两个值组成,done
表示锁的状态,m
就是Mutex
结构体,用于加解锁
bash
type Once struct {
done atomic.Uint32
m Mutex
}
Once 主要实现了 Do()
方法,用于保证函数只会被执行一次
下面对源码进行分析:
done
用于控制当前函数是否执行过,done
为 0 才去执行该函数,不为 0 则表示已经执行过,直接返回即可
scss
func (o *Once) Do(f func()) {
if o.done.Load() == 0 {
o.doSlow(f)
}
}
- 通过加锁的机制来避免并发情况下多次执行函数问题,当函数执行完成后会修改
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 读写共享数据(包括变量、数据库等) |
后续补充竞态与信号量相关概念