Go 语言中的 sync.Mutex 学习

极客时间专栏,鸟窝大佬的 Go 并发编程课程,由于大佬的课程十分精彩,领悟需要时间与精力!所以再次本文仅仅只能作为知识总结,相应具体内容与源码分析请去极客时间上购买大佬相应的课程!

Mutex

​ Go 语言中的 sync 包的 mutex 的设计,有四个演变阶段。

  • 1、初版的 Mutex 采用一个 flag 表示锁是否被持有,实现比较简单
  • 2、之后为了照顾新来的 Goroutine(下文简称 G ),会让新人能够尽可能的优先获取锁,此为第二个阶段
  • 3、第三个阶段呢,是使被唤醒的 G 与新来的 G 有更多的机会竞争锁,但是这样会引发相应的饥饿问题,所以目前又加入了饥饿的解决方案
  • 4、第四个即为解决饥饿的阶段。

如下图所示

go-concurrency1

初版 mutex 的实现

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
37
38
39
40
2008 年时候, Russ Cox 提交的第一版的 mutex 如下所示

//CAS 操作,当时并未抽象出 atomic 原子包
func cas(val *int32, old, new int32) bool
func semacquire(*int32)
func semrelease(*int32)

type Mutex struct {
//锁是否被持有
key int32

//信号量专用,用于阻塞/唤醒 G
sema int32
}

//保证成功在 val 上添加 delta 的值
func xadd(val *int32, delta int32) (new int32) {
for {
v := *val
if cas(val, v, v + delta) {
return v + delta
}
}
panic("unreached")
}

//请求锁
func (m *Mutex) Lock() {
if xadd(&m.key, 1) == 1 { // 标识加 1,如果为 1,则获取到锁
return
}
semacquire(&m.sema) //否则阻塞等待
}

func (m *Mutex) Unlock() {
if xadd(&m.key, -1) == 0 {// 标识减 1,如果为 0,则没有其他的等待者
return
}
semarelease(&m.sema) //唤醒其他的 G
}

· 其中,CAS 为一种指令,即将给定的值与内存地址中的值进行相比较,如果是同一个值,就用新值替换内存地址中的旧值。而且 CAS 操作指令是原子性的指令(即数据库中原子性的概念,修改不了数据,事务回滚到修改之前的数据,数据不改变)

有趣的事情是,Unlock 方法能被任意的 G 调用释放,即使没有持有互斥锁的 G,也能进行相应的操作。

所以在使用 Mutex 的时候,必须保证 G 尽可能不去释放自己未持有的锁,一定遵循 “谁申请,谁释放” 的原则。一般在使用 Mutex 的时候,Lock 与 Unlock 方法都应该在一个方法内成对出现。

在1.14版本中 Go 对 defer 做了相应的优化,采取更有效的内联模式,将之前生成的 defer 对象放入 defer chain 中,所以 defer 对程序执行的影响微乎其微了。

缺点:请求锁时候,G 会排队等待获取互斥锁,虽然看起来挺公平的,但是从性能上来看,并非最优的解法。如果能将锁让给正在用 CPU 时间片的 G 的话,就不需要做上下文的切换,在高并发的情况下,可能会有更好的性能。

“给新人机会” 阶段

2011 年 6 月 30 日,Go 语言开发者在 commit 中对 Mutex 做了一次大调整,调整后的 Mutex 实现如下:

1
2
3
4
5
6
7
8
9
10
type Mutex struct {
state int32
sema uint32
}

const (
mutexLock = 1 << iota //mutex is locked
mutexWoken
mutexWaiterShift = iota
)

其中 Mutex 此版本的设计思想为将第一个 int32 类型的 state 字段,划算为二进制,按二进制的位数进行区分,如下图:

这样可能以最小的内存来实现互斥锁结构,最低位表示锁是否被占有(1|0 占有|非占有),次低位表示锁是否有被唤醒的 G,其余 30 位表示等待此锁的 G 的数量。与计算机网络的子网划分很相似的设计,一个数值,分为三部分,代表三个意义。

并且因为 atomic 原子性包的添加,请求锁Lock也变复杂了,。

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
func (m *Mutex) Lock() {
//Fast path : 幸运 case,能够直接获取到相应的锁
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}

awoke := false
for {
old := m.state
new := old | mutexLocked //新状态加锁
if old & mutexLocked != 0 {
new = old + 1 << mutexWaiterShift //等待者数量加一
}

if awoke {
//G 是被唤醒的
//新状态清除唤醒标志
new &^ = mutexWoken
}

if atomic.CompareAndSwapInt32(&m.state, old, new) {//设置新状态
if old & mutexUnlocked == 0 { //锁原状态未加锁
break
}
runtime.Semacquire(&m.sema) //请求信号量
awoke = true
}
}
}
设计包含大量的位运算,要联系 Go 语言的位运算优先级进行思考。

使用 mutex 的一些注意事项

1、能不用 mutex, 尽量不用 mutex, 使用读写锁合适

2、尽量使用 defer 释放锁,防止因为 panic 而导致锁未释放

3、mutex.Lock() 后是不可重入的,写递归时候,不能调用 mutex

4、尽量使用读写锁!sync.RLock/RUnlock, sync.WLock/WUnlock


Go 语言中的 sync.Mutex 学习
https://chaggle.github.io/2022/03/06/go/concurrency/mutex/
作者
chaggle
发布于
2022年3月6日
许可协议