从源码深入理解golang RWMutex读写锁操作
来源:脚本之家
时间:2023-05-12 15:21:05 361浏览 收藏
本篇文章向大家介绍《从源码深入理解golang RWMutex读写锁操作》,主要包括RWMutex、锁、go读写,具有一定的参考价值,需要的朋友可以参考一下。
环境:go 1.19.8
在读多写少的情况下,即使一段时间内没有写操作,大量并发的读访问也不得不在Mutex的保护下变成串行访问,这种情况下,使用Mutex,对性能影响比较大。
所以就要区分读写操作。如果某个读操作的g持有了锁,其他读操作的g就不必等待了,可以并发的访问共享变量,这样就可以将串行的读变成并行的读,提高读操作的性能。可理解为共享锁。
当写操作的g持有锁,它是一个排他锁,不管其他的g是写操作还是读操作,都需要阻塞等待持有锁的g释放锁。
什么是RWMutex?
reader/writer互斥锁,在某一时刻只能由任意数量的reader持有,或者是只被单个writer持有。
RWMutex实现了5个方法:
- Lock/Unlock:写操作时调用。如果锁已经被reader或者writer持有,那么,Lock方法会一直阻塞,直到能获取到锁;Unlock是对应的释放锁方法
- RLock/RUnlock:读操作时调用。如果锁已经被writer持有,RLock方法会一直阻塞,直到能获取锁,否则直接return;Rnlock是对应的释放锁方法
- RLocker:这个方法的作用是为读操作返回一个 Locker 接口的对象
案例:计数器,1writer n reader
使用场景
如果可以明确区分 reader 和 writer goroutine ,且有大量的并发读,少量的并发写,并且有强烈的性能要求,可以考虑使用读写锁RWMutex替换Mutex
实现原理
RWMutex 是很常见的并发原语,很多编程语言的库都提供了类似的并发类型。RWMutex
一般都是基于互斥锁、条件变量(condition variables)或者信号量(semaphores)等
并发原语来实现。Go 标准库中的 RWMutex 是基于 Mutex 实现的。
reader-writers 问题,一般有三类,基于对读和写操作的优先级,读写锁的设计和实现也分成三类
- Read-Preferring:读优先的设计可以提供很高的并发性。但在竞争激烈的情况下会导致写饥饿
- Write-Preferring:如果有一个writer在等待请求锁,它会阻止新来请求锁reader获取到锁,优先保障writer。当然,如果reader已经获得锁,新请求的writer也需要等待已持有锁的reader释放锁。写优先级设计中的优先权是针对新来的请求而言的。这种设计主要避免了 writer 的饥饿问题。
- 不指定优先级:这种设计比较简单,不区分 reader 和 writer 优先级,某些场景下这种不指定优先级的设计反而更有效,因为第一类优先级会导致写饥饿,第二类优先级可能会导致读饥饿,这种不指定优先级的访问不再区分读写,大家都是同一个优先级,解决了饥饿的问题。
Go 标准库中的 RWMutex 设计是 Write-preferring 方案。一个正在阻塞的 Lock 调用
会排除新的 reader 请求到锁。
源码解析
上锁解锁流程以及数值变化情况
rwmutexMaxReaders 的数量被初始化为1,理想中,写锁不会持续很久,不会导致readerCount 自动从负值自动+1回到正值。
RLock/RUnlock实现
type RWMutex struct { w sync.Mutex // hold if there are pending writers writerSem uint32 // 写 阻塞信号 readerSem uint32 // 读 阻塞信号 readerCount int32 // 正在读的调用者数量/ 当为负数时 表示有write持有锁 readerWait int32 // writer持有锁之前正等待解锁的数量 } const rwmutexMaxReaders = 1
RLock
第11行,上读锁,首先对readerCount进行原子加1,如果小于0则表示存在写锁,直接阻塞。为什么readerCount会存在负值?这个要看readerCount除了在RLock中处理,还在哪里被处理了。可以看到在获取写锁时有响应代码。后面在解释。如果原子加大于等于0,则表示获取读锁成功。
RUnlock
第18行,读解锁,对readerCount进行原子减1,如果小于零,则表示存在活跃的reader(即当前获得互斥锁的写锁之前获取到读锁权限的读者数量),readerWait 字段就减 1,直到所有的活跃的 reader 都释放了读锁,才会唤醒这个 write
Lock/Unlock
func (rw *RWMutex) Lock() { // 1. 先尝试获取互斥锁 rw.w.Lock() // 2. 看是否有其他正持有锁的读者,有则阻塞 r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders if r != 0 && atomic.AddInt32(&rw.readerWait, r) != 0 { // rc - rwmutexMaxReaders + rwmutexMaxReaders > 0说明还有等待者, 写端阻塞 runtime_SemacquireMutex(&rw.writerSem, false, 0) } } func (rw *rwMutex) Unlock() { r := atomic.AddInt32(&rw.readerCount, rwmutexMaxReaders) if r >= rwmutexMaxReaders { fatal("sync: Unlock of unlocked RWMutex") } // 如果有等待的读者,先唤醒 for i := 0; i
Lock
- 先获取互斥锁
- 成功获取后,r=readerCount-rwmutexMaxReaders,得到的数值就是一个负数,在加上rwmutexReaders就表示写锁等待者的数量,此时,如果r不等于0,且readerWait+r!=0,则表示有读等待者,写锁阻塞
我们知道,写操作要等待读操作结束后才可以获得锁,写操作等待期间可能还有新的读操作持续到来,如果写操作等待所有读操作结束,就会出现饥饿现象。然而,通过readerWait
可完美解决这个问题。
写操作到来时,会把readerCount
值拷贝到readerWait
中,用于标记排在写操作之前到读者个数。
当读操作结束后,除了会递减readerCount
,还会递减readerWait
的值,当readerWait
值变为0时会唤醒写操作。
写操作之后产生的读操作会加入到readerCount
中,阻塞知道写锁释放。
Unlock
上面说过,写锁之后来的读者会被阻塞,所以在写锁释放之际,会看是否有需要唤醒的读者,再释放互斥锁
场景讨论
写操作如何阻塞写操作
读写锁包含一个互斥锁(Mutex),写锁必须先获取该互斥锁,如果互斥锁已被协程A获取,意味者其他协程只能阻塞等待互斥锁释放
写操作是如何阻塞读操作
readerCount
是个整型值,用于表示读者数量,不考虑写操作的情况下,每次获取读锁,将该值加1,每次解锁将其减1,所以readerCount
的取值为[0, N]
,最大可支持2^30
个并发读者。
当写锁定进行时,会先将readerCount -= rwmutextMaxReaders(2^30)
,此时 readerCount
负数。这时再有读者到了,检测到readerCount
为负值,则表示有写操作正在进行,后来到读者阻塞等待。等待者的数量即 reaerCount + 2^30
读操作是如何阻止写操作的
写操作时,会把readerCount
的值拷贝到readerWait
中,用于标记在写操作前面读者的个数,前面的写锁释放后,会递减readerCount,readerWait
,当readerWait
值变为0时唤醒写操作
3个踩坑点
不可复制
rwmutex是由一个互斥锁和四个辅助字段组成的,与互斥锁一样,读写锁也是不能复制的。
一旦读写锁被使用,它的字段就会记录它当前的一些状态,如果此时去复制这把锁,就会把它的状态也复制过去。但原来的锁在释放的时候,并不会修改复制出来的读写锁,会导致复制出来的读写锁状态异常,可能永远无法释放锁。
重入导致死锁
读写锁重入,或者递归调用,导致的死锁情况很多
读写锁内部基于互斥锁实现对writer并发控制,而互斥锁本身就有重入问题,所以,writer重入调用Lock,会导致死锁
func foo(l *sync.RWMutex) { fmt.Println("lock in foo") l.Lock() bar(l) l.Unlock() } func bar(l *sync.RWMutex) { fmt.Println("lock in bar") l.Lock() l.Unlock() } func main() { l := &sync.RWMutex{} foo(l) }
2.当一个 writer 请求锁的时候,如果已经有一些活跃的 reader,它会等待这些活跃的reader 完成,才有可能获取到锁,但是,如果之后活跃的 reader 再依赖新的 reader 的话,这些新的 reader 就会等待 writer 释放锁之后才能继续执行,这就形成了一个环形依赖: writer 依赖活跃的 reader -> 活跃的 reader 依赖新来的 reader -> 新来的 reader依赖 writer。
func main() { var mu sync.RWMutex go func() { time.Sleep(200*time.Millisecond) mu.Lock() fmt.Println("Lock") time.Sleep(100*time.Millisend) mu.Unlock() fmt.Println("Unlock") } go func() { factorial(&mu, 10) // 计算10的阶乘 } select {} } // func factorial(m *sync.RWMutex, n int) { if n
factorial 方法是一个递归计算阶乘的方法,我们用它来模拟 reader。为了更容易地制造出死锁场景,在这里加上了 sleep 的调用,延缓逻辑的执行。这个方法会调用读锁(第 27
行),在第 33 行递归地调用此方法,每次调用都会产生一次读锁的调用,所以可以不断地产生读锁的调用,而且必须等到新请求的读锁释放,这个读锁才能释放。同时,我们使用另一个 goroutine 去调用 Lock 方法,来实现 writer,这个 writer 会等待200 毫秒后才会调用 Lock,这样在调用 Lock 的时候,factoria 方法还在执行中不断调用
RLock。这两个 goroutine 互相持有锁并等待,谁也不会退让一步,满足了“writer 依赖活跃的reader -> 活跃的 reader 依赖新来的 reader -> 新来的 reader 依赖 writer”的死锁条件,所以就导致了死锁的产生。
释放未加锁的RWMutex
锁都是成对出现的,Lock和RLock的多余调用会导致锁没有被释放,可能会出现死锁。
而Unlock和RUnlock多余调用会导致panic
参考
今天关于《从源码深入理解golang RWMutex读写锁操作》的内容介绍就到此结束,如果有什么疑问或者建议,可以在golang学习网公众号下多多回复交流;文中若有不正之处,也希望回复留言以告知!
-
319 收藏
-
130 收藏
-
252 收藏
-
300 收藏
-
350 收藏
-
233 收藏
-
322 收藏
-
181 收藏
-
316 收藏
-
244 收藏
-
300 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 542次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 507次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 497次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 484次学习