并发编程Mutex (互斥锁)发展分析

Posted 了 凡

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程Mutex (互斥锁)发展分析相关的知识,希望对你有一定的参考价值。

博主介绍:

我是了 凡,喜欢每日在简书上投稿日更的读书感悟笔名:三月_刘超。专注于 Go Web 后端,了解过一些Python、Java、算法、前端等领域。微信公众号【了凡银河系】期待你的关注,企鹅群号(798829931)。未来大家一起加油啊~


前言

初版”的Mutex使用一个flag来表示锁是否被持有,实现比较简单;后来照顾到新来的goroutine,所以会让新的goroutine也尽可能地先获取到锁,这是第二阶段,可以称呼为“给新人机会";那么,接下来就是第三阶段”多给些机会“,照顾新来的和被唤醒的goroutine;但是这样会带来饥饿问题,所以目前又加入了饥饿的解决方案,也就是第四阶段”解决饥饿“。


文章目录


初版Mutex

/* 初版的互斥锁 Russ Cox 在2008年提交的第一版Mutex实现方式 */

// CAS操作,当时还没有抽象出 atomic 包
func cas(val *int32, old, new int32) bool {return true}
func semacquire(*int32){}
func semrelease(*int32){}

// Mutex 互斥锁的结构,包含两个字段
type Mutex struct {
   key int32 // 锁是否被持有的标识
   sema int32 // 信号量专用,可以阻塞/唤醒goroutine
}

// 保证成功在val上增加delta的值
func xadd(val *int32,delta int32)(new int32)  {
   for {
      v := *val
      if cas(val, v, v+delta){
         return v + delta
      }
   }
   panic("unreached")
}

func (m *Mutex) Lock()  {
   if xadd(&m.key, 1) == 1 { // 标识加1,如果等于1,成功获取到锁
      return
   }
   semacquire(&m.sema) // 否则阻塞等待
}

func (m *Mutex) Unlock()  {
   if xadd(&m.key, -1) == 0 { // 将标识减去1,如果等于0,则没有其他等待者
      return
   }
   semrelease(&m.sema) // 唤醒其他阻塞的goroutine
}

CAS:CAS指令将给定的值和一个内存地址中的值进行比较,如果它们是同一个值,就使用新值替换内存地址中的值,这个操作是原子性。CAS是实现互斥锁和同步原语的基础。

原子性:明确一点,就是原子性保证这个指令总是基于最新的值进行计算,如果同时有其它线程已经修改了这个值,那么,CAS会返回失败。

CAS有两大好处

  1. 无锁的方式没有锁竞争带来的系统开销,也没有不同线程间频繁调度的开销,所以性能比锁更优越
  2. 对死锁免疫

CAS缺点

  1. ABA问题:当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而经过两次修改后,对象的值又恢复为旧值,这样当前线程无法正确判断这个对象是否修改过。
  • 解决办法:JDK1.5可以利用类AtomicStampedReference来解决这个问题,AtomicStampedReference内部不仅维护了对象值,还维护了一个时间戳。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳,对象值和时间戳都必须满足期望值,写入才会成功。

  1. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
  • 解决办法:JVM支持处理器提供的pause指令,使得效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令,使CPU不会消耗过多的执行资源,第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

  1. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性。
  • 解决办法:从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

Mutex 结构体包含两个字段:

  1. 字段key:是一个flag,用来标识这个排外锁是否被某个goroutine所持有,如果key大于等于1,说明这个排外锁已经被持有;
  2. 字段sema:是个信号量变量,用来控制等待goroutine的阻塞休眠和唤醒。

Unlock方法可以被任意的goroutine调用释放锁,即使没持有这个互斥锁的goroutine,也可以进行这个操作。这是因为,Mutex本身并没有包含持有这把锁的goroutine的信息,所以,Unlock也不会对此进行检查。Mutex的这个设计一直保持至今。

为了解决 goroutine 可以强制释放锁,因为在临界区goroutine可能不知道锁已经被释放了,还会继续执行临界区的业务操作,这可能会带来意想不到的结果,因为这个goroutine还以为自己持有锁呢,有可能导致data race 问题。这是一个非常危险的操作。

所以,我们在使用 Mutex 的时候,必须要保证 goroutine 尽可能不去释放自己未持有的锁,一定要遵循 “谁申请,谁释放” 的原则。在真实的实践中,我们使用互斥锁的时候,很少一个方法中单独申请锁,而在另外一个方法中单独释放锁,一般都会在同一个方法中获取锁和释放锁。

基于性能的考虑,及时释放掉锁,所以在一些if-else分支中加上释放锁的代码,代码看起来很臃肿。而且,在重构的时候,也很容易因为误删或者是漏掉而出现死锁的现象。

type Foo struct {
    mu sync.Mutex
    count int
}

func (f *Foo) Bar(){
    f.mu.Lock()
    
    if f.count < 1000 {
         f.count += 3
         f.mu.Unlock() // 此处释放锁
         return   
    }
    
    f.count++
    f.mu.Unlock() // 此处释放锁
    return
}

从1.14版本起,Go对defer做了优化,采用更有效的内联方式,取代之前的生成defer对象到defer chain 中,defer对耗时的影响微乎其微了,所以基于上修改成下面简介的写法也没问题:

func (f *Foo) Bar() {
    f.mu.Lock()
    defer f.mu.Unlock()
    
    if f.count < 1000 {
        f.count += 3
        return
    }
    
    f.count ++
    return
}

这样做的好处就是Lock/Unlock总是成对紧凑出现,不会遗漏或者多调用,代码更少。

但是,如果临界区只是方法中的一部分,为了尽快释放锁,还是应该第一时间调用Unlock而不是一直等到方法返回时才释放。

初版的Mutex实现之后,Go开发组又对 Mutex做了一些微调,比如把字段类型变成了uint32类型;调用Unlock方法会做检查;使用atomic包的同步原语执行原子操作等等,这些小的改动,都不是核心功能,你简单知道就行了,我就不详细介绍了。

但是,初版的 Mutex实现有一个问题:请求锁的goroutine 会排队等待获取互斥锁。虽然这貌似很公平,但是从性能上来看,却不是最优的。因为如果我们能够把锁交给正在占用CPU时间片的goroutine的话,那就不需要做上下文的切换,在高并发的情况下,可能会有更好的性能。

Go开发者是怎么解决这个问题的呢?我们后续在继续了解。


这次就先讲到这里,如果想要了解更多的golang语言内容一键三连后序每周持续更新!

以上是关于并发编程Mutex (互斥锁)发展分析的主要内容,如果未能解决你的问题,请参考以下文章

go语言学习笔记 — 进阶 — 并发编程:互斥锁(sync.Mutex)—— 保证同时只有一个goroutine可以访问共享资源

并发编程Mutex(互斥锁)拓展提高

Go语言自学系列 | golang并发编程之Mutex互斥锁实现同步

图解Go里面的互斥锁mutex了解编程语言核心实现源码

golang mutex互斥锁源码分析

golang之sync.Mutex互斥锁源码分析