likes
comments
collection
share

Go 的 Mutex 是怎样的,为什么需要锁呢?

作者站长头像
站长
· 阅读数 20

那些锁事 —— Mutex

一、为什么需要锁

在了解 Mutex 的实现之前,我们先来了解为什么会存在锁这个同步原语呢?为了响应现代计算机性能的需求,人们引入了多线程模型,以使程序能够快速执行。简单来说就是将一份活,分给多个人做。

(1)多线程模型

比如做饭这个过程,如果是单线程模型,那么你只能一个人将饭做好,需要顺序的进行煮饭、洗菜、切菜、炒菜等过程。如果是多线程模型,那么你可以和你的女朋友一起做饭,她在煮饭的同时,你可以洗菜。你在切菜的同时,她可以起锅烧油,准备开始炒菜。

Go 的 Mutex 是怎样的,为什么需要锁呢?

不仅做事效率提高了,生活的幸福感也提升了。当然了,这只是理想情况,首先你的电脑必须是多核的,就像你想要和女朋友一起做饭,那也得先有女朋友才行。其次勒,当多个线程同时操作同一资源时,会发生资源竞争,可能导致资源的不稳定性,也就是并发问题。就像你和你女朋友一起做饭,在你菜还没有洗好切好,可是她锅里的油都已经快燃起来了,急需将菜下锅,所以你们可能会竞争菜,可能导致安全问题。

(2)出问题了

不说做饭的例子了,毕竟和女朋友一起做饭哪里那么容易啊!!!还是来看贴近程序一些的例子,比如下图:

Go 的 Mutex 是怎样的,为什么需要锁呢?

这里的业务很简单,只要调用 SetUser 方法,并且将设置后的 User 对象返回。当有两个线程并发的操作一个 User 对象的时候,就出现了问题。如图红色方框的部分,线程 1 本身设置的是 User("Ciusyan", 20) 这个对象,返回的却是 User("XiaoCher", 18) 这个对象。线程 1 拿到了错误的结果,它想了很久,可能也不知道是有线程 2 这个老六,几乎与它同一时间操作了同一资源,才导致它出现了问题。

(3)怎么解决呢?

可人们既希望程序高效运行,又想要避免并发问题,于是想了一些解决方案。其实出现并发问题的核心就是发生了资源竞争,何为竞争呢?就是在同一时间操作了同一资源。这句话其实有同一时间和同一资源两个条件。如果要解决这样的并发问题,只需要破坏其中一个条件即可。因为我们这篇文章侧重于讲锁,我们来想办法破坏掉同一时间这个条件。类似于写时拷贝这样的破坏同一资源条件的思想可以自行了解。

“同一时间” 是相对而言的,因为很多操作几乎不可能达到完全并行,只能进行相对的并发。

而破坏同一时间这一条件,其核心是限制多个线程同时访问同一资源,实际上就是让多个线程进行排队。基于这样的思想,引入了类似锁 🔐 这样的工具。比如上面 SetUser 的例子,如果将其加上了锁:

Go 的 Mutex 是怎样的,为什么需要锁呢?

可以看到,想要访问 User 这一资源,需要先将其加锁。加锁的本质其实是让对应的线程排队,拿到锁的线程是排在最前面的,当然可以优先操作这一资源呐。当线程 1 操作完 User 并将其结果返回后,会释放掉锁。让线程 2 也能抢占到锁,当线程 2 抢到锁后,也就能够安全的修改资源了,因为人家辛苦排了那么久的队,凭什么不能安安心心的操作资源!!

在 Go 语言中,也有这样的工具:互斥锁(Mutex)。它是如何破坏掉同一时间的呢?下面我们一起来看看它是如何让多个协程高效排队的。

因为在 Go 中有协程这样轻量级的线程,所以下文所述的多协程模型。可以理解成就是多线程模型。

(了解 Goroutine 的请略过这句话)

二、Go 的互斥锁

(1)Mutex 的底层表示

