Golang底层原理剖析之互斥锁sync.Mutex

简介: Golang底层原理剖析之互斥锁sync.Mutex

Mutex概括

Mutex(Mutual exclusion),Go中Mutex的数据结构是这样的,因为足够简单,所以不需要额外的初始化,零值就是一个有效的互斥锁,处于Unlocked状态。state存储的是互斥锁的状态,加锁和解锁,都是通过atomic包提供的函数原子性,操作该字段。sema用作一个信号量,主要用于等待队列。

Mutex有两种模式,在正常模式下,一个尝试加锁的goroutine会先自旋四次,自旋锁(如果不成功就一直尝试),尝试通过原子操作获得锁,若几次自旋之后仍不能获得锁,则通过信号量排队等待。

所有等待者会按照先入先出FIFO的顺序排队。

但是当锁被释放,第一个等待者被唤醒后并不会直接拥有锁,而是需要和后来者竞争,也就是那些处于自旋阶段,尚未排队等待的goroutine。这种情况下后来者更有优势,一方面,它们正在CPU上运行,自然比刚被唤醒的goroutine更有优势,另一方面处于自旋状态的goroutine可以有很多,而被唤醒的goroutine每次只有一个,所以被唤醒的goroutine有很大概率拿不到锁。这种情况下它会被重新插入到队列的头部,而不是尾部。

而当一个goroutine本次加锁等待时间超过了1ms后,它会把当前Mutex从正常模式切换至“饥饿模式”。

在饥饿模式下,Mutex的所有权从执行Unlock的goroutine,直接传递给等待队列头部的goroutine,后来者不会自旋,也不会尝试获得锁,即使Mutex处于Unlocked的状态。它们会直接到队列的尾部排队等待。

当一个等待者获得锁之后,它会在以下两种情况时,将Mutex由饥饿模式切换回正常模式。

  • 第一种情况是它的等待时间小于1ms,也就是它刚来不久
  • 第二种情况是它是最后一个等待者,等待队列已经空了,后面自然就没有饥饿的goroutine了

综上所述,在正常模式下自旋和排队是同时存在的,执行lock的goroutine会先一边自旋,尝试4次后如果还没拿到锁,就需要去排队等待了,这种排队之前先让大家来抢的模式,能够有更高的吞吐量,因为频繁的挂起,唤醒goroutine会带来较多的开销。但是又不能无限制的自旋,要把自旋的开销控制在较小的范围内,所以在正常模式下,Mutex有更好的性能。 但是可能会出现队列尾端的goroutine迟迟抢不到锁(尾端延迟)的情况。

而饥饿模式不再尝试自旋,所有goroutine都要排队,严格的FIFO,对于防止出现尾端延迟来讲特别重要。

Mutex.state状态标识

首先来看一下关于Mutex.state的几个常量定义,state是int32类型,其中第一个位用作锁状态标识,1表示已加锁,对应掩码常量为mutexLocked,第二位用于记录是否已有goroutine被唤醒了,1表示已唤醒,对应掩码常量为mutexWoken,第三位表示Mutex的工作模式,0代表正常模式,1代表饥饿模式,对应掩码常量为mutexStartving ,而常量mutexWaiterShift等于3,表示除了低三位以外,state的其它位用来记录有多少个等待者在排队。

来看一下lock和unlock的方法,精简掉了注释和部分race检测相关代码,两个方法中主要是通过atomic函数来实现了Fast path。相应的Slow path被单独放在了lockSlow和unlockSlow方法中。根据源码注释的说法,这样是为了便于编译器堆Fast path进行内联优化。

Lock的Fast path期望Mutex处于Unlocked状态,没有goroutine在排队,更不会饥饿,理想状态下,一个CAS操作就可以获得锁,但是如果CAS操作没能获得锁,就需要进入Slow path,也就是lockSlow方法。

Unlock方法同理,首先通过原子操作从state中减去mutexLocked,也就是释放锁,然后根据state的新值来判断是否需要执行Slow path。如果新值为0,也就意味着没有其他goroutine在排队, 所以不需要执行额外操作,如果新值不为0,那就需要进入slow path,看看是不是需要唤醒某个goroutine。lock和unlock的fast path就是这样,接下来展开slow path的主要逻辑。

Mutex源码剖析

当一个goroutine尝试给mutex加锁时,如果其他goroutine已经加了锁还没有释放,而且当前mutex工作在正常模式下,是不是就要开始自旋了呢?

