当读遇上写:RWMutex读写锁解析

在Go中,使用Mutex同步原语来保证共享资源的只能由一个goroutine访问,但某些场景下这种只能由一个goroutine访问的情况事实上效率是比较低的,例如,在读多写少的情况下,即使没有读操作,大量的并发读访问也会因为Mutex而导致串行访问,实际上对性能的影响其实算比较大的。

面对这种读多写少的情况,一种有效的办法就是区分读写操作

具体来说:

  • 如果某个读操作的goroutine持有了锁,此时若其他读操作的goroutine也想要获取这把锁不会被阻塞,可以并发访问共享资源,从而将串行读变成并发读,提高读操作的性能。
  • 当写操作的goroutine持有锁时,这个锁相当于是排他锁,其他读写操作的goroutine想要获取锁时会被阻塞并等待直到锁被释放。
  • 这一类并发读写问题叫作readers-writers问题。

在Go标准库中,RWMutex(读写锁)便是解决这类 readers-writers 问题的神兵利器。

一、RWMutex介绍及使用场景

读写锁RWMutex是一个读写互斥锁,即reader/writer互斥锁,RWMutex锁在某一时刻只能由任意数量的reader持有,或者是被单个writer持有。

RWMutex的零值为未加锁状态 ,当使用RWMutex的时候,无论是声明该类型的变量,还是将其嵌入到struct中,都无需显示地初始化它。

RWMutex提供了如下几个方法:

方法 说明
Lock/Unlock 写操作调用的方法 。如果锁已经被reader 或者 writer 持有,则调用Lock方法会一直阻塞;Unlock 则是对应释放锁的方法
RLock/RUnlock 读操作调用的方法 。如果锁已经被writer 持有,则RLock方法会一直阻塞,直到能获取到锁,否则就直接返回;而 RUnlockreader 释放锁的方法
RLocker 为读操作返回一个Locker接口的对象。该对象的Lock方法会调用RWMutexRLock方法;该对象的Unlock方法会调用RWMutexRUnlock方法

计数器就是一个很好使用RWMutex读写互斥锁的例子。

  • 读操作可以并行执行;
  • 写操作只允许一个线程执行;
  • 计数器的count++操作是写操作,而获取count的值为读操作;
  • 这种场景下非常适合使用读写锁,即readers-writers问题。

如何使用RWMutex保护共享资源?

go 复制代码
// Counter 线程安全的计数器结构体
type Counter struct {
    mu    sync.RWMutex
    count uint64
}

// Incr 写锁保护写操作
func (c *Counter) Incr() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}

// Count 读锁保护读操作
func (c *Counter) Count() uint64 {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.count
}

func main() {
    var counter Counter
    // 开启10个reader进行读操作
    for i := 0; i < 1000; i++ {
       go func() {
          for {
             count := counter.Count() // 计数器读操作
             fmt.Println(count)
             time.Sleep(time.Millisecond)
          }
       }()
    }

    // writer
    for {
       counter.Incr()
       time.Sleep(time.Second)
    }
}

上述代码中,使用for循环开启了十个进行读操作的goroutine,每个goroutine循环读取count的值,每读取一次,sleep 1 毫秒。同时,以main goroutine作为写操作的执行goroutine,循环执行计数器的写操作,每一秒写一次。

  • Incr方法会修改计数器的值,是一个写操作方法,使用Lock/Unlock进行保护。Incr方法每秒才调用一次,所以writer竞争锁的频次比较的低;
  • Count方法读取计数器的值,是一个读操作方法,使用RLock/RUnlock方法进行保护。十个goroutine每毫秒都在执行一次查询;

通过读写锁,可以看到能够极大的提升计数器的性能,在读取计数器的值时,能够并发进行读操作,避免了串行读。

如果使用Mutex,性能就不会像读写锁这么好。因为多个reader 并发读时,使用互斥锁导致了reader要排队读的情况,即串行读,没有RWMutex并发读的性能好。

可以通过修改一下Count方法,对比一下串行读与并发读的执行过程。

go 复制代码
// Count 读锁保护读操作
func (c *Counter) Count() uint64 {
    c.mu.Lock()
    defer c.mu.Unlock()
    time.Sleep(time.Millisecond * 100)
    return c.count
}

// Count 读锁保护读操作
func (c *Counter) Count() uint64 {
    c.mu.RLock()
    defer c.mu.RUnlock()
    time.Sleep(time.Millisecond * 100)
    return c.count
}

