一文带你读懂Golang sync包之sync.Mutex

2023-05-16 15:05:35 带你 读懂 一文

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的含义如图

他的低三位分别是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的时候引入了饥饿模式, 在饥饿模式下, 锁的获取方式为非抢占式调度.

正常模式->饥饿模式:

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

饥饿模式->正常模式:

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

sync.Mutex三大方法

Lock()

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

快速上锁

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

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

慢速上锁

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

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

  • state不处于饥饿态
  • 自旋次数小于4次
  • 当前进程运行于多CPU主机
  • 存在至少一个正在运行且工作队列为空的控制器P

之后会根据当前锁的不同状态做出不同的行为, 这里我们分类讨论.

正常状态

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

//如果处于正常态, 那么我们修改新状态为上锁
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, 他的实现十分简单.

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

到此这篇关于一文带你读懂golang sync包之sync.Mutex的文章就介绍到这了,更多相关Golang sync.Mutex内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!

相关文章