当提到并发编程、多线程编程时,我们往往都离不开『锁』这一概念,Go 语言作为一个原生支持用户态进程 Goroutine 的语言,也一定会为开发者提供这一功能,锁的主要作用就是保证多个线程或者 Goroutine 在访问同一片内存时不会出现混乱的问题,这些功能都位于sync包下。
WaitGroup
开发中会经常遇到让主Goroutine等待子Goroutine执行完再执行的场景,该如何实现这种效果呢。
第一种方式直接通过time.Sleep()让主Goroutine等待一段时间以便子Goroutine能够执行完成。这种方式简单粗暴实现简单,但是不建议,因为不知道子Goroutine要多久才能执行完成,不能确切的知道需要等待多久。
第二种方式通过channel实现
func Test_waitGroup(t *testing.T) {
// 并发任务数
tasksNum := 10
ch := make(chan struct{}, tasksNum)
for i := 0; i < tasksNum; i++ {
go func() {
defer func() {
ch <- struct{}{}
}()
// working
<-time.After(time.Second)
}()
}
// 等待 10 个 goroutine 完成任务
for i := 0; i < tasksNum; i++ {
<-ch
}
// do next
// ...
}
这种方式相对于直接使用time.Sleep()来说优雅了不少,但是缺点也很明显,主 goroutine 需要在一开始就明确启动的子 goroutine 数量,从而建立好对应容量的 channel,以及设定执行 for 循环接收信号量的次数. 这样的设定不够灵活。
第三种方式使用sync.WaitGroup实现
func Test_waitGroup(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-time.After(time.Second)
}()
}
wg.Wait()
//do next
}
通过 WaitGroup
我们可以在多个 Goroutine 之间非常轻松地同步信息,原本顺序执行的代码也可以在多个 Goroutine 中并发执行,加快了程序处理的速度,在上述代码中只有在所有的 Goroutine 都执行完毕之后 Wait
方法才会返回,程序可以继续执行其他的逻辑。
数据结构
type WaitGroup struct {
noCopy noCopy
state atomic.Uint64 // high 32 bits are counter, low 32 bits are waiter count.
sema uint32
}
noCopy
,这个东西是为了告诉编译器,WaitGroup
结构体对象不可复制,即 wg2 := wg
是非法的。之所以禁止复制,是为了防止可能发生的死锁。
state
是 WaitGroup
的核心,它是一个无符号的 64 位整型,并且用的是 atomic
包中的 Uint64
,所以 state
本身是线程安全的。至于 atomic.Uint64
为什么能保证线程安全,因为它使用了 CompareAndSwap(CAS)
操作,而这个操作依赖于 CPU 提供的原子性指令,是 CPU 级的原子操作。state
的高 32 位是计数器(counter),低 32 位是等待者数量(waiters)。其中计数器其实就是 Add(int)
数量的总和,譬如 Add(1)
后再 Add(2)
,那么这个计数器就是 1 + 2 = 3;而等待数量就是现在有多少 goroutine 在执行 Wait()
等待 WaitGroup
被释放。
基本操作
WaitGroup
对外暴露的接口只有三个 Add
、Wait
和 Done
,其中 Done
方法只是调了 wg.Add(-1)
本身并没有什么特殊的逻辑,我们来了解一下剩余的两个方法:
// Add 方法将 delta 值加上计数器,delta 可以为负数。如果计数器变为 0,
// 则所有在 Wait 上阻塞的 Goroutine 都会被释放。
// 如果计数器变为负数,则 Add 方法会 panic。
//
// 注意:当计数器为 0 时调用 delta 值为正数的 Add 方法必须在 Wait 方法之前执行。
// 而 delta 值为负数或者 delta 值为正数但计数器大于 0 时,则可以在任何时间点执行。
// 通常情况下,这意味着应该在创建 Goroutine 或其他等待事件的语句之前执行 Add 方法。
// 如果一个 WaitGroup 用于等待多组独立的事件,
// 那么必须在所有先前的 Wait 调用返回之后再进行新的 Add 调用。
// 详见 WaitGroup 示例代码。
func (wg *WaitGroup) Add(delta int) {
// 将 int32 的 delta 变成 unint64 后左移 32 位再与 state 累加。
// 相当于将 delta 与 state 的高 32 位累加。
state := wg.state.Add(uint64(delta) << 32)
// 高 32 位,就是 counter,计数器
v := int32(state >> 32)
// 低 32 位,就是 waiters,等待者数量
w := uint32(state)
// 计数器为负数时直接 panic
if v < 0 {
panic("sync: negative WaitGroup counter")
}
// 当 Wait 和 Add 并发执行时,会有概率触发下面的 panic
if w != 0 && delta > 0 && v == int32(delta) {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
// 如果计数器大于 0,或者没有任何等待者,即没有任何 goroutine 在 Wait(),那么就直接返回
if v > 0 || w == 0 {
return
}
// 当 waiters > 0 时,这个 Goroutine 将计数器设置为 0。
// 现在不可能有对状态的并发修改:
// - Add 方法不能与 Wait 方法同时执行,
// - Wait 不会在看到计数器为 0 时增加等待者。
// 仍然需要进行简单的健全性检查来检测 WaitGroup 的误用情况。
if wg.state.Load() != state {
panic("sync: WaitGroup misuse: Add called concurrently with Wait")
}
// 重置 state 为 0
wg.state.Store(0)
// 唤醒所有等待者
for ; w != 0; w-- {
// 使用信号量控制唤醒等待者
runtime_Semrelease(&wg.sema, false, 0)
}
}
Add
方法的主要作用就是更新 WaitGroup
中持有的计数器 counter
,64 位状态的高 32 位,虽然 Add
方法传入的参数可以为负数,但是一个 WaitGroup
的计数器只能是非负数,当调用 Add
方法导致计数器归零并且还有等待的 Goroutine 时,就会通过 runtime_Semrelease
唤醒处于等待状态的所有 Goroutine。
// Wait 会阻塞,直到计数器为 0。
func (wg *WaitGroup) Wait() {
for {
state := wg.state.Load()
v := int32(state >> 32) // 计数器
w := uint32(state) // 等待者数量
if v == 0 {
// 计数器为 0,直接返回。
return
}
// 增加等待者数量
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
}
}
}
另一个 WaitGroup
的方法 Wait
就会在当前计数器中保存的数据大于 0
时修改等待 Goroutine 的个数 waiter
并调用 runtime_Semacquire
陷入睡眠状态,陷入睡眠的 Goroutine 就会等待 Add
方法在计数器为 0
时唤醒。
小结
通过对 WaitGroup
的分析和研究,我们能够得出以下的一些结论:
-
Add
不能在和Wait
方法在 Goroutine 中并发调用,一旦出现就会造成程序崩溃; -
WaitGroup
必须在Wait
方法返回之后才能被重新使用; -
Done
只是对Add
方法的简单封装,我们可以向Add
方法传入任意负数(需要保证计数器非负)快速将计数器归零以唤醒其他等待的 Goroutine; -
可以同时有多个 Goroutine 等待当前
WaitGroup
计数器的归零,这些 Goroutine 也会被『同时』唤醒;
Mutex
互斥锁是一种用于多线程编程中,防止两条线程同时对同一公共资源进行读写的机制。Golang的sync.mutex就是一种互斥锁的实现。Golang号称是为了高并发而生的,在高并发场景下,势必会涉及到对公共资源的竞争。互斥锁是在并发程序中对共享资源进行访问控制的主要手段。对此Golang提供了简单易用的Mutex。
数据结构
Mutex由两个字段state和sema组成,state表示当前互斥锁的状态,sema用于控制锁状态的信号。
type Mutex struct {
state int32
sema uint32
}
- state:锁中最核心的状态字段,不同 bit 位分别存储了 mutexLocked(是否上锁)、mutexWoken(是否有 goroutine 从阻塞队列中被唤醒)、mutexStarving(是否处于饥饿模式)的信息。
- sema:用于阻塞和唤醒 goroutine 的信号量。
const (
mutexLocked = 1 << iota // mutex is locked
mutexWoken
mutexStarving
mutexWaiterShift = iota
starvationThresholdNs = 1e6
)
- mutexLocked = 1:state 最右侧的一个 bit 位标志是否上锁,0-未上锁,1-已上锁;
- mutexWoken = 2:state 右数第二个 bit 位标志是否有 goroutine 从阻塞中被唤醒,0-没有,1-有;
- mutexStarving = 4:state 右数第三个 bit 位标志 Mutex 是否处于饥饿模式,0-非饥饿,1-饥饿;
- mutexWaiterShift = 3:右侧存在 3 个 bit 位标识特殊信息,分别为上述的 mutexLocked、mutexWoken、mutexStarving;
- starvationThresholdNs = 1 ms:sync.Mutex 进入饥饿模式的等待时间阈值;
两种模式
互斥锁可以同时处于两种不同的模式,也就是正常模式和饥饿模式,在正常模式下,所有锁的等待者都会按照先进先出的顺序获取锁,但是如果一个刚刚被唤起的 Goroutine 遇到了新的 Goroutine 进程也调用了 Lock
方法时,大概率会获取不到锁,为了减少这种情况的出现,防止 Goroutine 被『饿死』,一旦 Goroutine 超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式。
在饥饿模式中,互斥锁会被直接交给等待队列最前面的Goroutine,新的Goroutine在这时不能获取锁,也不会进入自旋的状态,会直接放入等待队列的末尾。如果一个Goroutine获取了互斥锁并且是队列中最末尾的协程或者他的等待时间少于1ms那么当前互斥锁就会被切换为正常模式。
相对于饥饿模式,正常模式下的互斥锁能够提供更好的性能,饥饿模式的主要作用就是避免一些Goroutine由于陷入等待而无法获取锁而造成较高的延时,这也是对Mutex的一个优化。
加锁流程
func (m *Mutex) Lock() {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}
首先进行一轮 CAS 操作,假如当前未上锁且锁内不存在阻塞协程,则直接 CAS 抢锁成功返回,第一轮初探失败,则进入 lockSlow 流程;
Mutex.lockSlow()
func (m *Mutex) lockSlow() {
var waitStartTime int64
starving := false
awoke := false
iter := 0
old := m.state
// waitStartTime:标识当前 goroutine 在抢锁过程中的等待时长,单位:ns;
// starving:标识当前是否处于饥饿模式;
// awoke:是否有Goroutine被唤醒;
// iter:标识当前 goroutine 参与自旋的次数;
// old:临时存储锁的 state 值;
for {
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
}
- 第一个if要满足的三个条件:1.锁已经被占用,2.锁为正常模式,3.满足自旋条件,都满足进入自旋后处理环节。
- 第二个if要满足的四个条件:1.没有Goroutine被唤醒、2.mutexWoken等于0、3.有等待的Goroutine、4.成功将mutexWoken设置为1避免再有其他协程被唤醒和自己抢锁,满足则将局部变量 awoke 置为 true。
- 调用 runtime_doSpin 告知调度器 P 当前处于自旋模式;
- 更新自旋次数 iter 和锁状态值 old;
- 通过 continue 语句进入下一轮尝试;
func (m *Mutex) lockSlow() {
// ...
for {
// 自旋抢锁失败后处理 ...
new := old
if old&mutexStarving == 0 {
//如果当前不是饥饿模式(mutexStarving 位为 0),则尝试通过在新状态中添加 mutexLocked 位来加锁
new |= mutexLocked
}
if old&(mutexLocked|mutexStarving) != 0 {
//如果锁当前被持有(mutexLocked 位为 1)或处于饥饿模式(mutexStarving 位为 1),则增加等待者计
//数。这是通过将 1 左移 mutexWaiterShift 位(这是等待者计数在状态字中的位置)来实现的,并将结果
//加到新状态上。
new += 1 << mutexWaiterShift
}
if starving && old&mutexLocked != 0 {
//如果当前 goroutine 处于饥饿模式(starving 为 true)并且锁当前被持有(mutexLocked 位为 1),则g更新为饥饿模式
new |= mutexStarving
}
if awoke {
//倘若局部变量标识是已有唤醒协程抢锁,说明 Mutex.state 中的 mutexWoken 是被当前 gouroutine 置为 1 的,但由于当前 goroutine 接下来要么抢锁成功,要么被阻塞挂起,因此需要在新值中将该 mutexWoken 标识更新置 0.
new &^= mutexWoken
}
// ...
}
}
func (m *Mutex) lockSlow() {
// ...
for {
// 自旋抢锁失败后处理 ...
// new old 状态值更新 ...
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// case1 加锁成功
// case2 将当前协程挂起
// ...
}else {
old = m.state
}
// ...
}
}
- 通过 CAS 操作,用构造的新值替换旧值;
- 倘若失败(即旧值被其他协程介入提前修改导致不符合预期),则将旧值更新为此刻的 Mutex.State,并开启一轮新的循环;
- 倘若 CAS 替换成功,则进入最后一轮的二择一局面:I 倘若当前 goroutine 加锁成功,则返回;II 倘若失败,则将 goroutine 挂起添加到阻塞队列;
func (m *Mutex) lockSlow() {
// ...
for {
// 自旋抢锁失败后处理 ...
// new old 状态值更新 ...
if atomic.CompareAndSwapInt32(&m.state, old, new) {
if old&(mutexLocked|mutexStarving) == 0 {
break
}
// ...
}
// ...
}
}
- 诺成功将 Mutex.state 由旧值替换为新值,接下来进行判断,倘若旧值是未加锁状态且为正常模式,则意味着加锁标识位正是由当前 goroutine 完成的更新,说明加锁成功,返回即可;
- 倘若旧值中锁未释放或者处于饥饿模式,则当前 goroutine 需要进入阻塞队列挂起.
func (m *Mutex) lockSlow() {
// ...
for {
// 自旋抢锁失败后处理 ...
// new old 状态值更新 ...
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 加锁成功后返回的逻辑分支 ...
queueLifo := waitStartTime != 0
if waitStartTime == 0 {
waitStartTime = runtime_nanotime()
}
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// ...
}
// ...
}
}
承接上节,走到此处的情形有两种:要么是抢锁失败,要么是锁已处于饥饿模式,而当前 goroutine 不是从阻塞队列被唤起的协程. 不论处于哪种情形,当前 goroutine 都面临被阻塞挂起的命运.
- 基于 queueLifo 标识当前 goroutine 是从阻塞队列被唤起的老客还是新进流程的新客;
- 倘若等待的起始时间为零,则为新客;倘若非零,则为老客;
- 倘若是新客,则对等待的起始时间进行更新,置为当前时刻的 ns 时间戳;
- 将当前协程添加到阻塞队列中,倘若是老客则挂入队头;倘若是新客,则挂入队尾;
- 挂起当前协程;
func (m *Mutex) lockSlow() {
// ...
for {
// 自旋抢锁失败后处理...
// new old 状态值更新 ...
if atomic.CompareAndSwapInt32(&m.state, old, new) {
// 加锁成功后返回的逻辑分支 ...
// 挂起前处理 ...
runtime_SemacquireMutex(&m.sema, queueLifo, 1)
// 从阻塞队列被唤醒了
starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
old = m.state
if old&mutexStarving != 0 {
delta := int32(mutexLocked - 1<<mutexWaiterShift)
if !starving || old>>mutexWaiterShift == 1 {
delta -= mutexStarving
}
atomic.AddInt32(&m.state, delta)
break
}
awoke = true
iter = 0
}
// ...
}
}
runtime_SemacquireMutex
方法的主要作用就是通过 Mutex
的使用互斥锁中的信号量保证资源不会被两个 Goroutine 获取,从这里我们就能看出 Mutex
其实就是对更底层的信号量进行封装,对外提供更加易用的 API,runtime_SemacquireMutex
会在方法中不断调用 goparkunlock
将当前 Goroutine 陷入休眠等待信号量可以被获取。
一旦当前 Goroutine 可以获取信号量,就证明互斥锁已经被解锁,该方法就会立刻返回,Lock
方法的剩余代码也会继续执行下去了,当前互斥锁处于饥饿模式时,如果该 Goroutine 是队列中最后的一个 Goroutine 或者等待锁的时间小于 starvationThresholdNs(1ms)
,当前 Goroutine 就会直接获得互斥锁并且从饥饿模式中退出并获得锁。
解锁流程
func (m *Mutex) Unlock() {
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
m.unlockSlow(new)
}
}
通过原子操作解锁,倘若解锁时发现,目前参与竞争的仅有自身一个 goroutine,则直接返回即可,倘若发现锁中还有阻塞协程,则走入 unlockSlow 分支。
func (m *Mutex) unlockSlow(new int32) {
if (new+mutexLocked)&mutexLocked == 0 {
fatal("sync: unlock of unlocked mutex")
}
// ...
}
解锁时倘若发现 Mutex 此前未加锁,直接抛出 fatal.
func (m *Mutex) unlockSlow(new int32) {
// ...
if new&mutexStarving == 0 {
old := new
for {
//倘若阻塞队列内无 goroutine 或者 mutexLocked、mutexStarving、mutexWoken 标识位任一不为零,三者均说明此时有其他活跃协程已介入,自身无需关心后续流程
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
}
}
// ...
}
如果当前互斥锁的状态是饥饿模式就会直接调用 runtime_Semrelease
方法直接将当前锁交给下一个正在尝试获取锁的等待者,等待者会在被唤醒之后设置 mutexLocked
状态,由于此时仍然处于 mutexStarving
,所以新的 Goroutine 也无法获得锁。
在正常模式下,如果当前互斥锁不存在等待者或者最低三位表示的状态都为 0
,那么当前方法就不需要唤醒其他 Goroutine 可以直接返回,当有 Goroutine 正在处于等待状态时,还是会通过 runtime_Semrelease
唤醒对应的 Goroutine 并移交锁的所有权。
RWMutex
读写互斥锁也是 Go 语言 sync
包为我们提供的接口之一,一个常见的服务对资源的读写比例会非常高,如果大多数的请求都是读请求,它们之间不会相互影响,那么我们为什么不能将对资源读和写操作分离呢?这也就是 RWMutex
读写互斥锁解决的问题,不限制对资源的并发读,但是读写、写写操作无法并行执行。
应用场景
- 典型应用场景就是读多写少
- 一写多读
数据结构
type RWMutex struct {
// Mutex,互斥锁。写者互斥锁,所有的写者加锁都调用w.Lock或者w.TryLock
w Mutex
// 写者信号量。当最后一个读者释放了锁,会触发一个信号通知writerSem
writerSem uint32
// 读者信号量。当写者释放了锁,会触发一个信号通知readerSem
readerSem uint32
// readerCount 记录当前持有读锁的协程数量。如果为负数,表示有写者在等待所有读者释放锁。如果为0,表示没有任何协程持有锁
readerCount atomic.Int32
// readerWait 记录写者需要等待的读者数量。当一个写者获取了锁之后,readerWait会设置为当前readerCount的值。当读者释放锁时,readerWait会递减1
readerWait atomic.Int32
}
读锁
读锁的加锁非常简单,通过 atomic.AddInt32
方法为 readerCount
加一,如果该方法返回了负数说明当前有 Goroutine 获得了写锁,当前 Goroutine 就会调用 runtime_SemacquireMutex
陷入休眠等待唤醒:
func (rw *RWMutex) RLock() {
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
}
如果没有写操作获取当前互斥锁,当前方法就会在 readerCount
加一后返回;当 Goroutine 想要释放读锁时会调用 RUnlock
方法:
func (rw *RWMutex) RUnlock() {
if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
rw.rUnlockSlow(r)
}
}
该方法会在减少正在读资源的 readerCount
,当前方法如果遇到了返回值小于零的情况,说明有一个正在进行的写操作,在这时就应该通过 rUnlockSlow
方法减少当前写操作等待的读操作数 readerWait
并在所有读操作都被释放之后触发写操作的信号量 writerSem
:
func (rw *RWMutex) rUnlockSlow(r int32) {
if r+1 == 0 || r+1 == -rwmutexMaxReaders {
throw("sync: RUnlock of unlocked RWMutex")
}
if atomic.AddInt32(&rw.readerWait, -1) == 0 {
runtime_Semrelease(&rw.writerSem, false, 1)
}
}
writerSem
在被触发之后,尝试获取读写锁的进程就会被唤醒并获得锁。
写锁
当资源的使用者想要获取读写锁时,就需要通过 Lock
方法了,在 Lock
方法中首先调用了读写互斥锁持有的 Mutex
的 Lock
方法保证其他获取读写锁的 Goroutine 进入等待状态,随后的 atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders)
其实是为了阻塞后续的读操作:
func (rw *RWMutex) Lock() {
rw.w.Lock()
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
runtime_SemacquireMutex(&rw.writerSem, false, 0)
}
}
如果当前仍然有其他 Goroutine 持有互斥锁的读锁,该 Goroutine 就会调用 runtime_SemacquireMutex
进入休眠状态,等待读锁释放时触发 writerSem
信号量将当前协程唤醒。
对资源的读写操作完成之后就会将通过 atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
变回正数并通过 for 循环触发所有由于获取读锁而陷入等待的 Goroutine:
func (rw *RWMutex) Unlock() {
r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
if r >= rwmutexMaxReaders {
throw("sync: Unlock of unlocked RWMutex")
}
for i := 0; i < int(r); i++ {
runtime_Semrelease(&rw.readerSem, false, 0)
}
rw.w.Unlock()
}
在方法的最后,RWMutex
会释放持有的互斥锁让其他的协程能够重新获取读写锁。
总结
-
readerSem
— 读写锁释放时通知由于获取读锁等待的 Goroutine; -
writerSem
— 读锁释放时通知由于获取读写锁等待的 Goroutine; -
w
互斥锁 — 保证写操作之间的互斥; -
readerCount
— 统计当前进行读操作的协程数,触发写锁时会将其减少rwmutexMaxReaders
阻塞后续的读操作; -
readerWait
— 当前读写锁等待的进行读操作的协程数,在触发Lock
之后的每次RUnlock
都会将其减一,当它归零时该 Goroutine 就会获得读写锁; -
当读写锁被释放
Unlock
时首先会通知所有的读操作,然后才会释放持有的互斥锁,这样能够保证读操作不会被连续的写操作『饿死』;
RWMutex
在 Mutex
之上提供了额外的读写分离功能,能够在读请求远远多于写请求时提供性能上的提升,我们也可以在场景合适时选择读写互斥锁。