架构思维中谈到一点,“演进优于一步到位”,在进行技术开发的时候,我们不应该指望做到“一步到位”,能够hold住所有的场景和问题,而是应该随着业务的发展不断演进。
Go语言的Mutex也是经过了几次的迭代才成为了今日能适应Go语言的高并发特性的重要的同步原语。
1. 初出茅庐
初版的Mutex有两个字段,一个是key uint32,一个是sema uint32类型。
- 字段key来表示当前的锁有没有被上锁以及有多少个协程在等待锁。如果key的值等于0表示当前锁是空闲状态,如果key等于1表示当前的锁被一个协程上锁了,如果当前的值等于n,则说明当前的锁处于加锁的状态,并且有n-1个等待的协程
- 字段sema用来表示信号量,sema信号量的底层维护一个协程的等待队列,等待队列是一个双向链表
一个协程获取锁时会先判断key的值是否等于0,如果等于0则通过原子操作将key的值置为1,否则,则将key的值+1,然后将该协程休眠放入到sema的等到队列中。当锁被释放的时候,先判断sema的协程休眠队列中有没有协程在休眠,如果有协程在休眠则唤醒链表头的协程,被唤醒的协程获取锁。
优点: 保证绝对公平性,所有的协程先到先得,没有获取到锁的协程进入休眠
缺点: 比较消耗性能,在锁竞争场景中,正在CPU上执行的协程没有机会获取到锁,所有竞争的协程都要进入休眠,然后再唤醒拿到锁,唤醒协程可能比执行临界区代码逻辑更加损耗性能。
试想一下,你正在进行跑步比赛,此时你想要补充“水”(相当于请求锁),如果让你再一个高速奔跑的状态停下来,然后再给你“水”,然后再让你继续进行比赛,这样的比赛成绩大概率没有你保持高速奔跑的过程中给你“水”的成绩好。
换句话说,如果可以让正在CPU上执行的协程获取到锁,这样可以极大的减少损耗,充分利用CPU资源。
2. 给新人机会
在这个阶段,Mutex中还是两个字段,但是之前的key字段变成了state字段。
state字段时一个uint32整数,从左向右数:
- state字段的最后一位表示锁的状态,0表示锁处于空闲,1表示锁处于上锁状态
- 倒数第二位时唤醒标志位,表示当前是否有协程从等待队列中唤醒
- 前面的30位表示的时当前正在等待获取锁的协程的个数
如果当前的锁没有被锁上,则会当前请求锁的协程获取锁,否则,没有获取锁的协程会进入休眠队列。当时,在锁被释放后,被唤醒的协程并不能像之前那样直接获取锁,要和正在请求锁的协程进行竞争。这样的设计,会给新来的协程一个获取锁的机会,也会让正在cpu上执行的协程有更多所获锁的可能,这样可以在一定程度上提升cpu的利用率。
3. 多给机会
上面的优化,给了“新人”一定的机会,但是还可以继续优化,15年Go官方对此进行了进一步的优化——“多给些机会”。新来的协程或者被唤醒的协程,如果获取不到锁的话,他们会通过自旋的方式,尝试检查锁是否被释放,如果在自旋的过程种,锁被释放了,则协程可以在自旋种获得该锁,可以避免协程过早休眠。
对于一些执行任务很短逻辑,上锁的时间很短,协程可以自旋几次便可获得别的协程释放的锁,如果没有自旋,协程竞争不到锁后会进入休眠。频繁的休眠和唤醒比较的消耗性能,不利于并发。
上述的优化,能很大程度的利用CPU资源,但是可能会存在一个问题,每次解锁后唤醒的协程都要和新来的协程进行竞争锁,可能会导致被唤醒的协程一直竞争不到锁,陷入饥饿模式,这是不允许的。
4. 保证公平,防止饥饿
为了解决上述提到的饥饿问题,Go原因的state字段发生了变化:从左向右,倒数第三位表示的是饥饿标志位。
Mutex数据结构如下
type Mutex struct {
state int32
sema uint32
}
当一个协程在等待锁的时间超过了1ms(这里的等待时间指的是协程自旋结束后的等待时间),则锁进入饥饿模式,在饥饿模式中,新来的协程不自旋,直接进入休眠队列,被唤醒的协程直接获取锁。通过锁的饥饿模式可以让协程得到较为公平的调度,防止出现锁饥饿现象。
5.Mutex的易错点
- 不要拷贝锁,因为锁是有状态的。给一个已经上锁的锁进行加锁会panic。这样的错误可能会很隐晦的出现在结构体中,一个结构体中存在一把锁,把这个结构体当成参数传给一个函数。
type copyMutex struct {
count int
mu sync.Mutex
}
func copMutex() {
cm := copyMutex{}
cm.mu.Lock()
defer cm.mu.Unlock()
cm.count++
}
func copy(cm copyMutex) {
cm.mu.Lock()
cm.count++
cm.mu.Unlock()
}
- 加锁和解锁要成对出现,“不重不漏”
- 警惕死锁现象,多个协程获取多种资源,可以按照一定的规则进行加锁,防止死锁
检测锁异常:
- go vet 可以检查锁拷贝,例如 go vet main.go,用于检查main.go这个文件的锁异常
- 数据竞争检测:go build -race
- 死锁问题检测:go-deadlock
6.总结
Go语言的锁的底层基础是原子操作和sema信号量,原子操作是一种硬件层面的锁,可以实现对简单变量简单操作的并发安全性,sema信号量底层对应一个SemaRoot结构体,结构中维护一个双向链表,用来存储休眠的协程。Go语言的Mutex的演进可以总结为:
- “初出茅庐”:要么上锁成功,要么进入休眠队列休眠
- “给新人机会”:为了提高cpu利用率,唤醒的协程要和新来的协程进行竞争锁
- “多给机会”:获取不到锁,则自旋一段时间,如果仍然获取不到,则休眠
- “保证公平,防止饥饿”:引入饥饿模式,饥饿模式新来的协程不自旋,直接休眠,唤醒的协程直接获取锁
“大厦并非一日建成”,即使是有Googel生态的Go语言,其最重要的并发原语Mutex也是经历了多次的改进才成为了如今我们看到的这个状态。
欢迎讨论,欢迎批评指正!