如果在实际场景中,遇到可以明确区分 reader 和 writer goroutine 的场景,并且存在大量的并发读,少量的并发写,并且有性能上的需求,则可以考虑使用读写锁 RWMutex 替换 Mutex

二、RWMutex实现原理

readers-writers问题一般有三类,基于对读和写操作的优先级,读写锁的设计和实现也分成三类。

  • Read-preferring:读操作优先可以提供很高的并发性,但在竞争激烈的情况下,很有可能会导致写操作饥饿,即写操作goroutine无法获取到锁,只有所有的读操作结束后,没有读操作获取锁,写操作才能够获取到锁。
  • Write-preferring:写操作优先,如果有一个writer goroutine在等待请求锁时,其他的新来的readergoroutine请求锁时会被阻止,保证writer goroutine先获取到锁,当然,如果有一些reader已经请求了锁的话,新请求的writer也会等待已经存在的reader都释放锁之后才能获取。写操作优先主要避免了writer的饥饿问题。
  • 不指定优先级:不区分readerwriter的优先级,在某些场景下这种设计反而更加高效,避免了上述两种方法导致的读饥饿问题与写饥饿问题。

在Go标准库中,RWMutex的设计思想采用了Write-preferring方案,一个正在阻塞的Lock调用会排除新的reader请求到锁。

1、RWMutex结构

在Go中,RWMutex是基于基于 Mutex 实现的,结合条件变量 (condition variables)、信号量(semaphores)等并发原语来实现。

RWMutex结构体中,包含一个 Mutex,以及四个辅助字段 writerSem、readerSem、readerCount 和 readerWait:

go 复制代码
type RWMutex struct {
    w           Mutex  // 互斥锁,解决多个writer的竞争
    writerSem   uint32 // writer等待reader完成的信号量
    readerSem   uint32 // reader等待writer完成的信号量
    readerCount int32  // reader的数量
    readerWait  int32  // writer等待完成的reader的数量
}

const rwmutexMaxReaders = 1 << 30

上述结构体中:

  • w:解决writer之间竞争锁的互斥锁;
  • writerSem:阻塞writer的信号量;
  • readerSem:阻塞reader的信号量;
  • readerCount:记录当前reader的数量,以及标识是否有writer竞争锁;
  • readerWait:记录writer请求锁时,需要等待read完成的reader数量;
  • rwmutexMaxReaders:定义了最大的reader数量;

2、RLock/RUnlock 的实现

移出race等无关紧要,RLock方法如下:

go 复制代码
func (rw *RWMutex) RLock() {
    // rw.readerCount是负值的时候,意味着此时有writer等待请求锁,因为writer优先级高,所以把后来的reader阻塞休眠
    if atomic.AddInt32(&rw.readerCount, 1) < 0 {
       // A writer is pending, wait for it.
       runtime_SemacquireMutex(&rw.readerSem, false, 0)
    }
}

上述RLock方法中,对reader的计数器readerCount加1,当readerCount为负数时,则会将当前请求锁的reader进行阻塞睡眠。

为什么readerCount会是负数呢?其实readerCount有双重含义:

  • 若当前RWMutex没有writer竞争或持有锁时,readerCount为正数,为正常的reader的计数;
  • 若当前RWMutex有writer 竞争锁或者持有锁时,readerCount为负数,此时readerCount不仅为reader的计数,而且负数标识为当前是否有 writer 竞争或持有锁,此时请求锁的reader会进入阻塞,等待锁的释放(runtime_SemacquireMutex(&rw.readerSem, false, 0));

RUnlock方法:

go 复制代码
func (rw *RWMutex) RUnlock() {
    if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
       // 检查是否所有的reader都释放读锁,唤醒请求写锁的writer
       rw.rUnlockSlow(r)
    }
}

当调用RUnlock时,会将reader的计数器readerCount的计数值减去1,标识当前reader释放读锁。之后判断当前减去后的返回值,如果返回值为负数,说明当前有writer正在获取RWMutex锁,此时调用rw.rUnlockSlow(r)方法来检查是否所有的reader都释放读锁,若所有的reader都释放了锁,则唤醒请求写锁的writer

rUnlockSlow方法如下:

