golang的锁机制
1. Golang 的锁
1.1 锁的分类
sync.Mutex
(互斥锁):一个“排他锁”。在任何时刻,最多只允许一个 goroutine(Go 语言中的轻量级线程)进入被它保护的代码区域。一旦一个 goroutine 持有该锁,其他任何试图获取该锁的 goroutine 都会被阻塞,直到锁被释放。sync.RWMutex
(读写互斥锁):一个“共享-排他锁”。它更加智能,对读和写操作进行了区分:- 读锁 (Read Lock):可以被多个 goroutine 同时持有。只要没有 goroutine 持有写锁,任意数量的 goroutine 都可以获得读锁。
- 写锁 (Write Lock):是完全排他的。当一个 goroutine 持有写锁时,其他任何 goroutine(无论是想读还是想写)都必须等待。
1.2 Mutex 内部原理
Mutex
内部主要依赖一个叫 state
的 int32
整型变量来做所有事情。这个整数的不同 bit 位代表了不同的含义:
- Locked Bit (锁定状态位):第 0 位,表示该锁是否已被持有。
- Woken Bit (唤醒状态位):第 1 位,表示是否有 goroutine 被唤醒。
- Starving Bit (饥饿状态位):第 2 位,表示锁是否进入“饥饿模式”。
- Waiter Count (等待者数量):剩余的 bit 位,用来记录有多少个 goroutine 在排队等锁。
Go 的 Mutex 默认是非公平的,新来的 goroutine 可以“插队”。但为了防止队首的 goroutine 永远抢不到锁(饿死),引入了饥饿模式,切换为公平锁。
1.3 RWMutex 内部原理
1 | type RWMutex struct { |
RWMutex 内部其实包含了一个 Mutex,并额外增加了一些计数器来协调读和写。
w (Mutex):一个内部互斥锁,主要用来保护写操作和其他内部状态的修改。
writerSem, readerSem (信号量):用来阻塞和唤醒写 goroutine 和读 goroutine。
readerCount (int32):一个复合状态的计数器。
当它为正数时,表示当前持有读锁的 goroutine 数量。
当它为负数时,表示有一个写锁被持有。
readerWait (int32):等待写锁释放的读 goroutine 数量。
工作流程
- RLock() (请求读锁):
- 原子地给 readerCount加 1。
- 如果结果是负数(意味着有写锁),则说明有写操作正在进行或等待,当前读 goroutine 需等待。
- 否则,成功获取读锁。
- Lock() (请求写锁):
- 首先,获取内部的 Mutex w,这会阻止新的读/写请求进来。
- 然后,原子地将 readerCount 减去一个巨大常数 rwmutexMaxReaders,把它变成负数,标记“写模式”。
- 检查是否还有正在读的 goroutine(看 readerCount 的原始值),如果有,则等待它们全部 RUnlock()。
成功获取写锁。
RWMutex
的设计有一个写优先的倾向。当一个写锁请求到来时,它会阻止后续新的读锁请求,以避免写操作被源源不断的读操作“饿死”。
在纯写场景下,RWMutex 比 Mutex 慢。通常读写比 > 10:1 时才考虑 RWMutex。如果不确定,可以先使用 Mutex,在确定有性能问题且读操作远多于写操作时,再考虑使用 RWMutex。
2. 锁的使用方式
2.1 TryRLock 和 TryLock
1 | func main() { |
2.2 注意事项
保持临界区简短:被锁保护的代码区域(临界区)应该尽可能小。不要在锁内做耗时的操作,如 I/O、复杂的计算等,这会严重影响并发性能。
defer
是你的好朋友:永远使用defer
来确保Unlock
或RUnlock
被调用。这能防止因为函数提前return
或发生panic
而导致锁无法释放,造成程序死锁。明确保护对象:一个锁应该只保护一个或一组紧密相关的共享资源。不要用一个全局的“万能锁”去锁住所有东西,这会使程序退化成单线程。
RWMutex
的“饥饿”问题:虽然RWMutex
对写锁有一定优先,但在极端的读密集场景下,写锁仍可能长时间等待。Go 1.8 版本的RWMutex
实现对此做了优化,但理解其内在权衡很重要。
2.3 不正确的使用方式
死锁 (Deadlock):最经典的陷阱。
两个或多个 goroutine 互相等待对方释放锁。最常见的是”AB-BA”死锁:Goroutine 1 锁住 A 再请求 B,Goroutine 2 锁住 B 再请求 A。
在
RLock
保护的区域内,调用了需要获取写锁的函数,这会造成死锁。因为写锁会等待所有读锁释放,而当前 goroutine 自己就持有一个读锁,它永远不会释放,导致写锁永远等待。
在不该用的地方用锁 (Over-locking)。
对于一些可以通过其他方式实现并发安全的场景,滥用锁。例如,使用 channel 进行 goroutine 间的数据传递和同步通常是更符合 Go 设计哲学的做法(“不要通过共享内存来通信,而要通过通信来共享内存”)。
在设计并发模型时,优先考虑 channel。只有在需要保护某个状态或数据结构时,才使用锁。
3. sync/atomic
sync/atomic 包提供了底层的、硬件级别的原子内存操作,专门用于对整型(int32, int64 等)和指针(unsafe.Pointer)这些基础数据类型进行无需锁的并发安全修改。
atomic
操作就像是按下一个体育馆门口的【电子计数器按钮】。这个按钮被设计成了“原子”的。
无论多少人(goroutine)在同一瞬间试图去按它,它的内部机制都能确保每一次按压都完整地让数字 +1
,绝不会出现两个人同时按、数字却只加了一次的情况。这个操作是如此之快、如此底层,以至于它在执行过程中不可被中断。
3.1 它不是魔法,是硬件!
Mutex
是一个由操作系统和 Go 调度器协作实现的“软件锁”,如果发生锁竞争,可能会导致 goroutine 的挂起和上下文切换,开销较大。
atomic
则完全不同,它绕过了操作系统,直接利用了 CPU 提供的特殊指令。
在现代多核 CPU 中,都有一套指令集来保证对某个内存地址的操作是原子的。例如在 x86 架构上,这通常是通过 LOCK
指令前缀来实现的。当一条指令(比如 ADD
)带上 LOCK
前缀时,CPU 会:
- 锁定内存总线 或使用更高效的缓存一致性协议。
- 确保在它执行“读取内存 -> 修改值 -> 写回内存”这整个系列操作期间,其他任何 CPU 核心都不能访问这块内存。
- 这个过程作为一个单一的、不可分割的操作完成。
所以,atomic
的性能极高,因为它本质上就是一个或几个机器指令,避免了复杂的锁机制和 goroutine 调度。
3.2 和 mutex 的区别
特性 | sync/atomic | sync.Mutex |
---|---|---|
保护对象 | 单个变量(如 int64 , uint32 , 指针) | 一段代码逻辑(临界区),可以包含多个变量和复杂操作 |
实现机制 | 硬件指令(CPU-level) | 软件机制(OS-level,涉及 goroutine 调度) |
性能 | 极高,开销非常小 | 相对较低,有竞争时开销显著增大 |
使用场景 | 简单的计数器、状态标志位、配置值的读取更新 | 保护结构体中的多个字段,或者需要保证多个操作步骤的整体原子性 |
易用性 | API 简单,但用它构建复杂逻辑(无锁编程)极其困难 | 概念直观,易于理解和正确使用 (Lock /Unlock ) |
总结 | 性能压榨机,用于最底层的同步需求 | 通用工具箱,用于保护绝大多数的并发场景 |
4. 互斥锁工作模式
Go 语言的互斥锁 (sync.Mutex
) 非常聪明,它为了平衡性能和公平,设计了两种工作模式:正常模式(Normal Mode) 和 饥饿模式(Starvation Mode),同一时间一个锁只有一个模式。
4.1 正常模式 (Normal Mode)
这是默认的工作模式,它的核心思想是:性能优先,允许“抢锁”。
- 工作流程:
- 当锁被释放时,它会优先唤醒在“等待室”(等待队列)里排在最前面的那个人(goroutine)。
- 但是,就在被唤醒的人从椅子上站起来、走向柜台的这个短暂间隙,如果一个刚来的、精力充沛的新人(新来的 goroutine)直接冲到柜台前,并且成功用“原子图章”盖了章(CAS操作成功),那么这个新人就可以抢先拿到锁。
- 被唤醒的那个排队者,发现锁又被抢了,只好无奈地回到等待室的队头,继续等待下一次机会。
- 优点:高吞吐量。如果一个 goroutine 恰好在 CPU 上运行,它能立刻拿到锁并开始工作,避免了唤醒一个睡眠中的 goroutine 所带来的上下文切换、缓存失效等开销。整体上,大家干活的总量会更多。
- 缺点:可能导致不公平。如果新人总是能成功抢锁,那么等待队列里的人可能会等很久,这就是“尾端延迟”(tail latency)问题。
4.2 饥饿模式 (Starvation Mode)
当系统检测到不公平可能发生时,就会切换到这个模式。它的核心思想是:公平优先,严禁“插队”。
- 触发条件:
- 当一个 goroutine 在等待室里等待的时间超过了 1 毫秒(1ms),系统就会认为它可能“饿”着了。
- 工作流程:
- 一旦进入饥饿模式,许可证办公室的规则就变了。锁被释放后,它会直接、钦定地交给等待队列最前面的那个人。
- 任何新来的人,无论跑得多快,都会被告知:“对不起,请去后面排队。”它们失去了抢锁的资格。
- 这变成了一个严格的先进先出(FIFO)队列。
- 优点:绝对公平。保证了等待的 goroutine 最终一定能拿到锁,解决了“饿死”的问题。
- 缺点:性能略有损失。即使有一个正在运行的 goroutine 可以立即工作,系统也必须去唤醒一个沉睡的 goroutine,这个过程是有开销的,可能会降低整体的吞吐量。
- 退出条件:当等待队列变空,或者一个 goroutine 是队列里最后一个等待者并成功获取了锁,
Mutex
就会从饥饿模式切换回正常模式,重新开启“抢锁”以追求更高的性能。
4.3 正常模式下自旋
自旋锁:https://www.liuvv.com/p/4af98226.html
当一个 goroutine 尝试获取 Mutex, 失败时,它的行为是这样的:
- 第一阶段(尝试自旋):它会先进行极其短暂的自旋(Spinning)。它会赌一下:也许锁马上就会被释放。如果在这几次自旋尝试中成功拿到了锁,就避免了进入“等待室”的巨大开销。这是自旋锁思想在互斥锁中的应用。
- 第二阶段(进入等待队列):如果短暂的自旋后仍然没有拿到锁,它就放弃了,认为锁可能要被持有较长时间。于是,它将自己加入到等待队列,并进入休眠(这是传统互斥锁的行为)。
- 模式切换(正常 vs. 饥饿):在等待队列中,它遵循着我们上面讲的正常模式和饥饿模式的切换逻辑,以平衡性能和公平。
前提条件:进入自旋阶段是有条件的,不是任何时候都会发生。必须满足所有以下条件:
- 多核环境:
GOMAXPROCS > 1
。在单核 CPU 上自旋毫无意义,只会浪费时间。 - 自旋次数未超限:有一个内置的、较小的自旋次数上限(通常是 4 次)。
- 锁未处于饥饿模式:如果锁已经是饥饿模式,说明有 goroutine 已经等了很久,必须保证公平,不能再让新来的抢。
- CPU 资源尚可:运行时系统会判断当前 P(Processor,可以理解为执行 goroutine 的线程)是否还有其他可运行的 goroutine。如果没有,自旋是比较划算的。
5. 提问问题
5.1 硬件如何保证的
只有硬件的物理电路才能保证操作真正 “ 不可分割 “——当 CPU 发出原子指令时,内存控制器会像电流触发开关一样,在单个时钟周期内物理锁定总线,让整个操作成为一个不可中断的电气事件。
这里的“保证”来自于 CPU 的物理电路。它就像是给连接 CPU 和内存的高速公路(内存总线)上了一把电控锁,或者是通过核心之间高效的“缓存一致性协议”来完成。这是一条硬件层面的铁律,而不是一个软件层面的建议。这是最底层、最快速的控制方式。
5.2 mutex 也依赖硬件吗?
Mutex 的底层绝对依赖硬件的原子操作。
atomic
本身就是那个硬件操作。Mutex
使用硬件原子操作作为积木,来搭建一个更复杂、更“智能”的锁系统。
mutex 竞争情况
- 但如果你走上前,发现指示牌已经是“使用中”了呢?你的“原子图章”盖下去就会失败。此刻,
Mutex
才展现出它更复杂的一面。 - 你不能傻站在那儿不停地尝试盖章(这叫“自旋锁”,会空耗 CPU)。
- 取而代之,你走进旁边的等待室。你在一个等候列表上写下自己的名字,然后拿出手机开始刷视频(你的 goroutine 被 Go 调度器挂起了,主动让出 CPU)。
- 这部分操作比较复杂了,它涉及到了 Go 运行时和操作系统的介入,不再是一个简单的硬件指令。
- 当持有许可证的那个 goroutine 干完活回来 (
Unlock
),它会查看等待列表,唤醒排在第一位的人(调度器让你的 goroutine 重新变为可运行状态)。