锁是为了避免竞争而建立的并发控制手段,为有序地访问共享资源。
互斥锁mutex
Mutex为一结构体类型,对外暴露Lock与Unlock接口。加锁与解锁要成对出现(应加锁后,立即用defer解锁),重复解锁会引起panic。
Mutex内存布局:
Mutex有以下状态:
- Locked:是否已被锁定(0:没锁定,1:锁定);
- Woken:是否有协程已被唤醒,正处于加锁状态(0:无协程唤醒,1:有协程唤醒);
- Starving:是否处于饥饿状态(0:没有饥饿,1:饥饿状态);
- Waiter:阻塞的等待锁的协程数(解锁时据此判断是否要释放信号量);
加解锁
协程间抢锁实际上是抢给Locked赋值的权利(能给Locked域置1,说明抢锁成功);抢不到的阻塞等待Mutex.sema信号量。
Woken状态用于加锁和解锁过程的通信;处于自旋状态的加锁协程会把Woken标记为1,通知解锁协程不必释放信号量了。
加锁、唤醒示意图(最基本的情形):
正常模式下,被阻塞的协程会进入等待队列;当持有锁协程释放锁时,会释放唤醒信号来唤醒等待的协程。
自旋
自旋对应于CPU的‘PAUSE’指令(CPU空转),不同于sleep,其不需要把协程转为睡眠状态;加锁时程序会自动判断是否可自旋,自旋必须满足(要不忙):
- 自旋次数要足够小(通常最多不超4次);
- CPU核数要大于1(否则自旋无意义);
- 协程调度机制中的Process数量要大于1;
- 协程调度机制中可运行队列必须为空;
自旋优势是更充分利用CPU,尽量避免协程切换。若自旋过程中获得锁,那么之前被阻塞协程将无法获得锁,从而可能会进入饥饿状态;为避免协程长时间无法获取锁,自1.8版本后,Mutex增加了Starving状态,此状态下不会自旋(释放锁时,一定会唤醒一个协程并让其成功加锁)。
加锁模式
每个Mutex都有两个模式,称为Normal和Starving:
- Normal模式:若加锁不成功,不会立即转入阻塞队列,而是判断是否满足自旋条件;
- Starvation模式:处于饥饿模式下,不会启动自旋过程;一旦有协程释放了锁,那么一定会唤醒协程,被唤醒的协程将会成功获取锁,同时也会把等待计数减1。
释放锁时如果发现有阻塞等待的协程,还会释放一个信号量来唤醒一个等待协程,被唤醒的协程得到CPU后开始运行,此时发现锁已被抢占了,自己只好再次阻塞,不过阻塞前会判断自上次阻塞到本次阻塞经过了多长时间,如果超过1ms的话,会将Mutex标记为”饥饿”模式,然后再阻塞。
基本使用
sync包中提供了锁相关的一系列同步原语,用于加解锁:
import (
"fmt"
"sync"
)
func ShowMutex() {
var syn sync.Mutex
var count = 0
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
for j := 0; j < 100000; j++ {
syn.Lock()
count++
syn.Unlock()
}
}()
}
wg.Wait()
fmt.Println(count)
}
读写锁rwmutex
读写互斥锁,可增加并发能力(程序中一般是读多、写少):
- 写锁阻塞其他写锁;
- 写锁阻塞读锁;
- 读锁阻塞写锁;
- 读锁不阻塞读锁;
接口
RWMutex提供了四个接口:
- RLock(读锁定):增加读操作计数,(若有写操作)阻塞等待写操作结束;
- RUnlock(读解锁):减少读操作计数,(最后一个读操作、且有写锁定)唤醒等待等待写操作的协程;
- Lock(写锁定):获取互斥锁,(若有读操作)等待所有读操作结束;
- Unlock(写解锁):唤醒因读锁定而被阻塞的协程(若有),解除互斥锁;
读写锁定义:
type RWMutex struct {
w Mutex //用于控制多个写锁, 获得写锁首先要获取该锁, 如果有一个写锁在进行, 那么再到来的写锁将会阻塞于此
writerSem uint32 //写阻塞等待的信号量, 最后一个读者释放锁时会释放信号量
readerSem uint32 //读阻塞的协程等待的信号量, 持有写锁的协程释放锁后会释放信号量
readerCount int32 //记录读者个数
readerWait int32 //记录写阻塞时读者个数
}
互斥
写阻塞读
写操作是如何阻止读操作的:
- readerCount是个整型值,用于表示读者数量,不考虑写操作的情况下,每次读锁定将该值+1,每次解除读锁定将该值-1,所以readerCount取值为[0,N](N为读者个数,最大可支持 2 30 2^{30} 230个并发读者)。
- 当写锁定进行时,会先将readerCount减去 2 30 2^{30} 230,从而readerCount变成了负值,此时再有读锁定到来时检测到readerCount为负值,便知道有写操作在进行,只好阻塞等待。
- 真实的读操作个数并不会丢失,只需要将readerCount加上 2 30 2^{30} 230即可获得。
读阻塞写
读操作是如何阻止写操作的:
- 读锁定会先将readerCount加1,此时写操作到来时发现读者数量不为0,会阻塞等待所有读操作结束。
避免饿死
为什么写锁定不会被饿死:
- 写操作到来时,会把readerCount值拷贝到readerWait中,用于标记排在写操作前面的读者
个数。 - 读操作结束后,除了会递减readerCount,还会递减readerWait值,当readerWait值变为0时唤醒写操作。