网站首页 > 技术文章 正文
在 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 语言中的互斥锁是一个强大的工具,用于确保并发安全。通过深入理解互斥锁的结构和工作原理,我们可以更好地应用它来解决并发编程中的问题。
猜你喜欢
- 2024-10-12 漫画 | Linux 并发和竞态问题究竟是什么?
- 2024-10-12 【驱动】串口驱动分析(三)-serial driver
- 2024-10-12 synchronized锁 synchronized锁的是类还是对象
- 2024-10-12 Golang 程序遇到性能问题该怎么办?
- 2024-10-12 线程间通信——互斥锁 线程间互斥方式
- 2024-10-12 【Linux系统编程】互斥锁 linux 互斥锁优先级反转
- 2024-10-12 linux c/c++开发:多线程并发锁:互斥锁、自旋锁、原子操作、CAS
- 2024-10-12 每行代码都带注释,带你看懂Go互斥锁的源码
- 2024-10-12 一文搞懂pprof 一文搞懂伤寒论六经辨证
- 2024-10-12 并发原理系列八:信号量、互斥锁、自旋锁
你 发表评论:
欢迎- 最近发表
- 标签列表
-
- oraclesql优化 (66)
- 类的加载机制 (75)
- feignclient (62)
- 一致性hash算法 (71)
- dockfile (66)
- 锁机制 (57)
- javaresponse (60)
- 查看hive版本 (59)
- phpworkerman (57)
- spark算子 (58)
- vue双向绑定的原理 (68)
- springbootget请求 (58)
- docker网络三种模式 (67)
- spring控制反转 (71)
- data:image/jpeg (69)
- base64 (69)
- java分页 (64)
- kibanadocker (60)
- qabstracttablemodel (62)
- java生成pdf文件 (69)
- deletelater (62)
- com.aspose.words (58)
- android.mk (62)
- qopengl (73)
- epoch_millis (61)
本文暂时没有评论,来添加一个吧(●'◡'●)