Go语言中的互斥锁:深度解析与应用

超级欧派课程 2024-08-21 15:18:46

在 Go 语言的世界里,互斥锁(Mutex)扮演着至关重要的角色,它是确保并发安全的关键工具之一。本文将深入探讨 Go 语言中互斥锁的原理、结构以及加锁和解锁流程,帮助读者更好地理解和应用这一强大的并发控制机制。

一、互斥锁的基本概念

在 Go 语言中,Mutex(即互斥锁,Mutual Exclusion 的缩写)本质上是一种确保在任何时刻仅有一个 goroutine 能够对共享资源进行操作的机制。这里的共享资源可以是一段代码、一个整数、一个映射(map)、一个结构体(struct)、一个通道(channel)或者几乎任何对象。

虽然这个解释并非严格的学术定义,但在实际应用中非常实用。在讨论互斥锁时,我们通常会从问题出发,探讨解决方案,然后深入底层了解其实际的组合方式。

二、为何需要 sync.Mutex

在使用 Go 语言中的映射(map)等共享资源时,如果没有进行适当的保护,多个 goroutine 同时访问和写入可能会导致出现“fatal error: concurrent map read and map write”这样令人不悦的错误。

这时,我们可以使用带有互斥锁的映射或者 sync.Map,但本文的重点是 sync.Mutex。它主要有三个操作:Lock​、Unlock​ 和 TryLock​(本文暂不深入探讨 TryLock​)。

当一个 goroutine 锁定一个互斥锁时,就相当于在宣告:“嘿,我要使用这个共享资源。”其他所有的 goroutine 都必须等待,直到这个互斥锁被解锁。一旦操作完成,它应当解锁互斥锁,以便其他 goroutine 能够依次获得使用共享资源的机会。

例如,以下是一个简单的计数器示例:

在这个例子中,counter​ 变量被 1000 个 goroutine 所共享。对于刚接触 Go 语言的人来说,可能会认为结果应该是 1000,但实际上由于出现了竞态条件,结果永远都不是 1000。竞态条件会在多个 goroutine 试图同时访问和更改共享数据而又没有进行适当同步的时候发生。在这个例子中,递增操作(counter++​)并非原子操作,它由多个步骤组成,在 ARM64 架构下的 Go 汇编代码如下:

​counter++​ 是一个读 - 改 - 写操作,上述步骤并非原子性的,意味着它们不能作为一个不可分割、不可中断的操作来执行。例如,goroutine G1 读取了 counter​ 的值,在它写入更新后的值之前,goroutine G2 读取了相同的值。然后它们都将自己的更新值写回去,但由于它们读取的是相同的原始值,所以实际上有一个递增操作被“丢失”了。

​使用互斥锁可以解决这个问题:

现在,结果正如我们所期望的那样是 1000。在这里,使用互斥锁非常简单:用 Lock​ 和 Unlock​ 包裹关键部分。但请注意,如果你在一个已经解锁的互斥锁上调用 Unlock​,将会导致一个致命错误 sync: unlock of unlocked mutex​。通常,使用 defer mutex.Unlock()​ 是一个好主意,这样可以确保即使在发生错误时也能解锁互斥锁。

另外,通过设置 GOMAXPROCS​ 为 1(通过运行 runtime.GOMAXPROCS(1)​)来运行程序,结果仍然会是正确的 1000。这是因为我们的 goroutine 将不会并行运行,而且由于函数足够简单,在运行过程中也不会被抢占。

三、互斥锁结构剖析

在深入探究 Go 语言中sync.Mutex​的加锁和解锁流程之前,我们先来剖析互斥锁的结构。

在 Go 中,互斥锁的核心由两个字段构成:state​和sema​。

1. state 字段

​state​字段是一个 32 位整数,用于表征互斥锁的当前状态。实际上,它被划分成多个位,这些位编码了关于互斥锁的各种信息。

已锁定(位 0):表明互斥锁当前是否被锁定。若设置为 1,则表示互斥锁已被锁定,其他 goroutine 无法获取它。已唤醒(位 1):如果有任何 goroutine 已被唤醒并正在尝试获取互斥锁,此位则设置为 1。这样可以确保其他 goroutine 不会被不必要地唤醒。饥饿模式(位 2):该位表示互斥锁是否处于饥饿模式(设置为 1 时处于饥饿模式)。稍后我们将深入探讨此模式的具体含义。等待者(位 3 - 31):其余位用于追踪正在等待互斥锁的 goroutine 数量。2. sema 字段

​sema​是一个uint32​类型,它作为信号量来管理并通知等待的 goroutine。当互斥锁被解锁时,会唤醒一个等待的 goroutine 以获取锁。与state​字段不同,sema​没有特定的位布局,而是依赖于运行时内部代码来处理信号量逻辑。

互斥锁结构

让我们根据图像对state​字段进行概述:

