25

Go 中锁的那些姿势,估计你不知道 | Go 技术论坛

 4 years ago
source link: https://learnku.com/articles/39577?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Go 中锁的那些姿势,估计你不知道

/ 106 / 0 / 发布于 10个月前

什么是锁,为什么使用锁#

用俗语来说,锁意味着一种保护,对资源的一种保护,在程序员眼中,这个资源可以是一个变量,一个代码片段,一条记录,一张数据库表等等。

就跟小孩需要保护一样,不保护的话小孩会收到伤害,同样的使用锁的原因是资源不保护的话,可能会受到污染,在并发情况下,多个人对同一资源进行操作,有可能导致资源不符合预期的修改。

常见的锁的种类#

锁的种类细分的话,非常多,主要原因是从不同角度看,对锁的定义不一样,我这里总结了一下,画一个思维脑图,大家了解一下。

我个人认为锁都可以归为一下四大类,其它的叫法不同只是因为其实现方式或者应用场景而得名,但本质上上还是下面的这四大类中一种。

Go中锁的那些姿势,估计你不知道

其它各种类的锁总结如下,这些锁只是为了高性能,为了各种应用场景在代码实现上做了很多工作,因此而得名,关于他们的资料很多

Go中锁的那些姿势,估计你不知道

更多锁的详细解释参考我 github 的名词描述,这里不在赘述,地址如下:

https://github.com/sunpengwei1992/java_common/tree/master/src/lock

Go 中的锁使用和实现分析#

Go 的代码库中为开发人员提供了一下两种锁:

  1. 互斥锁 sync.Mutex
  2. 读写锁 sync.RWMutex

第一个互斥锁指的是在 Go 编程中,同一资源的锁定对各个协程是相互排斥的,当其中一个协程获取到该锁时,其它协程只能等待,直到这个获取锁的协程释放锁之后,其它的协程才能获取。

第二个读写锁依赖于互斥锁的实现,这个指的是当多个协程对某一个资源都是只读操作,那么多个协程可以获取该资源的读锁,并且互相不影响,但当有协程要修改该资源时就必须获取写锁,如果获取写锁时,已经有其它协程获取了读写或者写锁,那么此次获取失败,也就是说读写互斥,读读共享,写写互斥。

Go 中关于锁的接口定义如下:,该接口的实现就是上面的两个锁种类,篇幅有限,这篇文章主要是分析一下互斥锁的使用和实现,因为 RWMutex 也是基于 Mutex 的,大家可以参考文章自行学习一下。

type Locker interface {
   Lock()
   Unlock()
}
type Mutex struct {
   state int32 //初始值默认为0
   sema  uint32 //初始值默认为0
}

Mutex 使用也非常的简单,,声明一个 Mutex 变量就可以直接调用 Lock 和 Unlock 方法了,如下代码实例,但使用的过程中有一些注意点,如下:

  1. 同一个协程不能连续多次调用 Lock, 否则发生死锁
  2. 锁资源时尽量缩小资源的范围,以免引起其它协程超长时间等待
  3. mutex 传递给外部的时候需要传指针,不然就是实例的拷贝,会引起锁失败
  4. 善用 defer 确保在函数内释放了锁
  5. 使用 - race 在运行时检测数据竞争问题,go test -race ....,go build -race ....
  6. 善用静态工具检查锁的使用问题
  7. 使用 go-deadlock 检测死锁,和指定锁超时的等待问题 (自己百度工具用法)
  8. 能用 channel 的场景别使用成了 lock
var lock sync.Mutex

func MutexStudy(){
    //获取锁
    lock.Lock()
    //业务逻辑操作
    time.Sleep(1 * time.Second)
    //释放锁
    defer lock.Unlock()
}

我们了解了 Mutext 的使用和注意事项,那么具体原理是怎么实现的呢?运用到了那些技术,下面一起分析一下 Mutex 的实现原理。

Mutex 实现中有两种模式,1:正常模式,2:饥饿模式,前者指的是当一个协程获取到锁时,后面的协程会排队 (FIFO), 释放锁时会唤醒最早排队的协程,这个协程会和正在 CPU 上运行的协程竞争锁,但是大概率会失败,为什么呢?因为你是刚被唤醒的,还没有获得 CPU 的使用权,而 CPU 正在执行的协程肯定比你有优势,如果这个被唤醒的协程竞争失败,并且超过了 1ms,那么就会退回到后者 (饥饿模式),这种模式下,该协程在下次获取锁时直接得到,不存在竞争关系,本质是为了防止协程等待锁的时间太长。

两种模式都了解了,我们再来分析一下几个核心常量,代码如下:

const (
   mutexLocked = 1 << iota //1, 0001 最后一位表示当前锁的状态,0未锁,1已锁 
   mutexWoken //2, 0010,倒数第二位表示当前锁是否会被唤醒,0唤醒,1未唤醒
   mutexStarving //4, 0100 倒数第三位表示当前对象是否为饥饿模式,0正常,1饥饿
   mutexWaiterShift = iota //3 从倒数第四位往前的bit表示排队的gorouting数量
   starvationThresholdNs = 1e6 // 饥饿的阈值:1ms
)
//Mutex中的变量,这里主要是将常量映射到state上面
state //0代表未获取到锁,1代表得到锁,2-2^31表示gorouting排队的数量的
sema //非负数的信号量,阻塞协程的依据

