1. 快速了解

在一些多进程或多线程的场景下,可能会出现数据不一致的情况,这时候就需要通过加锁来保证数据的一致性,下面可以简单来看几个例子,快速了解一下锁的重要性。

  • 不用锁的情况

由于没有加锁,可能会导致 total 最终得到的值不是期望的结果。

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
package lock

import (
"fmt"
"sync"
)

var total int
var wg sync.WaitGroup

func add() {
defer wg.Done()
for i := 0; i < 100000; i++ {
total += 1
}
}

func sub() {
defer wg.Done()
for i := 0; i < 100000; i++ {
total -= 1
}
}

func main() {
wg.Add(2)
go add()
go sub()
wg.Wait()
fmt.Println(total)
}

之所以期望不一致,是因为每次执行代码计算操作时,在编译器中只是显示一行 a+=1 或 a-=1。但在代码的底层其实是分为了三步执行,而由于没有加锁,所以这三步操作并不能保证使原子性的。

  • 加锁

当代码带锁之后,每一步操作的同一时间段都不会有其他进程操作,从而可以保证数据的一致性。

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
36
package lock

import (
"fmt"
"sync"
)

var total int
var wg sync.WaitGroup
var lock sync.Mutex //定义一个锁变量

func add() {
defer wg.Done()
for i := 0; i < 100000; i++ {
lock.Lock()
total += 1
lock.Unlock()
}
}

func sub() {
defer wg.Done()
for i := 0; i < 100000; i++ {
lock.Lock()
total -= 1
lock.Unlock()
}
}

func main() {
wg.Add(2)
go add()
go sub()
wg.Wait()
fmt.Println(total)
}
  • 使用 atomic 原子操作

如果想达到数据一致性的效果,除了使用加锁操作外,也可以使用 atomic 原子操作。

而原子操作简单来讲,就是在多线程或多进程环境下,不可被中断且执行过程具有原子性(不可分割),它能够保证数据一致性与并发安全的操作。

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
package lock

import (
"fmt"
"sync"
"sync/atomic"
)

var total int32 //需要改变
var wg sync.WaitGroup

func add() {
defer wg.Done()
for i := 0; i < 100000; i++ {
atomic.AddInt32(&total, 1)
}
}

func sub() {
defer wg.Done()
for i := 0; i < 100000; i++ {
atomic.AddInt32(&total, -1)
}
}

func main() {
wg.Add(2)
go add()
go sub()
wg.Wait()
fmt.Println(total)
}

2. Mutex 的常见模式

Mutex 这个名称的由来应该是 mutual exclusion 的前缀组合,俗称「互斥体」或「互斥锁」。 go 语言 sync 包中 Mutex 的数据结构是下面这样的:

1
2
3
4
type Mutext struct {
state int32
sema uint32
}

因为足够简单,所以不需要额外的初始化。

  • 此结构的零值就是一个有效的互斥锁,处于 unlock 的状态,state 存储的就是互斥锁的状态,加锁和解锁都是通过 atomic 包提供的函数原子性的操作该字段。
  • 而 sema 用作一个信号量,主要用作等待队列。
1
2
3
4
5
6
7
8
9
10
11
func (m *Mutex) Lock() {
......
atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked)
......
}

func (m *Mutex) Unlock() {
......
atomic.AddInt32(&m.state, -mutexLocked)
......
}

2.1 正常模式

Mutex 有两种模式,在正常模式下,一个尝试加锁的 goroutine 会先自旋几次,尝试通过原子操作获得锁。若几次自旋之后仍不能获得锁,则通过信号量排队等待,所有的等待者都会按照先入先出的顺序排队。

但是当锁被释放,第一个等待者被唤醒后,并不会直接拥有锁,而是需要和后来者竞争,也就是那些处于自旋阶段尚未排队等待的 goroutine。

这种情况下,后者更有优势,一方面它们已经在 CPU 上运行了,自然比刚被唤醒的 goroutine 更有优势。

另一方面,处于自旋状态的 goroutine 可以有很多,而被唤醒的 goroutine 每次只有一个,所以被唤醒的 goroutine 有很大概率拿不到锁,这种情况下它会被重新插入到队列的头部而不是尾部。而当一个 goroutine 本次加锁等待的时间超过了一毫秒后,它会把当前 Mutex 从正常模式切换至饥饿模式。

2.2 饥饿模式

在饥饿模式下,Mutex 的所有权从执行 unlock 的 goroutine 直接传递给等待队列头部的 goroutine。后来者不会自旋,也不会尝试获得锁,即使 Mutex 处于 unlock 的状态,它们会直接从队列的尾部排队等待。

当一个等待者获得锁之后,它会在以下两种情况时将 Mutex 由饥饿模式切换回正常模式。

  • 第一种情况是他的等待时间小于一毫秒,也就是他刚来不久。
  • 第二种情况是他是最后一个等待者,等待队列已经空了,后面自然就没有饥饿的 goroutine 了。