不一定,因为如果当前是单核场景,自旋的goroutine在等待持有锁的goroutine释放锁,而持有锁的goroutine在等待自旋的goroutine让出CPU,这种情况下自旋是没有意义的。而且如果GOMAXPROCS=1,或者当前没有其它正在运行的P,也和单核场景类似,同样不需要自旋。除此之外,如果当前P的本地runq不为空,相较于自旋而言,切换到本地runq更有效率,所以为保障吞吐量也不会自旋。

最终,只有在多核场景下,且GOMAXPROCS>1,至少有一个其他的P处于running,当前P的本地runq为空的情况下,才可以自旋。

进入自旋的goroutine会先去争抢mutex的唤醒标识位(自旋G与等待队列第一个G就是在此竞争),设置mutexWoken标识位的目的是,在正常模式下,告知持有锁的goroutine,在unlock的时候不用再唤醒其他goroutine了,已经有goroutine在这里等待,以免唤醒太多的等待协程。mutex中的自旋,底层是通过procyield循环执行30次PAUSE,自旋次数上限为4,而且每自旋一次都要重新判断是否可以继续自旋。

如果锁被释放了,或者锁进入了饥饿模式,亦或者已经自旋了4次,都会结束自旋。结束自旋或者根本不用自旋的goroutine,就该尝试原子操作修改mutex的状态了。把此时mutex.state保存了old中,把要修改为的新state记为new。

  • 如果old处于饥饿模式或者加锁状态,goroutine就得去排队,所以在这些情况下排队规模要加1.
  • 如果是正常模式,就要尝试设置lock位,所以最后一位要置为1
  • 如果当前goroutine等待的时间已经超过1ms,而且锁还没被释放,就要将mutex的状态切换为饥饿模式,这里之所有还要求锁没被释放,是因为如果锁已经释放了,那怎么都得去抢一次啊,要是直接进入饥饿模式就只能去排队了。
  • 在执行原子操作修改state之前,若是当前goroutine持有唤醒标识的话,还要将唤醒标识位重置。

把排队规模和几个标志位都设置好以后,在执行原子操作修改state之前,若是当前goroutine持有唤醒标识的话,还要将唤醒标识位重置。因为接下来无论是要去抢锁,还是单纯的去排队,如果原子操作成功了,那么要么是成功抢到了锁,要么是成功进到了等待队列里。当前goroutine都不再是被唤醒的goroutine了,所以要释放唤醒标识。

而如果原子操作不成功,也就意味着其他goroutine在我们保存mutex.state到old以后,又修改了state的值,当前goroutine就要回过头去,继续从自旋检测这里开始再次尝试。所以也需要释放自己之前抢到的唤醒标识位,从头再来。

继续展开这个原子操作成功的分支,如果是抢锁操作成功了,那么加锁的slow path就可以宣告结束了。

如果是排队的规模设置成功了,还要决定是排在等待队列头部还是尾部,如果当前goroutine已经排过队,是在unlock中唤醒的,那就排在等待队列头部。

如果是第一次排队,就排到最后,并且从第一次排队开始记录当前goroutine的等待时间。

接下来就会让出,进到等待队列里面了(runtime_SemacquireMutex(&m.sema, queueLifo, 1)),队列里的goroutine被唤醒时(unlock),要从上次让出的地方(39行,runtime_SemacquireMutex的下面)开始继续执行,接下来会判断,如果mutex处在正常模式,那就接着从自旋开始抢锁,如果唤醒后的mutex处在饥饿模式,那就没有其他goroutine会和自己抢了,锁已经轮到自己这里,只需要把mutex.state中lock标识位设置为加锁,把等待队列规模减去1,再看看是不是要切换到正常模式,也就是自己的等待时间是不是小于1ms,或者等待队列已经空了,最后设置好mutex.state就一切ok了,这就是加锁操作的slow path。

接下来看unlock的slow path,进到unlock的slow path,说明除去lock标识为以外,剩下的位不全为0,如果处在正常模式,若等待队列为空,或已经有goroutine被唤醒或者获得了锁,或者锁进入了饥饿模式,那就不需要唤醒某个goroutine了,直接返回即可。否则就要尝试抢占mutexWoken标识位,获取唤醒一个goroutine的权利,抢占成功后,就会通过runtime_Semrelease函数唤醒一个goroutine,如果抢占不成功就进行循环尝试,直到等待队列为空,或已经有goroutine被唤醒或者获得了锁,或者锁进入了饥饿模式,则退出循环,而在饥饿模式下,后来的goroutine不会争抢锁,而是直接排队,锁的所有权是直接从执行unlock的goroutine,传递给等待队列中首个等待者的,所以不用抢占mutexWoken标识位。