这几个变量你要是都弄白了,那么代码看起来就相对好理解一些了,整个 Lock 的源码较长,我将注释写入代码中,方便大家理解,整个锁的过程其实分为三部分,建议大家参考源码和我的注释一块学习。

  1. 直接获取锁,返回
  2. 自旋和唤醒
  3. 判断各种状态,特殊情况处理

第一部分代码如下,较为简单,获取锁成功之后直接返回

//对state进行cas修改操作,修改成功相当于获取锁,修改之后state=1
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
    return
}

第二部分自旋的代码如下

//开始等待时间
var waitStartTime int64
//这几个变量含义依次是:是否饥饿,是否唤醒,自旋次数,锁的当前状态
starving := false;awoke := false;iter := 0;old := m.state
//进入死循环,直到获得锁成功(获得锁成功就是有别的协程释放锁了)
for {
    //这个if的核心逻辑是判断:已经获得锁了并且不是饥饿模式 && 可以自旋,与cpu核数有关
    if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
           //这个是判断:没有被唤醒 && 有排队等待的协程 && 尝试设置通知被唤醒
        if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 && atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
           //说明上个协程此时已经unlock了,唤醒当前协程
            awoke = true
        }
        //自旋一段时间
        runtime_doSpin()
        //自选次数加1
        iter++
        old = m.state
        continue
    }
}

第三部分代码,判断各种状态,特殊情况处理

new := old
 //1:原协程已经unlock了,对new的修改为已锁
if old&mutexStarving == 0 { 
    new |= mutexLocked
}
//2:这里是执行完自旋或者没执行自旋(原协程没有unlock)
if old&(mutexLocked|mutexStarving) != 0 {
    new += 1 << mutexWaiterShift //排队
}
//3:如果是饥饿模式,并且已锁的状态
if starving && old&mutexLocked != 0 {
    new |= mutexStarving //设置new为饥饿状态
}
 //4:上面的awoke被设置为true
if awoke {
    //当前协程被唤醒了,肯定不为0
    if new&mutexWoken == 0 {
        throw("sync: inconsistent mutex state")
    }
    //既然当前协程被唤醒了,重置唤醒标志为0
    new &^= mutexWoken
}
//修改state的值为new,但这里new的值会有四种情况,
//就是上面4个if情况对new做的修改,这一步获取锁成功
if atomic.CompareAndSwapInt32(&m.state, old, new) {
    if old&(mutexLocked|mutexStarving) == 0 {
        //这里代表的是正常模式获取锁成功
        break 
    }
    //下面的代码是判断是否从饥饿模式恢复正常模式 
    queueLifo := waitStartTime != 0
    if waitStartTime == 0 {
        waitStartTime = runtime_nanotime()
    }
   //进入阻塞状态  
    runtime_SemacquireMutex(&m.sema, queueLifo)
   //设置是否为饥饿模式,等待的时间大于1ms就是饥饿模式  
    starving=starving||runtime_nanotime()-waitStartTime> starvationThresholdNs
    old = m.state
    //如果当前锁是饥饿模式,但这个gorouting被唤醒
    if old&mutexStarving != 0 {
        if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
                    throw("sync: inconsistent mutex state")
        }
       //减去当前锁的排队
        delta := int32(mutexLocked - 1<<mutexWaiterShift)
        if !starving || old>>mutexWaiterShift == 1 {
            //退出饥饿模式
            delta -= mutexStarving
        }
        //修改状态,终止  
        atomic.AddInt32(&m.state, delta)
            break
        }
    }    
    //设置被唤醒  
    awoke = true
    iter = 0
} else {
    old = m.state
}

Lock 的源码我们弄明白了,那么 Unlock 呢,大家看代码的时候最好 Lock 和 Unlock 结合一起来看,因为他们是对同一变量 state 在操作

func (m *Mutex) Unlock() {
   //释放锁
   new := atomic.AddInt32(&m.state, -mutexLocked)
   if (new+mutexLocked)&mutexLocked == 0 {
      throw("sync: unlock of unlocked mutex")
   }
   //判断当前锁是否饥饿模式,==0代表不是
   if new&mutexStarving == 0 {
      old := new
      for {
         //如果没有未排队的协程 或者 有已经被唤醒,得到锁或饥饿的协程,则直接返回
         if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
            return
         }
         //唤醒其它协程
         new = (old - 1<<mutexWaiterShift) | mutexWoken
         if atomic.CompareAndSwapInt32(&m.state, old, new) {
            runtime_Semrelease(&m.sema, false)
            return
         }
         old = m.state
      }
   } else {
      //释放信号量
      runtime_Semrelease(&m.sema, true)
   }
}

到这里整个 Mutex 的源码分析完成,可以看到 Metux 的源码并不是很复杂,只是各种位运算让开发人员难以直接观察到结果值,另外阅读源码前一定要先明白各个变量和常量的含义,不然读起来非常费劲。

Go中锁的那些姿势,估计你不知道
本作品采用《CC 协议》,转载必须注明作者和本文链接
那小子阿伟

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK