为什么需要锁?(锁就像一把钥匙,只有一个协程得到并打开共享资源的门)
解决并发访问共享资源时,出现的数据竞争和并发异常问题
如何设计和实现
首先定义结构体,需要
- 字段 state int32 类型来表示锁状态(0 代表锁未持有,1 代表锁已经被某个协程持有),使用 atomic 的 cas 来更新 state 字段,标记是否获取锁
- 需要等待队列 queue 存放阻塞 goroutine(先入先出),依次被唤醒,唤醒后 g 需要争抢获取锁;
- 唤醒和挂起都需要 cpu 开销,可以插入队列前先自旋抢锁,得到更高的吞吐量,但不能一直自旋,也需要记录自旋次数字段 spin
- 信号量字段来唤醒等待的 goroutine 获取锁
- 进一步优化,释放锁后新创建 g1 和唤醒队首的 g2 争抢锁,往往新创建 g1 会获取锁,因为新创建在 cpu 运行 + 数量比较多 可能导致队列中 g 一直获取不到锁,造成尾部延时;增加互斥锁状态模式字段,表示正常/饥饿状态,如果一个等待 goroutine 超过 1ms 还没有获取锁切换到饥饿状态,互斥锁解锁后优先让队首 g 获取,新创建 g 直接插入队尾。如果 g 是队列最后一个元素 or 等待时间小于 1ms 会从饥饿状态切换正常状态。
type RWMutex struct {
w Mutex // 一个互斥锁的字段,用户进行写时加互斥锁
writerSem uint32 // 一个 writer 的信号量,类似互斥锁中的信号量
readerSem uint32 // 一个 reader 的信号量,类似互斥锁中的信号量
readerCount int32 // 两种作用,1:标记有多少拿到读锁的 reader,2:是否有 writer 需要竞争
readerWait int32 // writer 需要等待读锁解锁的 reader 的数量
}
const rwmutexMaxReaders = 1 << 30 // 最大 reader 的上限。即最多有多少的 reader 同时能拿到读锁
- 读锁加锁:针对 readerCount 字段的判断,如果其 +1 仍未负数时就代表此时此刻写锁已经被获取,即需要进行阻塞等待写锁的解锁
- 读锁的解锁:判断是否有正在等待的写锁,如果没有就直接返回,否则就进行 readerWait 字段的校验判断是否是最后一个需要等待的读锁后唤醒,等待读锁释放完的 writer 进行写锁的获取。
- 写锁的加锁过程必须先对整体的结构体的 Mutex 进行加锁,以免有其他的写操作同时对写锁的竞争导致 data race。然后进行当前持有读锁的 reader 的数量进行取反,并且将其值交给 readerWait
- 用于标记需要等待释放锁的 reader 的数量,如果该字段不等于 0 则代表需要进行读锁解锁等待。当 reader 调用 RUlock 时会进行对此字段的 -1 并且判断,如果此字段为 0 时,则唤醒 writer 的阻塞,使得 writer 获取到写锁。
- 写锁的解锁方式很简单,先进行 readerCount 的取反,以便告知无 writer 正在竞争,然后依次去唤醒这些等待的 reader 去获取读锁,然后将互斥锁写锁,以便后续的 writer 进行写操作,在写操作时,加锁时先进行互斥锁的加锁,解锁时后进行互斥锁的解锁,为的是保证字段的修改也受到互斥锁的保护。
- go 的读写锁采用的是 Write-preferring(即写优先)的设计,这样可以保证写操作在大量的读操作进行时不会被饿死。但是相对于 Read-preferring(即读优先)的设计会降低读的并发性,但是这种方式避免了写会出现饥饿问题。也是一种良好的解决办法