go 复制代码
func (rw *RWMutex) rUnlockSlow(r int32) {
    // A writer is pending.
    if atomic.AddInt32(&rw.readerWait, -1) == 0 {
       // The last reader unblocks the writer.
       runtime_Semrelease(&rw.writerSem, false, 1)
    }
}

在代码第三行,判断readerWait需要等待的reader数量是否为0,若为0则通过writerSem信号量唤醒阻塞的writer。当一个或者多个reader持有锁的时候,竞争锁的writer会等待这些reader释放完,才可能持有这把锁。

rUnlockSlow方法将持有锁的reader计数减少1(等待完成的reader的数量readerWait减1),会检查既有的 reader 是不是都已经释放了锁(readerWait是否为0),如果都释放了锁,就会唤醒 writerruntime_Semrelease方法),让 writer 持有锁。

3、Lock/Unlock 的实现

Lock方法:

为了避免多个writer竞争共享资源,RWMutex内部使用Mutex来实现writer之间的互斥,保证并发安全。writer通过调用Lock方法来获取RWMutex读写锁。

go 复制代码
func (rw *RWMutex) Lock() {
    // 首先解决其他writer竞争问题
    rw.w.Lock()
    // 反转readerCount,告诉reader有writer竞争锁
    r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders
    // 如果当前有reader持有锁,那么需要等待活跃的reader释放锁
    if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 {
       runtime_SemacquireMutex(&rw.writerSem, false, 0)
    }
}

上述代码中:

  • 在第3行代码中,writer调用RWMutex中成员变量MutexLock方法获取互斥锁。
  • writer获取到互斥锁后,会第5行代码将readerCount反转为负值,将原来的正整数readerCount(>=0)修改为负数(readerCount - rwmutexMaxReaders),让readerCount保持两个含义,即保存了持有读锁的reader个数,又表示当前有writer获取到锁,之后将拿到的返回值在增加rwmutexMaxReaders得到r,用于后续的判断(相当于恢复readerCount为正数,记录当前活跃的reader,用于后续的判断,但是实际readerCount已反转为负数)。
  • 在第7行代码中,若当前已有活跃的reader是否全部都释放了锁(r != 0),以及判断目前是否还有正在等待锁的reader(atomic.AddInt32(&rw.readerWait, r) != 0),若存在,则阻塞当前writer。即如果 readerCount 不是 0,就说明当前有持有读锁的 readerRWMutex需要把这个当前 readerCount 赋值给 readerWait 字段保存下来(第 7 行), 同时这个 writer 进入阻塞等待状态(第 8 行)。
  • 每当一个 reader 释放读锁的时候(调用 RUnlock 方法时,具体可参考上述的rUnlockSlow方法),readerWait 字段就减 1,直到所有的活跃的 reader 都释放了读锁,才会唤醒这个 writer

所谓活跃的reader,就是指持有读锁还没有释放的那些reader

UnLock方法:

go 复制代码
func (rw *RWMutex) Unlock() {
    // 将readerCount转回正数,即告知reader没有活跃的writer了
    r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders)
    
    // 唤醒阻塞的reader们
    for i := 0; i < int(r); i++ {
        runtime_Semrelease(&rw.readerSem, false, 0)
    }
    // 释放内部的互斥锁
    rw.w.Unlock()
}

当持有锁的writer需要释放锁时,调用Unlock方法。

  • 在第3行代码中,将readerCount再次反转为正数,即告知reader们已经没有writer获取锁了。因为在writer获取锁时,readerCount通过减去常量rwmutexMaxReaders反转为负数,因此再次反转为正数的方法为增加 rwmutexMaxReaders 这个常数值。
  • 在第6行中,通过循环调用runtime_Semrelease函数,使用信号量readerSem唤醒阻塞的reader
  • 唤醒reader后,释放内部的互斥锁Mutex

Lock/Unlock方法中,需要重点理解readerCount这个字段的含义以及反转的方式,其次是字段的更改和内部互斥锁的顺序。在 Lock 方法中,是先获取内部互斥锁,才会修改的其他字段;在 Unlock 方法中,是先修改的其他字段,才会释放内部互斥锁,保证字段的修改也受到互斥锁的保护。

三、RWMutex使用注意事项

RWMutex在使用上与Mutex一样暴露的API简单便利,但是在实际使用时,容易犯一些错误。

1、RWMutex不可复制

RWMutexMutex类似,是由互斥锁和通过一系列记录状态的参数构成的结构体,包括信号量readerCount等,本身互斥锁就不可复制,再加上一系列状态字段,RWMutex就更不可能赋值使用。

