Go的sync.RWMutex

2023年6月29日 · 1305 字 · 3 分钟 · Go

为什么需要锁?(锁就像一把钥匙,只有一个协程得到并打开共享资源的门)

解决并发访问共享资源时,出现的数据竞争和并发异常问题

如何设计和实现

首先定义结构体,需要

  1. 字段state int32类型来表示锁状态(0代表锁未持有,1代表锁已经被某个协程持有),使用atomic的cas来更新state字段,标记是否获取锁
  2. 需要等待队列queue存放阻塞goroutine(先入先出),依次被唤醒,唤醒后g需要争抢获取锁;
  3. 唤醒和挂起都需要cpu开销,可以插入队列前先自旋抢锁,得到更高的吞吐量,但不能一直自旋,也需要记录自旋次数字段spin
  4. 信号量字段来唤醒等待的goroutine获取锁
  5. 进一步优化,释放锁后新创建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(即读优先)的设计会降低读的并发性,但是这种方式避免了写会出现饥饿问题。也是一种良好的解决办法