并发编程Mutex (互斥锁)发展分析
Posted 了 凡
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程Mutex (互斥锁)发展分析相关的知识,希望对你有一定的参考价值。
博主介绍:
– 我是了 凡,喜欢每日在简书上投稿日更的读书感悟笔名:三月_刘超。专注于 Go Web 后端,了解过一些Python、Java、算法、前端等领域。微信公众号【了凡银河系】期待你的关注,企鹅群号(798829931)。未来大家一起加油啊~
前言
“初版”的Mutex使用一个flag来表示锁是否被持有,实现比较简单;后来照顾到新来的goroutine,所以会让新的goroutine也尽可能地先获取到锁,这是第二阶段,可以称呼为“给新人机会";那么,接下来就是第三阶段”多给些机会“,照顾新来的和被唤醒的goroutine;但是这样会带来饥饿问题,所以目前又加入了饥饿的解决方案,也就是第四阶段”解决饥饿“。
初版Mutex
/* 初版的互斥锁 Russ Cox 在2008年提交的第一版Mutex实现方式 */
// CAS操作,当时还没有抽象出 atomic 包
func cas(val *int32, old, new int32) bool {return true}
func semacquire(*int32){}
func semrelease(*int32){}
// Mutex 互斥锁的结构,包含两个字段
type Mutex struct {
key int32 // 锁是否被持有的标识
sema int32 // 信号量专用,可以阻塞/唤醒goroutine
}
// 保证成功在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
}
semrelease(&m.sema) // 唤醒其他阻塞的goroutine
}
CAS:CAS指令将给定的值和一个内存地址中的值进行比较,如果它们是同一个值,就使用新值替换内存地址中的值,这个操作是原子性。CAS是实现互斥锁和同步原语的基础。
原子性:明确一点,就是原子性保证这个指令总是基于最新的值进行计算,如果同时有其它线程已经修改了这个值,那么,CAS会返回失败。
CAS有两大好处
- 无锁的方式没有锁竞争带来的系统开销,也没有不同线程间频繁调度的开销,所以性能比锁更优越
- 对死锁免疫
CAS缺点
- ABA问题:当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而经过两次修改后,对象的值又恢复为旧值,这样当前线程无法正确判断这个对象是否修改过。
-
解决办法:JDK1.5可以利用类AtomicStampedReference来解决这个问题,AtomicStampedReference内部不仅维护了对象值,还维护了一个时间戳。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳,对象值和时间戳都必须满足期望值,写入才会成功。
- 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
-
解决办法:JVM支持处理器提供的pause指令,使得效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令,使CPU不会消耗过多的执行资源,第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
- 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性。
-
解决办法:从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
Mutex 结构体包含两个字段:
- 字段key:是一个flag,用来标识这个排外锁是否被某个goroutine所持有,如果key大于等于1,说明这个排外锁已经被持有;
- 字段sema:是个信号量变量,用来控制等待goroutine的阻塞休眠和唤醒。
Unlock方法可以被任意的goroutine调用释放锁,即使没持有这个互斥锁的goroutine,也可以进行这个操作。这是因为,Mutex本身并没有包含持有这把锁的goroutine的信息,所以,Unlock也不会对此进行检查。Mutex的这个设计一直保持至今。
为了解决 goroutine 可以强制释放锁,因为在临界区goroutine可能不知道锁已经被释放了,还会继续执行临界区的业务操作,这可能会带来意想不到的结果,因为这个goroutine还以为自己持有锁呢,有可能导致data race 问题。这是一个非常危险的操作。
所以,我们在使用 Mutex 的时候,必须要保证 goroutine 尽可能不去释放自己未持有的锁,一定要遵循 “谁申请,谁释放” 的原则。在真实的实践中,我们使用互斥锁的时候,很少一个方法中单独申请锁,而在另外一个方法中单独释放锁,一般都会在同一个方法中获取锁和释放锁。
基于性能的考虑,及时释放掉锁,所以在一些if-else分支中加上释放锁的代码,代码看起来很臃肿。而且,在重构的时候,也很容易因为误删或者是漏掉而出现死锁的现象。
type Foo struct {
mu sync.Mutex
count int
}
func (f *Foo) Bar(){
f.mu.Lock()
if f.count < 1000 {
f.count += 3
f.mu.Unlock() // 此处释放锁
return
}
f.count++
f.mu.Unlock() // 此处释放锁
return
}
从1.14版本起,Go对defer做了优化,采用更有效的内联方式,取代之前的生成defer对象到defer chain 中,defer对耗时的影响微乎其微了,所以基于上修改成下面简介的写法也没问题:
func (f *Foo) Bar() {
f.mu.Lock()
defer f.mu.Unlock()
if f.count < 1000 {
f.count += 3
return
}
f.count ++
return
}
这样做的好处就是Lock/Unlock总是成对紧凑出现,不会遗漏或者多调用,代码更少。
但是,如果临界区只是方法中的一部分,为了尽快释放锁,还是应该第一时间调用Unlock而不是一直等到方法返回时才释放。
初版的Mutex实现之后,Go开发组又对 Mutex做了一些微调,比如把字段类型变成了uint32类型;调用Unlock方法会做检查;使用atomic包的同步原语执行原子操作等等,这些小的改动,都不是核心功能,你简单知道就行了,我就不详细介绍了。
但是,初版的 Mutex实现有一个问题:请求锁的goroutine 会排队等待获取互斥锁。虽然这貌似很公平,但是从性能上来看,却不是最优的。因为如果我们能够把锁交给正在占用CPU时间片的goroutine的话,那就不需要做上下文的切换,在高并发的情况下,可能会有更好的性能。
Go开发者是怎么解决这个问题的呢?我们后续在继续了解。
这次就先讲到这里,如果想要了解更多的golang语言内容一键三连后序每周持续更新!
以上是关于并发编程Mutex (互斥锁)发展分析的主要内容,如果未能解决你的问题,请参考以下文章
go语言学习笔记 — 进阶 — 并发编程:互斥锁(sync.Mutex)—— 保证同时只有一个goroutine可以访问共享资源