原因在于一但读写锁被使用,它的字段就会记录当前的一些锁状态,此时复制该锁会将其当前的锁状态也一同赋值,而赋值出来的状态是难以预估的,并且原来的锁释放了,并不会修改复制出来的读写锁,从而导致状态不统一,可能锁永远无法释放。

可以借助 vet 工具,在变量赋值、函数传参、函数返回值、遍历数据、struct 初始化等时,检查是否有读写锁隐式复制的情景。

2、重入导致死锁

读写锁在发生重入或者递归调用的情况下,容易导致死锁。

第一种情况:

因为读写锁内部是基于互斥锁Mutex实现控制writer的并发访问,而Mutex本身有重入问题,因此在调用writer 重入调用 Lock时,容易出现死锁现象。

go 复制代码
func lockUser1(l *sync.RWMutex) {
    fmt.Println("lock user1")
    l.Lock()
    lockUser2(l)
    l.Unlock()
}

func lockUser2(l *sync.RWMutex) {
    l.Lock()
    fmt.Println("lock user2")
    l.Unlock()
}

func main() {
    lock := &sync.RWMutex{}
    lockUser1(lock)
}

执行上述代码,会得到死锁的错误输出。

第二种情况:

当有reader获取到读写锁时,writer会进行阻塞等待,如果此时reader的读操作调用writer的写操作,即调用Lock方法,那么readerwriter会形成互相依赖的死锁状态。reader会等待writer 完成后再释放锁,而writer 需要等待这个 reader执行结束释放锁之后,才能不阻塞地继续执行。这是一个读写锁常见的死锁场景。

第三种情况:

当当一个 writer 请求锁时,如果已经有一部分活跃的reader,此时writer会等待这些活跃的reader完成后才能获取到锁,但之后活跃的reader如果在依赖新的reader的话,这些新的reader就会等待writer释放锁后才能继续执行,从而就会形成一个环形依赖:writer 依赖活跃的 reader -> 活跃的 reader 依赖新来的 reader -> 新来的 reader 依赖 writer

3、释放未加锁的RWMutex

与互斥锁相同,LockUnlock的调用总是成对出现的,RLockRUnlock的调用也必须成对出现。

LockRLock多余的调用会导致锁没有被释放,可能会出现死锁,而UnlockRUnlock多余的调用会导致panic

四、总结

在Go语言中,sync.RWMutex(读写互斥锁)是一个用于并发控制的同步原语,它允许多个goroutine同时读取共享资源,但在同一时间只允许一个goroutine写入共享资源。这种锁的设计是为了在读取操作远多于写入操作的场景中提高性能。

如果实际业务场景需要解决的问题是一个 readers-writers 问题,那么就可以毫不犹豫地选择 RWMutex,不用考虑其它选择。

在使用RWMutex,需要尽量注意避免重入,重入带来的死锁非常隐蔽,而且难以诊断。


参考文章

time.geekbang.org/column/arti...

相关推荐
㳺三才人子5 小时前
初探 Flask
后端·python·flask·html
星栈独行5 小时前
我在 Rust 全栈项目里用 JWT 做无状态认证
开发语言·后端·rust·前端框架·开源·github·web
Java爱好狂.6 小时前
Java程序员体系化学习路线(2026最新版)
java·后端·java面试·java架构师·java程序员·java八股文·java学习路线
陈随易6 小时前
Redis 8.8发布,一定要更新
前端·后端·程序员
装不满的克莱因瓶6 小时前
SpringBoot 如何将 lib 目录中jar包打包进最终的jar包里面
spring boot·后端·maven·jar·mvn
ltl7 小时前
Transformer 原论文实验结果:为什么 28.4 BLEU 足以改写路线图
后端
excel8 小时前
为什么我推荐使用 Termius:现代 SSH 工具的完整体验
前端·后端
卷毛的技术笔记8 小时前
Java后端硬核实战:用Spring AI Alibaba+Redis给LLM装上“超强记忆中枢”
java·人工智能·redis·后端·spring·ai·系统架构
IT_陈寒9 小时前
Java的Optional差点让我掉坑里,这几个坑你别踩
前端·人工智能·后端
子兮曰10 小时前
Harness 驾驭工程深度教程:从 AGENTS.md 到全链路 AI 编码基础设施
前端·后端·ai编程