从源码深入理解读写锁(golang-RWMutex)

这篇具有很好参考价值的文章主要介绍了从源码深入理解读写锁(golang-RWMutex)。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

环境: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<<30,理想中,写锁不会持续很久,不会导致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 << 30

func (rw *RWMutex) RLock() {
	if atomic.AddInt32(&rw.readerCount, 1) < 0 {
		// 写端 持有锁, 读端阻塞
		runtime_SemacquireMutex(&rw.readerSem, false, 0)
	}
}

func (rw *RWMutex) RUnlock() {
	if r := atomic.AddInt32(&rw.readerCount, -1); r < 0 {
		rw.rUnlockSlow(r)
	}
}

func (rw *RWMutex) rUnlockSlow(r int32) {
	if r+1 == 0 || r+1 == -rwmutexMaxReaders {
		fatal("sync: RUnlock of unlocked RWMutex")
	}

	if atomic.AddInt32(&rw.readerWait, -1) == 0 {
		// 无读者等待,唤醒写端等待者
		runtime_Semrelease(&rw.writerSem, false, 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 < int(r); i++ {
		runtime_Semrelease(&rw.readerSem, false, 0)
	}

	// 释放互斥锁
	rw.w.Unlock()
}

Lock

  1. 先获取互斥锁
  2. 成功获取后,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是由一个互斥锁和四个辅助字段组成的,与互斥锁一样,读写锁也是不能复制的。
一旦读写锁被使用,它的字段就会记录它当前的一些状态,如果此时去复制这把锁,就会把它的状态也复制过去。但原来的锁在释放的时候,并不会修改复制出来的读写锁,会导致复制出来的读写锁状态异常,可能永远无法释放锁。

重入导致死锁

读写锁重入,或者递归调用,导致的死锁情况很多

  1. 读写锁内部基于互斥锁实现对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)
}
  1. 当一个 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 < 1 {
        return 0
    }
    
    fmt.Println("RLock")
    m.RLock()
    defer func() {
        fmt.Println("RUnlock")
        m.RUnlock()
    }

    time.Sleep(100*time.Millisecond)
    return factorial(m, n-1) * 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文章来源地址https://www.toymoban.com/news/detail-434093.html

参考

  1. go中sync.RWMutex源码解读

到了这里,关于从源码深入理解读写锁(golang-RWMutex)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 深入理解Golang中的接口与实例展示

    标题:深入理解Golang中的接口与实例展示 引言: Golang(Go)的接口是一项强大的特性,它为面向对象编程带来了灵活性和可维护性。本文将深入讲解Golang中的接口概念,从基础到实际应用,通过详细案例展示,帮助读者更好地掌握接口的使用和设计。 一、接口基础概念: 接

    2024年01月21日
    浏览(32)
  • 深入理解Flink IntervalJoin源码

    IntervalJoin基于connect实现,期间会生成对应的IntervalJoinOperator。 并且会根据给定的自定义Function构建出对应的TwoInputTransformation,以便能够参与Transformation树的构建。 作为ConnectedStreams,一旦left or right流中的StreamRecord抵达,就会被及时处理: 两者的处理逻辑是相同的: 先取出当

    2024年02月12日
    浏览(28)
  • 【排序算法】 归并排序详解!深入理解!思想+源码实现!

    🎥 屿小夏 : 个人主页 🔥个人专栏 : 算法—排序篇 🌄 莫道桑榆晚,为霞尚满天! ​ 什么是归并?通过归并排序就能让数据有序?分治法是怎么在归并排序上应用的?本文将对归并排序进行细致入微的讲解,庖丁解牛般让你彻底明白归并排序! 归并排序(MERGE-SORT)是建

    2024年02月06日
    浏览(31)
  • 深入理解 Spark(二)SparkApplication 提交和运行源码分析

    对于运行失败的 Task,TaskSetManager 会记录它失败的次数,如果失败次数还没有超过最大重试次数,那么就把它放回待调度的 Task 池子中等待重新执行,当重试次数过允许的最大次数,整个 Application失败。在记录 Task 失败次数过程中,TaskSetManager 还会记录它上一次失败所在的

    2024年01月17日
    浏览(29)
  • 深入理解 Hadoop (一)网络通信架构与源码浅析

    深入理解 Hadoop (一)网络通信架构与源码浅析 深入理解 Hadoop (二)HDFS架构演进 深入理解 Hadoop (三)HDFS文件系统设计实现 深入理解 Hadoop (四)HDFS源码剖析 深入理解 Hadoop (五)YARN核心工作机制浅析 深入理解 Hadoop (六)YARN核心设计理念与工作流程剖析 深入理解 Had

    2024年02月03日
    浏览(29)
  • 深入理解高并发编程 - 深度解析Thread 类的源码

    先看源码:Thread 类实现了 Runnable 接口 而 Runnable 被@FunctionalInterface 注解标记为函数式接口,Runnable 接口源代码 再来看看@FunctionalInterface 注解的源码 FunctionalInterface 用于标记类或接口是否是一个函数式接口,并在运行时可用于反射获取信息。 这段代码是 Thread 类的一个静态初

    2024年02月09日
    浏览(36)
  • 深入理解 Flink(五)Flink Standalone 集群启动源码剖析

    深入理解 Flink 系列文章已完结,总共八篇文章,直达链接: 深入理解 Flink (一)Flink 架构设计原理 深入理解 Flink (二)Flink StateBackend 和 Checkpoint 容错深入分析 深入理解 Flink (三)Flink 内核基础设施源码级原理详解 深入理解 Flink (四)Flink Time+WaterMark+Window 深入分析 深入

    2024年02月02日
    浏览(38)
  • 深入理解python虚拟机:调试器实现原理与源码分析

    调试器是一个编程语言非常重要的部分,调试器是一种用于诊断和修复代码错误(或称为 bug)的工具,它允许开发者在程序执行时逐步查看和分析代码的状态和行为,它可以帮助开发者诊断和修复代码错误,理解程序的行为,优化性能。无论在哪种编程语言中,调试器都是一

    2023年04月26日
    浏览(39)
  • Flink 深入理解任务执行计划,即Graph生成过程(源码解读)

    我们先看一下,Flink 是如何描述作业的执行计划的。以这个 DataStream 作业为例,Flink 会基于它先生成一个 StreamGraph。这是一个有向无环图,图中的节点对应着计算逻辑,图中的边则对应着数据的分发方式。 Flink 会根据节点的并行度以及他们之间的连边方式,把一些计算节点进

    2024年02月22日
    浏览(32)
  • [AIGC ~ coze] Kafka 消费者——从源码角度深入理解

    一、引言 Kafka 是一个分布式的流处理平台,广泛应用于大规模数据处理和实时数据管道。在 Kafka 生态系统中,消费者扮演着至关重要的角色,它们从 Kafka 主题中读取数据并进行处理。本文将深入探讨 Kafka 消费者的工作原理,包括消费者的基本概念、消费者组、订阅主题、偏

    2024年02月21日
    浏览(28)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包