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 内部主要依赖一个叫 stateint32 整型变量来做所有事情。这个整数的不同 bit 位代表了不同的含义:

  • Locked Bit (锁定状态位):第 0 位,表示该锁是否已被持有。
  • Woken Bit (唤醒状态位):第 1 位,表示是否有 goroutine 被唤醒。
  • Starving Bit (饥饿状态位):第 2 位,表示锁是否进入“饥饿模式”。
  • Waiter Count (等待者数量):剩余的 bit 位,用来记录有多少个 goroutine 在排队等锁。

Go 的 Mutex 默认是非公平的,新来的 goroutine 可以“插队”。但为了防止队首的 goroutine 永远抢不到锁(饿死),引入了饥饿模式,切换为公平锁。

1.3 RWMutex 内部原理

1
2
3
4
5
6
7
type RWMutex struct {
w Mutex // 互斥锁,用于写锁
writerSem uint32 // 写者信号量
readerSem uint32 // 读者信号量
readerCount int32 // 读者计数器
readerWait int32 // 等待读者完成的数量
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func main() {  
var rwMu sync.RWMutex

// 获取写锁
rwMu.Lock()

// 尝试获取读锁
go func() {
if rwMu.TryRLock() {
fmt.Println("成功获取读锁")
rwMu.RUnlock()
} else {
fmt.Println("获取读锁失败")
}
}()

// 尝试获取写锁
go func() {
time.Sleep(50 * time.Millisecond)
if rwMu.TryLock() {
fmt.Println("成功获取写锁")
rwMu.Unlock()
} else {
fmt.Println("获取写锁失败")
}
}()

time.Sleep(200 * time.Millisecond)
rwMu.Unlock()
}

/* 输出结果
获取读锁失败
获取写锁失败
*/

2.2 注意事项

  1. 保持临界区简短:被锁保护的代码区域(临界区)应该尽可能小。不要在锁内做耗时的操作,如 I/O、复杂的计算等,这会严重影响并发性能。

  2. defer 是你的好朋友:永远使用 defer 来确保 UnlockRUnlock 被调用。这能防止因为函数提前 return 或发生 panic 而导致锁无法释放,造成程序死锁。

  3. 明确保护对象:一个锁应该只保护一个或一组紧密相关的共享资源。不要用一个全局的“万能锁”去锁住所有东西,这会使程序退化成单线程。

  4. 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 会:

  1. 锁定内存总线 或使用更高效的缓存一致性协议。
  2. 确保在它执行“读取内存 -> 修改值 -> 写回内存”这整个系列操作期间,其他任何 CPU 核心都不能访问这块内存。
  3. 这个过程作为一个单一的、不可分割的操作完成。

所以,atomic 的性能极高,因为它本质上就是一个或几个机器指令,避免了复杂的锁机制和 goroutine 调度。

3.2 和 mutex 的区别

特性sync/atomicsync.Mutex
保护对象单个变量(如 int64, uint32, 指针)一段代码逻辑(临界区),可以包含多个变量和复杂操作
实现机制硬件指令(CPU-level)软件机制(OS-level,涉及 goroutine 调度)
性能极高,开销非常小相对较低,有竞争时开销显著增大
使用场景简单的计数器、状态标志位、配置值的读取更新保护结构体中的多个字段,或者需要保证多个操作步骤的整体原子性
易用性API 简单,但用它构建复杂逻辑(无锁编程)极其困难概念直观,易于理解和正确使用 (Lock/Unlock)
总结性能压榨机,用于最底层的同步需求通用工具箱,用于保护绝大多数的并发场景

4. 互斥锁工作模式

Go 语言的互斥锁 (sync.Mutex) 非常聪明,它为了平衡性能和公平,设计了两种工作模式:正常模式(Normal Mode) 和 饥饿模式(Starvation Mode),同一时间一个锁只有一个模式。

4.1 正常模式 (Normal Mode)

这是默认的工作模式,它的核心思想是:性能优先,允许“抢锁”。

  • 工作流程:
    1. 当锁被释放时,它会优先唤醒在“等待室”(等待队列)里排在最前面的那个人(goroutine)。
    2. 但是,就在被唤醒的人从椅子上站起来、走向柜台的这个短暂间隙,如果一个刚来的、精力充沛的新人(新来的 goroutine)直接冲到柜台前,并且成功用“原子图章”盖了章(CAS操作成功),那么这个新人就可以抢先拿到锁。
    3. 被唤醒的那个排队者,发现锁又被抢了,只好无奈地回到等待室的队头,继续等待下一次机会。
  • 优点:高吞吐量。如果一个 goroutine 恰好在 CPU 上运行,它能立刻拿到锁并开始工作,避免了唤醒一个睡眠中的 goroutine 所带来的上下文切换、缓存失效等开销。整体上,大家干活的总量会更多。
  • 缺点:可能导致不公平。如果新人总是能成功抢锁,那么等待队列里的人可能会等很久,这就是“尾端延迟”(tail latency)问题。

4.2 饥饿模式 (Starvation Mode)

当系统检测到不公平可能发生时,就会切换到这个模式。它的核心思想是:公平优先,严禁“插队”。

  • 触发条件:
    • 当一个 goroutine 在等待室里等待的时间超过了 1 毫秒(1ms),系统就会认为它可能“饿”着了。
  • 工作流程:
    1. 一旦进入饥饿模式,许可证办公室的规则就变了。锁被释放后,它会直接、钦定地交给等待队列最前面的那个人。
    2. 任何新来的人,无论跑得多快,都会被告知:“对不起,请去后面排队。”它们失去了抢锁的资格。
    3. 这变成了一个严格的先进先出(FIFO)队列。
  • 优点:绝对公平。保证了等待的 goroutine 最终一定能拿到锁,解决了“饿死”的问题。
  • 缺点:性能略有损失。即使有一个正在运行的 goroutine 可以立即工作,系统也必须去唤醒一个沉睡的 goroutine,这个过程是有开销的,可能会降低整体的吞吐量。
  • 退出条件:当等待队列变空,或者一个 goroutine 是队列里最后一个等待者并成功获取了锁,Mutex 就会从饥饿模式切换回正常模式,重新开启“抢锁”以追求更高的性能。

4.3 正常模式下自旋

自旋锁:https://www.liuvv.com/p/4af98226.html

当一个 goroutine 尝试获取 Mutex, 失败时,它的行为是这样的:

  1. 第一阶段(尝试自旋):它会先进行极其短暂的自旋(Spinning)。它会赌一下:也许锁马上就会被释放。如果在这几次自旋尝试中成功拿到了锁,就避免了进入“等待室”的巨大开销。这是自旋锁思想在互斥锁中的应用。
  2. 第二阶段(进入等待队列):如果短暂的自旋后仍然没有拿到锁,它就放弃了,认为锁可能要被持有较长时间。于是,它将自己加入到等待队列,并进入休眠(这是传统互斥锁的行为)。
  3. 模式切换(正常 vs. 饥饿):在等待队列中,它遵循着我们上面讲的正常模式和饥饿模式的切换逻辑,以平衡性能和公平。

前提条件:进入自旋阶段是有条件的,不是任何时候都会发生。必须满足所有以下条件:

  1. 多核环境:GOMAXPROCS > 1。在单核 CPU 上自旋毫无意义,只会浪费时间。
  2. 自旋次数未超限:有一个内置的、较小的自旋次数上限(通常是 4 次)。
  3. 锁未处于饥饿模式:如果锁已经是饥饿模式,说明有 goroutine 已经等了很久,必须保证公平,不能再让新来的抢。
  4. 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 重新变为可运行状态)。