从零单排之golang:mutex使用及源码详解

  • mutex(互斥锁)详解:互斥锁是一个值类型,实现了locker接口,所以使用的时候需要注意参数的传递,它的底层嵌套了linux的信号量(Semaphore),每次操作其实就是PV操作
type Mutex struct {
    state int32
    sema  uint32
}
type Locker interface {
	Lock()
	Unlock()
}
const (
	mutexLocked = 1 << iota // mutex is locked
	mutexWoken
	mutexStarving
	mutexWaiterShift = iota
	starvationThresholdNs = 1e6// Mutex can be in 2 modes of operations: normal and starvation.
// In normal mode waiters are queued in FIFO order, but a woken up waiter
// does not own the mutex and competes with new arriving goroutines over
// the ownership. New arriving goroutines have an advantage -- they are
// already running on CPU and there can be lots of them, so a woken up
// waiter has good chances of losing. In such case it is queued at front
// of the wait queue. If a waiter fails to acquire the mutex for more than 1ms,
// it switches mutex to the starvation mode.


//互斥锁可以有两种操作模式:正常和饿死。

//在正常模式下,协程按FIFO顺序排队,但一个唤醒的等待协程不拥有资源对象(互斥锁),并与新的到达的goroutines竞争所有权。

//新到的gorroutines有一个优势——它们是已经在CPU上运行,可以有很多,所以一个唤醒的等待协程输的机会很大(争抢不到锁资源)。

//在这种情况下,它被排在等待队列的前面,如果一个等待的协程在超过1ms的时间内没有获得互斥对象,它将互斥对象切换到饥饿模式


// In starvation mode ownership of the mutex is directly handed off from
// the unlocking goroutine to the waiter at the front of the queue.
// New arriving goroutines don't try to acquire the mutex even if it appears
// to be unlocked, and don't try to spin. Instead they queue themselves at
// the tail of the wait queue

//

//在饥饿模式下,互斥对象的所有权被直接给到解锁的goroutine在队列前面的等待协程。新到达的goroutines不会尝试获取互斥,相反,他们自己排队等待队列的尾部。
// If a waiter receives ownership of the mutex and sees that either
// (1) it is the last waiter in the queue, or (2) it waited for less than 1 ms,
// it switches mutex back to normal operation mode.

//

//如果一个等待协程获取到了互斥对象的所有权,并且看到了
//(1)它是队列中的最后一个等待协程
//(2)它等待的时间少于1 ms,
//它将互斥锁切换回正常操作模式。

// Normal mode has considerably better performance as a goroutine can acquire
// a mutex several times in a row even if there are blocked waiters.
// Starvation mode is important to prevent pathological cases of tail latency.

//

//普通模式有相当好的性能,因为goroutine可以获得即使有阻塞的等待者,互斥对象也会连续出现几次。
//饥饿模式对于处理延迟等待的协程很重要。

  • 信号量解释
通过操作信号量 S 来处理进程间的同步与互斥的问题。
S>0:表示有 S 个资源可用;
S=0 表示无资源可用;
S<0 绝对值表示等待队列或链表中的进程个数。
信号量 S 的初值应大于等于 0。

P 原语:表示申请一个资源,对 S 原子性的减 1,
若 减 1 后仍 S>=0,则该进程继续执行;
若 减 1 后 S<0,表示已无资源可用,需要将自己阻塞起来,放到等待队列上。

V 原语:表示释放一个资源,对 S 原子性的加 1;
若 加 1 后 S>0,则该进程继续执行;
若 加 1 后 S<=0,表示等待队列上有等待进程,需要将第一个等待的进程唤醒。

通过上面的解释,mutex 就可以利用信号量来实现 goroutine 的阻塞和唤起了。

其实 mutex 本质上就是一个关于信号量的阻塞唤起操作。

当 goroutine 不能占有锁资源的时候会被阻塞挂起,此时不能继续执行后面的代码逻辑。

当 mutex 释放锁资源时,则会继续唤起之前的 goroutine 去抢占锁资源。

至于 mutex 的 state 状态字段则是用来做状态流转的,这些状态值涉及到了一些概念,下面我们具体来解释一番。

  • mutex 状态标志位

mutex 的 state 有 32 位,它的低 3 位分别表示 3 种状态,依次为:上锁状态、唤醒状态、饥饿状态,剩下的位数则表示当前阻塞等待的 goroutine 数量。

mutex 会根据当前的 state 状态来进入正常模式、饥饿模式或者是自旋。

  • mutex 正常模式

当 mutex 调用 Unlock() 方法释放锁资源时,如果发现有等待唤起的 Goroutine 队列时,则会将队头的 Goroutine 唤起。

队头的 goroutine 被唤起后,会调用 CAS 方法去尝试性的修改 state 状态,如果修改成功,则表示占有锁资源成功。

(注:CAS 在 Go 里用 atomic.CompareAndSwapInt32(addr *int32, old, new int32) 方法实现,CAS 类似于乐观锁作用,修改前会先判断地址值是否还是 old 值,只有还是 old 值,才会继续修改成 new 值,否则会返回 false 表示修改失败。)

  • mutex 饥饿模式

由于上面的 Goroutine 唤起后并不是直接的占用资源,还需要调用 CAS 方法去尝试性占有锁资源。如果此时有新来的 Goroutine,那么它也会调用 CAS 方法去尝试性的占有资源。

但对于 Go 的调度机制来讲,会比较偏向于 CPU 占有时间较短的 Goroutine 先运行,而这将造成一定的几率让新来的 Goroutine 一直获取到锁资源,此时队头的 Goroutine 将一直占用不到,导致饿死。

针对这种情况,Go 采用了饥饿模式。即通过判断队头 Goroutine 在超过一定时间后还是得不到资源时,会在 Unlock 释放锁资源时,直接将锁资源交给队头 Goroutine,并且将当前状态改为饥饿模式。

后面如果有新来的 Goroutine 发现是饥饿模式时, 则会直接添加到等待队列的队尾。

  • mutex 自旋

如果 Goroutine 占用锁资源的时间比较短,那么每次都调用信号量来阻塞唤起 goroutine,将会很浪费资源。

因此在符合一定条件后,mutex 会让当前的 Goroutine 去空转 CPU,在空转完后再次调用 CAS 方法去尝试性的占有锁资源,直到不满足自旋条件,则最终会加入到等待队列里。

自旋的条件如下:

还没自旋超过 4 次
多核处理器
GOMAXPROCS > 1
p 上本地 Goroutine 队列为空
可以看出,自旋条件还是比较严格的,毕竟这会消耗 CPU 的运算能力。

  • mutex 的 Lock() 过程

首先,如果 mutex 的 state = 0,即没有谁在占有资源,也没有阻塞等待唤起的 goroutine。则会调用 CAS 方法去尝试性占有锁,不做其他动作。

如果不符合 m.state = 0,则进一步判断是否需要自旋。

当不需要自旋又或者自旋后还是得不到资源时,此时会调用 runtime_SemacquireMutex 信号量函数,将当前的 goroutine 阻塞并加入等待唤起队列里。

当有锁资源释放,mutex 在唤起了队头的 goroutine 后,队头 goroutine 会尝试性的占有锁资源,而此时也有可能会和新到来的 goroutine 一起竞争。

当队头 goroutine 一直得不到资源时,则会进入饥饿模式,直接将锁资源交给队头 goroutine,让新来的 goroutine 阻塞并加入到等待队列的队尾里。

对于饥饿模式将会持续到没有阻塞等待唤起的 goroutine 队列时,才会解除。

  • Unlock 过程

mutex 的 Unlock() 则相对简单。同样的,会先进行快速的解锁,即没有等待唤起的 goroutine,则不需要继续做其他动作。

如果当前是正常模式,则简单的唤起队头 Goroutine。如果是饥饿模式,则会直接将锁交给队头 Goroutine,然后唤起队头 Goroutine,让它继续运行。

mutex 代码详解
好了,上面大体流程讲完了,下面将会把详细的代码流程呈上,让大家能更详细的知道 mutex 的 Lock()、Unlock() 方法逻辑。

mutex Lock() 代码详解:

// Lock mutex 的锁方法。
func (m *Mutex) Lock() {
    // 快速上锁.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }
    // 快速上锁失败,将进行操作较多的上锁动作。
    m.lockSlow()
}

func (m *Mutex) lockSlow() {
  var waitStartTime int64  // 记录当前 goroutine 的等待时间
  starving := false // 是否饥饿
  awoke := false // 是否被唤醒
  iter := 0 // 自旋次数
  old := m.state // 当前 mutex 的状态
  for {
    // 当前 mutex 的状态已上锁,并且非饥饿模式,并且符合自旋条件
    if old&(mutexLocked|mutexStarving) == mutexLocked && runtime_canSpin(iter) {
      // 当前还没设置过唤醒标识
      if !awoke && old&mutexWoken == 0 && old>>mutexWaiterShift != 0 &&
        atomic.CompareAndSwapInt32(&m.state, old, old|mutexWoken) {
        awoke = true
      }
      runtime_doSpin()
      iter++
      old = m.state
      continue
    }
    new := old
    // 如果不是饥饿状态,则尝试上锁
    // 如果是饥饿状态,则不会上锁,因为当前的 goroutine 将会被阻塞并添加到等待唤起队列的队尾
    if old&mutexStarving == 0 {
      new |= mutexLocked
    }
    // 等待队列数量 + 1
    if old&(mutexLocked|mutexStarving) != 0 {
      new += 1 << mutexWaiterShift
    }
    // 如果 goroutine 之前是饥饿模式,则此次也设置为饥饿模式
    if starving && old&mutexLocked != 0 {
      new |= mutexStarving
    }
    //
    if awoke {
      // 如果状态不符合预期,则报错
      if new&mutexWoken == 0 {
        throw("sync: inconsistent mutex state")
      }
      // 新状态值需要清除唤醒标识,因为当前 goroutine 将会上锁或者再次 sleep
      new &^= mutexWoken
    }
    // CAS 尝试性修改状态,修改成功则表示获取到锁资源
    if atomic.CompareAndSwapInt32(&m.state, old, new) {
      // 非饥饿模式,并且未获取过锁,则说明此次的获取锁是 ok 的,直接 return
      if old&(mutexLocked|mutexStarving) == 0 {
        break
      }
      // 根据等待时间计算 queueLifo
      queueLifo := waitStartTime != 0
      if waitStartTime == 0 {
        waitStartTime = runtime_nanotime()
      }
      // 到这里,表示未能上锁成功
      // queueLife = true, 将会把 goroutine 放到等待队列队头
      // queueLife = false, 将会把 goroutine 放到等待队列队尾
      runtime_SemacquireMutex(&m.sema, queueLifo, 1)
      // 计算是否符合饥饿模式,即等待时间是否超过一定的时间
      starving = starving || runtime_nanotime()-waitStartTime > starvationThresholdNs
      old = m.state
      // 上一次是饥饿模式
      if old&mutexStarving != 0 {
        if old&(mutexLocked|mutexWoken) != 0 || old>>mutexWaiterShift == 0 {
          throw("sync: inconsistent mutex state")
        }
        delta := int32(mutexLocked - 1<<mutexWaiterShift)
        // 此次不是饥饿模式又或者下次没有要唤起等待队列的 goroutine 了
        if !starving || old>>mutexWaiterShift == 1 {
          delta -= mutexStarving
        }
        atomic.AddInt32(&m.state, delta)
        break
      }
      // 此处已不再是饥饿模式了,清除自旋次数,重新到 for 循环竞争锁。
      awoke = true
      iter = 0
    } else {
      old = m.state
    }
  }

  if race.Enabled {
    race.Acquire(unsafe.Pointer(m))
  }
}

mutex Unlock() 代码详解:

// Unlock 对 mutex 解锁.
// 如果没有上过锁,缺调用此方法解锁,将会抛出运行时错误。
// 它将允许在不同的 Goroutine 上进行上锁解锁
func (m *Mutex) Unlock() {
    if race.Enabled {
        _ = m.state
        race.Release(unsafe.Pointer(m))
    }

    // 快速尝试解锁
    new := atomic.AddInt32(&m.state, -mutexLocked)
    if new != 0 {
        // 快速解锁失败,将进行操作较多的解锁动作。
        m.unlockSlow(new)
    }
}

func (m *Mutex) unlockSlow(new int32) {
  // 非上锁状态,直接抛出异常
  if (new+mutexLocked)&mutexLocked == 0 {
    throw("sync: unlock of unlocked mutex")
  }
  // 正常模式
  if new&mutexStarving == 0 {
    old := new
    for {
      // 没有需要唤起的等待队列
      if old>>mutexWaiterShift == 0 || old&(mutexLocked|mutexWoken|mutexStarving) != 0 {
        return
      }
      // 唤起等待队列并数量-1
      new = (old - 1<<mutexWaiterShift) | mutexWoken
      if atomic.CompareAndSwapInt32(&m.state, old, new) {
        runtime_Semrelease(&m.sema, false, 1)
        return
      }
      old = m.state
    }
  } else {
    //饥饿模式,将锁直接给等待队列的队头 goroutine
    runtime_Semrelease(&m.sema, true, 1)
  }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值