综上所述,在正常模式下,自旋和排队是同时存在的。执行 lock 的 goroutine 会先一边自旋一边抢锁,尝试过几次后,如果还没拿到锁就需要去排队等待了。这种在排队之前先让大家来抢的模式能够有更高的吞吐量,因为频繁的挂起和唤醒 goroutine 会带来较多的开销,但是又不能无限制地自旋,要把自旋的开销控制在较小的范围内。

所以在正常模式下,Mutex 有更好的性能,但是可能会出现队列尾端的 goroutine 迟迟抢不到锁的情况。而饥饿模式下不再自旋尝试,所有 goroutine 都要排队,严格的先来后到,对于防止出现尾端延迟来讲特别重要。

2.3 正常模式 Vs 饥饿模式

特性 正常模式(默认) 饥饿模式
目标 追求效率(适合短时间持锁) 追求公平(避免个别 goroutine 长期等待)
新请求处理 先自旋,再排队 直接排队,不自旋
锁释放逻辑 唤醒等待队列中的第一个 goroutine(可能被新请求抢占) 直接把锁交给队列头部的 goroutine(跳过抢占)
切换条件 等待时间 < 1 毫秒 等待时间 ≥ 1 毫秒(或队列头部等待超 1 毫秒)

举个例子:

  • 正常模式像「自助餐取餐」:有人在门口张望(自旋),一旦空位出现就立刻抢位。
  • 饥饿模式像「排队买票」:所有人按顺序等待,新来的人必须去队尾,不允许插队(自旋)。

3. Lock & Unlock 详解

接下来我们来展开 lock 和 unlock 的逻辑,首先来看一下关于 Mutex.state 的几个常量定义,state 的类型是 int32:

  • 第 1 位用作锁状态标识,置为 1 就表示已加锁,对应掩码常量为 mutexLocked。
  • 第 2 位用于记录是否已有 goroutine 被唤醒了,置为 1 表示已唤,醒对应掩码常量为 mutexWoken。
  • 第 3 位标识 mutext 的工作模式,0 代表正常模式,1 则代表饥饿模式,对应掩码常量被 mutexStarving。

而常量 mutexWaiterShift 等于 3,表示除了最低 3 位以外,state 的其他位用来记录有多少个等待者在排队。

然后再来看一下 lock 和 unlock 方法,两个方法中主要通过 atomic 函数实现了 Fast path,相应的 Slow path 被单独放在了 lockSlow 和 unlockSlow 方法中。根据源码注释的说法,这样是为了便于编译器对 Fast path 进行内联优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (m *Mutex) Lock() {
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
m.lockSlow()
}

func (m *Mutex) Unlock() {
if race.Enabled {
_ = m.state
race.Release(unsafe.Pointer(m))
}
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
m.unlockSlow(new)
}
}

Fast path 和 Slow path 的含义:

  • Fast path(快路径 ):可以理解成一条 “ 绿色通道 “ ,当 Mutex 处于理想状态,也就是没有被锁住,也没有其他 goroutine 在排队等着拿锁时,程序可以直接通过简单的操作(像 atomic.CompareAndSwapInt32 这种原子操作 )快速获取锁。
  • Slow path(慢路径 ):如果走不了 “ 绿色通道 “,也就是 Mutex 已经被锁住,或者有其他 goroutine 在排队等着拿锁,那程序就没办法直接获取锁了。这时候就得走一条 “ 慢路 “,进入 lockSlow 或 unlockSlow 这样的函数去处理,要经过更复杂的流程,比如处理排队、唤醒等操作。

lock 方法的 Fast path 期望 Mutex 处于 unlock 状态,没有 goroutine 在排队,更不会饥饿,理想状况下一个 CAS 操作就可以获得锁。但是如果 CAS 操作没能获得锁,就需要进入 Slow path,也就是 lockSlow 方法。

CAS 的含义:

CAS 是一种在多线程等场景下,能原子性地比较变量当前值与预期值,若相等则更新为新值的操作。

unlock 方法同理,首先通过原子操作从 state 中减去 mutexLocked,也就是释放锁,然后根据 state 的新值来判断是否需要执行 Slow path:

  • 如果新值为 0,也就意味着没有其他 goroutine 在排队,所以不需要执行额外操作。
  • 如果新值不为 0,那就需要进入 Slow path,看看是不是需要唤醒某个 goroutine。

lock 和 unlock 的 Fast path 就是这样,接下来我们展开 Slow path 的主要逻辑。

4. Mutex 自旋机制详解

4.1 何为 Mutex 自旋?

自旋(Spin)的核心思想: 在等待锁时不立即阻塞,而是循环检查锁是否释放,适合「锁被短暂占用」的场景。

