Go语言的Mutex的演进

架构思维中谈到一点,“演进优于一步到位”,在进行技术开发的时候,我们不应该指望做到“一步到位”,能够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的易错点

  1. 不要拷贝锁,因为锁是有状态的。给一个已经上锁的锁进行加锁会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()
}
  1. 加锁和解锁要成对出现,“不重不漏”
  2. 警惕死锁现象,多个协程获取多种资源,可以按照一定的规则进行加锁,防止死锁

检测锁异常:

  • go vet 可以检查锁拷贝,例如 go vet main.go,用于检查main.go这个文件的锁异常
  • 数据竞争检测:go build -race
  • 死锁问题检测:go-deadlock

6.总结

Go语言的锁的底层基础是原子操作和sema信号量,原子操作是一种硬件层面的锁,可以实现对简单变量简单操作的并发安全性,sema信号量底层对应一个SemaRoot结构体,结构中维护一个双向链表,用来存储休眠的协程。Go语言的Mutex的演进可以总结为:

  1. “初出茅庐”:要么上锁成功,要么进入休眠队列休眠
  2. “给新人机会”:为了提高cpu利用率,唤醒的协程要和新来的协程进行竞争锁
  3. “多给机会”:获取不到锁,则自旋一段时间,如果仍然获取不到,则休眠
  4. “保证公平,防止饥饿”:引入饥饿模式,饥饿模式新来的协程不自旋,直接休眠,唤醒的协程直接获取锁

“大厦并非一日建成”,即使是有Googel生态的Go语言,其最重要的并发原语Mutex也是经历了多次的改进才成为了如今我们看到的这个状态。


欢迎讨论,欢迎批评指正!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值