Mutex 的底层表示非常简单,只有两个整型字段:statesema。其中,state 的类型是 int32,主要用于标识当前锁的状态,它的低三位分别表示是否被上锁、是否有协程被唤醒,以及是否处于饥饿模式,其余的 29 位用于记录正在等待锁的协程数量;另外,sema 字段的类型是 uint32,它被用作信号量锁,当值为 0 时,被当作休眠队列使用,底层使用平衡二叉树来存储休眠的协程。

看似是两个简单的整型的变量,却能表现得如此丰富,能有多丰富呢?如下图所示:

Go 的 Mutex 是怎样的,为什么需要锁呢?

小小鸟却拥有广阔的视野,而这偌大的视野,都是 Go 的 Runtime 赋予的。在了解了 Mutex 底层的抽象表示后,我们来看看它是如何进行加锁和解锁操作的。

sema 锁也被称为信号量锁,他是 Go 语言中一种更为轻量级的锁。核心是一个 uint32 的值,含义是可以同时并发的协程数量。每一个 sema 的数值,都对应一个 SemaRoot 结构体,SemaRoot 可以代表一棵平衡二叉树,树的节点是 sudog 结构体。

在 sema 值大于零时,可以代表锁来使用:值 +1 代表释放锁;值 -1 代表获取锁。

但在 Go 语言中,通常会将其作为一个协程的休眠队列。只需要将 sema 值设置为 0 即可,当值为 0 时,将对应协程放入休眠的堆树中代表获取锁;从堆树中获取一个协程并唤醒代表释放锁。

(2)该如何给协程上锁

当一个协程尝试加锁时,它会直接尝试使用 CAS 操作来抢占锁。如果将 state 的最后一位修改为 1,则抢占成功。如果抢占失败,为了快速获取锁,该协程会继续自旋来尝试加锁。然而,自旋会占用 CPU 资源,因此自旋加锁具有一定的限制条件:

  1. Mutex 处于正常模式。
  2. 自旋次数不超过 4 次。
  3. 不是单核 CPU。
  4. 至少有一个空闲处理器 P。

对于上述条件,没有必要记下来,不理解也没关系。只要知道自旋抢锁需要满足一定的条件,因为自旋会消耗资源,还要控制自旋的次数,还得全局考虑其它竞争的协程。 如果不满足上述条件,该协程将被放入 Sema 堆树中休眠等待。

CAS(Compare And Swap)是一种原子操作。原子操作可以认为是一种硬件层面加锁的机制,在保证操作一个变量的时候,其他的协程无法访问。但是原子操作只能用于简单变量的简单操作。

将上述步骤转换成图片表述:

Go 的 Mutex 是怎样的,为什么需要锁呢?

可以看到,抢锁时会有一条快速路径,如果协程进来直接 CAS 抢锁成功,那么直接就返回了。否则会进入慢路径,经过一系列的操作后,才能获取到锁。下面我接着将慢路径的操作补充完整。

当有协程解锁时,它会发出信号量来唤醒 Sema 堆树中最前面的一个协程。如果此时 Mutex 处于正常模式,该协程将与正在自旋的协程一起竞争抢锁,但由于休眠的协程上下文信息不活跃,很可能因为抢不到锁而再次被放入 Sema 堆树中休眠等待。

这下又被放回 Sema 队列的协程就不开心了啊,本来就来得早,还排了那么久的队,好不容易到它了,还得和新来的协程竞争,还大概率抢不过。久而久之,可能会使协程长时间获取不到锁,出现锁饥饿问题。

为了防止锁饥饿问题的出现,如果一个协程获取锁的时间超过 1 毫秒,Mutex 将进入饥饿模式。在饥饿模式下,新加入的协程在初次获取锁失败后,不会进行自旋尝试,而是直接加入 Sema 堆树中休眠等待。当有协程解锁时,它会直接将锁交给 Sema 堆树中最前面的一个协程,然后发出信号来唤醒它。重复上述逻辑,直到某个协程成功获取到锁。当 Sema 堆树中只剩下一个协程,并且获取锁的时间小于 1 毫秒时,Mutex 将切换回正常模式。