第一个等待者唤醒后,会继承当前goroutine的时间片立刻开始运行,也就是继续lockSlow中这里,goroutine被唤醒以后的逻辑,这就是unlock的slow path。


目录
相关文章
|
6月前
|
安全 Go
Golang深入浅出之-互斥锁(sync.Mutex)与读写锁(sync.RWMutex)
【4月更文挑战第23天】Go语言并发编程中,`sync.Mutex`和`sync.RWMutex`是保证线程安全的关键。互斥锁确保单个goroutine访问资源,而读写锁允许多个读者并发访问。常见问题包括忘记解锁、重复解锁以及混淆锁类型。使用`defer`可确保解锁,读写锁不支持直接升级或降级,需释放后再获取。根据读写模式选择合适锁以避免性能下降和竞态条件。理解并正确使用锁是编写并发安全程序的基础。
123 3
|
2月前
|
安全 Go
Golang语言goroutine协程并发安全及锁机制
这篇文章是关于Go语言中多协程操作同一数据问题、互斥锁Mutex和读写互斥锁RWMutex的详细介绍及使用案例,涵盖了如何使用这些同步原语来解决并发访问共享资源时的数据安全问题。
86 4
|
13天前
|
存储 安全 测试技术
GoLang协程Goroutiney原理与GMP模型详解
本文详细介绍了Go语言中的Goroutine及其背后的GMP模型。Goroutine是Go语言中的一种轻量级线程,由Go运行时管理,支持高效的并发编程。文章讲解了Goroutine的创建、调度、上下文切换和栈管理等核心机制,并通过示例代码展示了如何使用Goroutine。GMP模型(Goroutine、Processor、Machine)是Go运行时调度Goroutine的基础,通过合理的调度策略,实现了高并发和高性能的程序执行。
75 29
|
11天前
|
负载均衡 算法 Go
GoLang协程Goroutiney原理与GMP模型详解
【11月更文挑战第4天】Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理,创建和销毁开销小,适合高并发场景。其调度采用非抢占式和协作式多任务处理结合的方式。GMP 模型包括 G(Goroutine)、M(系统线程)和 P(逻辑处理器),通过工作窃取算法实现负载均衡,确保高效利用系统资源。
|
3月前
|
SQL 安全 Java
golang为什么不支持可重入锁?
本文对比分析了Java与Go语言中锁机制的不同。在Java中,无论是`synchronized`关键字还是`ReentrantLock`都支持可重入特性,通过维护一个计数器来跟踪锁的嵌套级别,确保同一线程可以多次获取同一把锁而不会造成死锁。然而,Go语言的`sync.Mutex`并不支持这一特性,其设计理念认为可重入锁往往指向代码设计问题,鼓励开发者重构代码以避免此类需求。文章进一步解释了这种设计理念背后的原因,并提供了替代方案示例。总体而言,Go语言试图从设计层面避免潜在的代码问题,尽管这可能会增加一定的开发复杂性。
golang为什么不支持可重入锁?
|
3月前
|
算法 NoSQL 关系型数据库
熔断原理与实现Golang版
熔断原理与实现Golang版
|
3月前
|
存储 关系型数据库 Go
SOLID原理:用Golang的例子来解释
SOLID原理:用Golang的例子来解释
|
3月前
|
存储 人工智能 Go
golang 反射基本原理及用法
golang 反射基本原理及用法
28 0
|
6月前
|
负载均衡 监控 Go
Golang深入浅出之-Go语言中的服务网格(Service Mesh)原理与应用
【5月更文挑战第5天】服务网格是处理服务间通信的基础设施层,常由数据平面(代理,如Envoy)和控制平面(管理配置)组成。本文讨论了服务发现、负载均衡和追踪等常见问题及其解决方案,并展示了使用Go语言实现Envoy sidecar配置的例子,强调Go语言在构建服务网格中的优势。服务网格能提升微服务的管理和可观测性,正确应对问题能构建更健壮的分布式系统。
452 1
|
6月前
|
JSON 监控 安全
Golang深入浅出之-Go语言中的反射(reflect):原理与实战应用
【5月更文挑战第1天】Go语言的反射允许运行时检查和修改结构,主要通过`reflect`包的`Type`和`Value`实现。然而,滥用反射可能导致代码复杂和性能下降。要安全使用,应注意避免过度使用,始终进行类型检查,并尊重封装。反射的应用包括动态接口实现、JSON序列化和元编程。理解反射原理并谨慎使用是关键,应尽量保持代码静态类型。
96 2