已锁定(位0):表示互斥锁当前是否被锁定。如果设置为1,则表示互斥锁已被锁定,其他goroutine无法获取它。已唤醒(位1):如果有任何goroutine已被唤醒并正在尝试获取互斥锁,则设置为1。其他goroutine不应被不必要地唤醒。饥饿模式(位2):此位表示互斥锁是否处于饥饿模式(设置为1)。稍后我们将深入探讨此模式的含义。等待者(位3-31):其余位用于跟踪正在等待互斥锁的goroutine数量。四、互斥锁的加锁流程

在mutex.Lock​函数当中,存在两种路径:快速路径用于应对常见情况,慢速路径则用于处理不常见的情形。

1. 快速路径

快速路径设计得极为迅速,预期能够处理大多数互斥锁未被占用时的加锁操作。此路径还会被内联,意味着它直接嵌入到调用函数之中。当快速路径中的 CAS(Compare And Swap)操作失败时,意味着状态字段不是 0,所以互斥锁当前处于锁定状态。

2. 慢速路径

真正的关键在于慢速路径m.lockSlow​,它承担了大部分繁重的工作。在慢速路径中,goroutine 会保持积极旋转以尝试获取锁,而不是直接进入等待队列。

“旋转”意味着 goroutine 进入一个紧密的循环,不断检查互斥锁的状态而不放弃 CPU。在这种情况下,它并非一个简单的for​循环,而是使用低级汇编指令来执行自旋等待。例如在 ARM64 架构上的这段代码:

汇编代码运行一个持续 30 个周期的紧密循环(runtime.procyield(30)​),不断让出 CPU 并减少自旋计数器。

经过旋转后,它会再次尝试获取锁。如果失败,它还有三次旋转机会,总共尝试最多 120 个周期。如果仍然无法获取锁,它会增加等待者计数,将自己放入等待队列,进入睡眠状态,等待信号唤醒后再次尝试。

旋转背后的理念是等待一小段时间,期望互斥锁能够很快被释放,这样 goroutine 就可以在不经过睡眠 - 唤醒周期的开销下获取锁。如果我们的计算机没有多个核心,那么旋转将不会被启用,因为它只会浪费 CPU 时间。

正常模式下的互斥锁

互斥锁有两种模式:正常模式和饥饿模式。在正常模式下,等待互斥锁的 goroutine 按照先进先出(FIFO)队列组织。当一个 goroutine 醒来尝试获取互斥锁时,它不会立即获得控制权,而是需要与任何此时也想获取互斥锁的新 goroutine 竞争。这种竞争对新 goroutine 有利,因为它们已经在 CPU 上运行,并且可以迅速尝试获取互斥锁,而队列中的 goroutine 仍在唤醒过程中。因此,刚刚醒来的 goroutine 可能会经常输给新的竞争者并被放回队列的前端。

如果 goroutine 很不幸,总是在新 goroutine 到达时醒来,那么它将永远无法获取锁。这就是为什么我们需要将互斥锁切换到饥饿模式。如果 goroutine 超过 1 毫秒未能获取锁,饥饿模式就会启动。在此模式下,当 goroutine 释放互斥锁时,它会直接将控制权传递给队列前端的 goroutine。这意味着没有竞争,没有新 goroutine 的争夺。它们甚至不会尝试获取锁,只是加入等待队列的末尾。

处于饥饿模式的互斥锁

在上面的图像中,互斥锁继续为G1、G2等提供访问权限。每个等待中的goroutine都会获得控制权并检查两个条件:

它是否是等待队列中的最后一个goroutine。它是否等待了不到一毫秒。

如果这两个条件中的任何一个为真,互斥锁就会切换回正常模式。

五、互斥锁解锁流程

解锁流程比加锁流程更为简单。我们仍然有两条路径:快速路径(内联)和慢速路径(处理异常情况)。

快速路径会清除互斥锁状态中的锁定位。如果清除这个位使状态变为零,则表示没有设置其他标志(如等待的 goroutine),我们的互斥锁现在完全空闲。

但如果状态不是零呢?这时慢速路径就会介入,并且需要知道我们的互斥锁是处于正常模式还是饥饿模式。

在正常模式下,如果有等待者且没有其他 goroutine 被唤醒或获取锁,互斥锁会尝试原子地递减等待者计数并设置mutexWoken​标志。如果成功,它会释放信号量以唤醒一个等待的 goroutine 来获取互斥锁。

在饥饿模式下,它会原子地增加信号量(mutex.sem​)并直接将互斥锁的所有权交给队列中的第一个等待 goroutine。runtime_Semrelease​的第二个参数决定了是否进行“移交”操作,此处为true​。

六、总结

Go 语言中的互斥锁是一个强大的工具,用于确保并发安全。通过深入理解互斥锁的结构和工作原理,我们可以更好地应用它来解决并发编程中的问题。

0 阅读:0

超级欧派课程

简介:感谢大家的关注