先按上面的思路,将慢路径补充完整:

Go 的 Mutex 是怎样的,为什么需要锁呢?

可以看到,我们在这里引入了锁的饥饿模式,当锁被释放时。如果是饥饿状态,会直接将锁给在 Sema 堆树中排第一的协程。但是这时候,新进入的协程当抢锁失败后,就不会进行自旋尝试了,所以我们还得校正上述过程:

Go 的 Mutex 是怎样的,为什么需要锁呢?

如图所示,在协程尝试抢锁后,就会判断是否属于饥饿模式。如果是饥饿模式,就没必要自旋抢锁了。这下就比较公平了,先上树的协程先得锁。至此,相信你已经弄懂 Mutex 的抢锁过程了,再来看释放锁的过程会轻松很多。

(3)用完了怎么释放呢?

当一个协程释放锁时,它会直接尝试使用 CAS 操作来释放锁。如果将 state 的最后一位修改为 0,则解锁成功。否则,它会判断 Mutex 处于何种模式。如果处于正常模式且没有等待者,或者低三位不全为 0,则说明没有协程要获取锁,或者锁已经被解开,直接返回即可。否则,它会再次尝试解锁,如果成功,则发送信号唤醒 Sema 堆树中的一个协程。重复上述逻辑,直到锁被解开。如果 Mutex 处于饥饿模式,它会直接将锁交给下一个等待的协程,并在等待者被释放后重新获得锁。

Go 的 Mutex 是怎样的,为什么需要锁呢?

这个过程相对于上述加锁的过程要简单许多,而且当解锁成功后,会尝试去 Sema 堆树中唤醒一个在等待锁的协程,这里就和加锁的过程对应起来了。

三、附赠使用技巧

其实在 Go 语言中的锁,非常的简单,但是在使用锁时,也需要注意以下几点:

  1. 尽量减少锁的使用时间,以提高效率。
  2. 不要在未加锁的 Mutex 上解锁,这会导致致命错误。
  3. 加锁和解锁操作最好成对出现,推荐使用 defer 语句来释放锁。
  4. 已加锁的 Mutex 不建议在另一个协程中释放锁。
  5. 对于读多写少的场景,可以考虑使用读写锁(RWMutex)。
  6. Go 更鼓励程序员使用通信的方式来共享内存,可以使用更高级的抽象 Channel 来实现安全的并发操作。

以上只是简单提了一点使用的注意事项,具体该如何使用与如何用好,还是得根据具体的场景而言!

本篇收获

走完了本篇的旅程,来看看你收获了哪些知识吧~

  1. 随着时代的发展,出现了多线程模型,可以更好的利用计算机资源,可以使程序的操作并发的执行,以提升程序的效率。
  2. 但在多线程模型下,可能会因为多条线程同一时间对同一资源进行操作,导致资源竞争,进而出现并发问题。
  3. 可以通过破坏同一时间或者操作同一资源两个条件中的其中一个来解决并发问题。
  4. 锁的核心就是破坏同一时间这个问题,核心是使多线程排队执行。Go 语言也提供了形如 Mutex 这样的互斥锁。
  5. Mutex 的是使用 state 和 sema 两个整型字段表示的,state 代表互斥锁的状态,sema 则作为协程的休眠队列,底层是使用平衡二叉树作为存储结构的。
  6. 加锁和解锁都有快慢路径,快路径都是尝试直接使用 CAS 原子操作加解锁,若成功则直接返回。否则都需要进入慢路径。
  7. 进入慢路径时,需要根据当前互斥锁的模式做对应的操作。互斥锁有正常和饥饿两种模式,正常模式不是一个公平的模式,可能会导致锁饥饿问题。所以引入了一个公平的饥饿模式。
  8. 其中具体加解锁的操作请去文章对应章节查看。这里就不概括了~
转载自:https://juejin.cn/post/7248606372953849912
评论
请登录