Golang是怎样实现的: sync包之sync.Mutex

787 阅读6分钟

sync.Mutex可以说是sync包的核心了, sync.RWMutex, sync.WaitGroup...都依赖于他, 本章我们将带你一文读懂sync.Mutex. 我们主要介绍如下内容

  • sync.Mutex数据结构
  • 为什么sync.Mutex不需要初始化
  • 正常模式和饥饿模式
  • sync.Mutex的三大方法, Lock(), UnLock(), TryLock()

sync.Mutex的数据结构

type Mutex struct {
   state int32
   sema  uint32
}

我们可以发现sync.Mutex的数据结构十分简单, 他只有两个字段

  • state: 一个32位整数, 表示了当前锁的状态
  • sema: 他代表一个信号量(信号量是一个无符号整数,OS对信号量提供了能使其原子性自增/自减的PV操作, 可以通过信号量来实现互斥)

state

state的含义如图

image.png 他的低三位分别是Locked, Woken, Starving. 剩余28位表示在当前Mutex中阻塞的goroutine数目

  • Locked: 当前Mutex是否处于上锁状态, 0: 上锁, 1: 上锁.
  • Woken: 当前Mutex是否存在goroutine被唤醒, 0: 没有, 1: 存在被唤醒的goroutnine正在上锁.
  • Starving: 当Mutex处于何种模式, 0: 正常模式, 1: 饥饿模式

为什么sync.Mutex不需要初始化

当我们声明一个变量但不给他赋值时, 他会被默认附上初始值, 这个初始值对于指针类型是nil, 而其他类型则是其零值.

因此, 当我们仅声明sync.Mutex时, 他会被默认赋值{state:0,sema:0}, 而这个值恰好表示初始的锁状态. 所以 ,我们可以仅在声明后直接使用sync.Mutex.

正常模式和饥饿模式

在了解正常模式和饥饿模式前, 我们先来看下抢占式和非抢占式.

抢占式和非抢占式是调度的两种方式

抢占式: 当一个新goroutine请求锁时, 他会和当前被唤醒的goroutine进行竞争, 竞争成功就获取锁. 非抢占式: 如果阻塞队列中存在有其他goroutine, 那么新请求锁的goroutine会直接进入阻塞队列排队.

一般情况下, sync.Mutex处于正常模式, 在该模式下, 锁的获取方式为抢占式调度. 而一般情况下, 因为新请求锁的goroutine正在持有CPU且可能不止一个, 这就导致阻塞队列中新被唤醒的goroutine是难以抢占过新请求锁的goroutine的, 从而导致饥饿现象.

为了解决这种饥饿现象, go语言在go1.9的时候引入了饥饿模式, 在饥饿模式下, 锁的获取方式为非抢占式调度.

正常模式->饥饿模式:

  1. 当一个goroutine在阻塞队列等待超过1ms后, 他就会修改state使锁的状态变为饥饿模式

饥饿模式->正常模式:

  1. 当阻塞队列中某个goroutine阻塞时间小于1ms时
  2. 阻塞队列中重新变为空时

sync.Mutex三大方法

Lock()

Lock方法有两种上锁方式, 快速上锁和慢速上锁

快速上锁

假如当前sync.Mutex的state=0, 那么就意味着当前sync.Mutex尚未被任何goroutine持有且阻塞队列中没有任何goroutine.

那么当前请求锁的goroutine就会通过CAS的方式修改state=1(意味着上锁), 然后返回.

慢速上锁

否则, 会调用lockSlow()方法来慢速上锁.

  1. 首先尝试通过自旋的方式获取锁, 在如下四个条件全部满足时会继续自旋, 如果成功通过自旋获取锁, 就直接返回

    • state不处于饥饿态
    • 自旋次数小于4次
    • 当前进程运行于多CPU主机
    • 存在至少一个正在运行且工作队列为空的控制器P
  2. 之后会根据当前锁的不同状态做出不同的行为, 这里我们分类讨论.

正常状态

我们只截取少量的核心代码.

//如果处于正常态, 那么我们修改新状态为上锁
if old&mutexStarving == 0 {
    new |= mutexLocked 
}

//通过cas的方式修改当前锁状态
if atomic.CompareAndSwapInt32(&m.state, old, new) {
        //如果旧状态处于正常态且未上锁, 那么就意味着当前线程抢占到了锁, 直接返回
	if old&(mutexLocked|mutexStarving) == 0 {
		break //这里的break可以理解为return, 不用return是因为最后有个对race.Enabled的判断, 用于竞态检测
	}
        ......
}

如果处于正常状态, 那么允许锁抢占, 我们先把新的锁状态修改为直接上锁, 这是因为假如他之前处于上锁态, 那么之后也会处于上锁态, 而如果处于未上锁状态, 那么当前goroutine会为其上锁, 因此锁的新状态一定处于上锁态.

然后我们通过CAS的方式用新状态替换旧状态. 替换成功后判断old(更新前的状态)是否处于正常态且未上锁, 如果是, 那么说明是当前goroutine上的锁, 这也就意味着当前goroutine成功获取锁了, 那么直接返回.

否则会调用runtime_SemacquireMutex来在sema信号量下阻塞当前goroutine.

饥饿状态

饥饿状态会直接调用runtime_SemacquireMutex来在sema信号量下阻塞当前goroutine.

被唤醒后

在某个goroutine被唤醒后, 他会判断自己阻塞时间是否超过1ms, 如果超过, 则切换为饥饿模式, 否则判断自己是否处于饥饿模式且阻塞的时间小于1ms, 如果处于饥饿模式且阻塞时间小于1ms, 那么就退出饥饿模式.

Unlock()

unlock()方法也分为快速解锁和慢速解锁两部分

快速解锁

我们直接让new=当前状态-mutexLocked如果new=0, 则意味着只有当前goroutine在持有锁, 且无任何goroutine在等待锁, 那么直接CAS修改m.state=new然后返回即可.

慢速解锁

否则调用unlockSlow()函数来解锁, unlockSlow()函数也会根据当前Mutex的不同状态做出不同的行为

不过首先, unlockSlow()会判断当前Mutex是否处于上锁态, 如果我们对未上锁的Mutex调用Unlock()函数, 会爆出sync: unlock of unlocked mutex的panic.

正常状态

通过state的前28位判断当前等待锁的goroutine是否为0, 如果是, 那么直接解锁返回.

否则通过CAS的方式修改当前Mutex状态为new, 如果修改成功, 那么将释放一个信号量来随机唤醒一个阻塞在sema中的goroutine

//false意味着随机唤醒
runtime_Semrelease(&m.sema, false, 1)

饥饿状态

如果当前Mutex处于饥饿状态, 那么说明一定存在阻塞的goroutine, 将释放一个信号量来唤醒sema中第一个阻塞的goroutine

//true为顺序唤醒
runtime_Semrelease(&m.sema, true, 1)

TryLock()

TryLock是尝试上锁, 如果上锁成功, 返回true, 否则返回false, 他的实现十分简单.

  1. 判断当前状态Mutex的状态是否是饥饿态或者已上锁, 如果是, 则直接返回false
  2. 通过CAS的方式尝试为Mutex上锁, 上锁成功则返回true,否则返回false