假设你和朋友去食堂打饭,窗口只有一个阿姨(锁)。

  • 正常情况:你发现前面有人排队(锁被占用),就乖乖站到队尾(阻塞等待)。
  • 自旋场景:你站在窗口旁边「偷看」,发现阿姨马上就要打完饭(锁即将释放),于是原地等待,避免去队尾再回来的麻烦(减少上下文切换开销)。

4.2 Golang 的 Mutex 自旋

Golang 的 sync.Mutex 有两种工作模式:正常模式(默认)和饥饿模式。我们先聊正常模式下的自旋条件,这需要满足 4 个前提(缺一不可):

  1. 多核环境(GOMAXPROCS > 1)
    如果是单核 CPU(或 GOMAXPROCS=1),自旋的 goroutine 和持有锁的 goroutine 共享同一个 CPU,你在自旋时对方无法运行,自旋毫无意义。
  2. 有其他处理器(P)在运行
    Golang 的调度器(GMP 模型)中,P(Processor)是真正执行 goroutine 的单元。如果其他 P 都空闲,说明没有其他任务在跑,即使自旋也等不到锁释放,不如直接阻塞。
  3. 当前 P 的本地任务队列(runqueue)为空
    如果本地还有其他 goroutine 在排队(比如 go func() 刚创建的任务),与其自旋浪费 CPU,不如先执行这些任务,等轮到自己时再抢锁,提升整体效率。
  4. 未进入饥饿模式
    如果某个 goroutine 等待锁超过 1 毫秒,Mutex 会切换到饥饿模式,此时所有新请求直接排队,不再自旋。

4.3 自旋的底层逻辑

当满足上述条件时,goroutine 会进入自旋循环,具体做三件事:

  1. 抢「唤醒标识位」
    Mutex 中有一个标记位(wakelock),用来避免唤醒过多的 goroutine 同时等待。就像食堂窗口放了一个「等待牌」,只有抢到牌子的人才能留在门口自旋,其他人必须去排队。
  2. 有限次数自旋
    最多自旋 4 次(每次循环执行 procyield 函数,让 CPU 稍作休息),每次自旋都重新检查:
    • 锁是否已释放?
    • 是否进入饥饿模式?
    • 自旋次数是否超标?
      一旦满足任意条件,立刻停止自旋,进入下一步操作(抢锁或排队)。
  3. 原子操作改状态
    自旋结束后,goroutine 会通过原子操作(CAS)尝试修改 Mutex 的状态(state 字段):
    • 如果锁处于正常模式且未被占用,直接抢锁成功。
    • 如果锁被占用或处于饥饿模式,就加入等待队列(排队规模 +1)。

4.4 加锁流程

  1. 快速路径(Fast Path)
    直接通过原子操作抢锁,成功则结束(无需自旋和排队)。
  2. 慢速路径(Slow Path)
    • 自旋检查:满足条件则自旋(最多 4 次),否则跳过。
    • 状态判断:
      • 如果锁处于饥饿模式,或已被占用,当前 goroutine 加入等待队列(记录等待时间)。
      • 如果正常模式且锁可用,通过原子操作抢锁。
    • 唤醒逻辑:
      • 解锁时,若在正常模式,会尝试唤醒一个等待的 goroutine(通过 semrelease 函数)。
      • 若在饥饿模式,直接把锁交给队首 goroutine,无需竞争。

为什么要自旋?

自旋的核心价值是减少上下文切换开销:

  • 当锁被短暂持有时(比如几纳秒),自旋比阻塞(陷入内核、唤醒线程)更高效。
  • 当锁被长期持有时,自旋会浪费 CPU,此时进入饥饿模式,通过排队保证公平。

4.5 机制小结

  1. 自旋一定比阻塞好?
    错!单核、本地任务队列非空时,自旋反而降低效率,必须阻塞。
  2. 饥饿模式一定更公平?
    对,但牺牲了效率。Golang 通过 1 毫秒的阈值平衡两者:短等待用正常模式(自旋抢锁),长等待用饥饿模式(排队保公平)。
  3. 自旋次数越多越好?
    错!限制 4 次是为了防止 CPU 被过度占用,就像排队时不能一直盯着窗口,最多看 4 眼,没人出来就去队尾等着。

Golang Mutex 自旋的「生存法则」

  1. 自旋是优化,不是必须: 只有满足多核、有其他 P 运行、本地队列为空时才会触发。
  2. 有限且克制: 最多自旋 4 次,避免浪费 CPU。
  3. 动态切换模式: 短等待用正常模式(自旋抢锁),长等待用饥饿模式(排队保公平)。。

理解自旋机制,能帮你更好地设计并发程序:

  • 若业务场景中锁竞争激烈且持锁时间长,可通过 sync.RWMutex 或通道(Channel)减少锁冲突。
  • 若持锁时间极短(如高频计数),自旋能显著提升性能。