序言
本文介绍了mutex在开发过程中,容易发生的错误,以及对mutex进一步封装使用
1.mutex的常见易错场景
通过前几期的学习,我们了解到了mutex的使用方法与底层实现原理,mutex的使用方法十分简单,只需要简单的加锁Lock()解锁Unlock()即可,但是在一些复杂场景中,还是会容易发生一些mutex人为使用错误的场景,比如最常见的四个场景,加锁Lock()解锁Unlock()没有成对出现、复制使用了mutex、重入了mutex(mutex是一个不可重入的锁)以及发生了死锁,接下来重点介绍这四个mutex常见的易错场景,以及我们如何避免他们。
1.1 Lock()加锁和Unlock()解锁不是成对出现
第一个错误场景可以分为两种
1.1.1 Unlock解锁一个未Lock加锁了的mutex
对一个还未Lock加锁的mutex进行Unlock解锁会导致panic提示错误解开一个尚未加锁的mutex,程序不会正常执行
1.1.2 Lock加锁了的mutex,但是忘记了Unlock解锁
忘记Unlock解锁,意味着锁的资源迟迟得不到释放,也就相当于其他gorutine永远无法获得这个锁,也就无法使用到临界资源。
1.2 复制了已经使用的Mutex
golang的sync包中所有的同步原语都是不可复制的,因为他们都是有状态的对象,比如mutex中的int32类型的state字段,不同的比特位有不同的含义,如果直接将它复制拿来使用,会导致mutex的状态也保存了,这样就不是一个初始状态的mutex,如果将复制过来的mutex直接使用,会导致mutex副本和原来的mutex状态不一致,极有可能会导致死锁(两个或多个协程相互等待锁的释放,谁也不让谁,程序停滞阻塞在了这里)的发生。
不过好在golang在运行时有死锁检查的机制,当发生了复制mutex导致死锁的情况,程序运行时死锁检查机制会发现这种死锁的情况并输出错误信息。
1.3 重入了Mutex
1.3.1 什么是可重入锁,什么是不可重入锁
重入锁,故名思义,就是可以重新入、重新加载的锁,当一个goroutine获取锁时,如果没有其他的goroutine拥有锁,那么这个goroutine就可以成功获取到锁,此时其他的goroutine处于阻塞等待的状态,但是如果拥有这把锁的goroutine再请求这把锁,能成功的就叫做可重入锁,这里叠加获取锁的过程就是重入。不可重入锁就是不允许同一个goroutine叠加获取锁,mutex就是一个不可重入的锁,因为mutex中并没有记录正在使用它的goroutine是哪一个,所以,只要能获取到mutex,任何goroutine都能申请加锁和解锁,即然无法记录正在使用mutex的goroutine是哪一个,显然就无法让同一个线程重入这把锁,所以mutex并不是一个可重入的锁。
1.3.2 封装一个可重入的锁示例
可重入的锁在实际的开发中有什么广泛的使用,虽然mutex并不是一个可重入的锁,但是我们可以通过mutex自己实现一个可重入的锁,只需要记录下拿到锁的goroutine是哪一个即可
1.3.2.1 方案1:使用hacker方式获取goroutine id
为了避免重复造轮子,我们可以直接使用goid来获取goroutine id
指令:go get github.com/petermattis/goid
这里实现可重入锁的意义在于在递归调用中, 如果函数已经持有锁,再次递归获取锁是不会发生死锁,根据我们实现的可重入锁的逻辑,当重入获取锁时,判断goroutine id 是否与当前占用mutex的goroutine一致,一致则将计数器+1,直接返回,不一致,就不属于重入,尝试lock,并将重入次数记为1。解锁的逻辑也较mutex原生有不同,当发现申请解锁的goroutine不是正在占用的goroutine时,直接panic,申请解锁的goroutine是正在占用的goroutine时,将重入次数-1,并且是最后一次从重入释放时,调用原生的Unlock解锁,否则直接返回。
总的来说,可重入锁,增加了一个重入次数,以及goroutine id,通过记录重入次数并判断goroutine id 来决定重入逻辑的变化,最终实现了一个可重入的锁,在函数递归调用过程中,Lock和Unlock是一一对应的,只有相同次数的lock和Unlock才可以真正把锁释放掉。
go
// RecursiveMutex 包装一个Mutex,实现可重入
type RecursiveMutex struct {
sync.Mutex
owner int64 // 记录当前持有锁的goroutine id
recursion int32 // 记录goroutine 重入的次数
}
func (m *RecursiveMutex) Lock() {
gid := goid.Get()// 使用goid库直接获取goroutine id
// 如果当前持有锁的goroutine就是这次调用的goroutine,说明是重入
if atomic.LoadInt64(&m.owner) == gid {
// 将重入次数+1
m.recursion++
return
}
m.Mutex.Lock()
// 获得锁的goroutine第一次调用,记录下它的goroutine id,调用次数加1
atomic.StoreInt64(&m.owner, gid)
m.recursion = 1
}
func (m *RecursiveMutex) Unlock() {
gid := goid.Get()// 使用goid库直接获取goroutine id
// 非持有锁的goroutine尝试释放锁,错误的使用
if atomic.LoadInt64(&m.owner) != gid {
// 错误日志 输出 错误的 goroutine id 和正在占用锁的goroutine id
panic(fmt.Sprintf("wrong the owner(%d): %d!", m.owner, gid))
}
// 调用次数减1
m.recursion--
if m.recursion != 0 { // 如果这个goroutine还没有完全释放,则直接返回
return
}
// 此goroutine最后一次调用,需要释放锁
atomic.StoreInt64(&m.owner, -1)
m.Mutex.Unlock()
}
1.3.2.2 方案2:生成一个token表示goroutine
方案1是直接拿goroutine id 来判断要获取锁的goroutine是不是正在占用锁的goroutine,但是将golang的开发团队并不希望我们利用goroutine id 做一些不确定的事,没有暴露获取goroutine的方法,可能存在一定的安全性问题,于是方案二采用的是自己生成token的形式,也就是我们自己直接给goroutine生成一个id,调用者自己提供token,获取锁的时候根据token判断是否是一个goroutine,相当于自己设定了一个goroutine,相比于直接使用goroutine更加安全,其他的实现逻辑与方案一基本一致,这里便不再赘述。
1.4 发生了死锁
1.4.1 什么是死锁
死锁指的就是两个或者两个以上的goroutine都在执行中,因为相互争夺资源而导致的相互阻塞等待的状态,如果没有外部干涉,他们就会一直阻塞在原地,比如A协程抢占着某个资源,A同时又需要B协程独占的资源,而此时B又同时需要A独占的资源,谁也不让着谁,此时就发生了,AB协程无法正常工作,阻塞在了这里。
1.4.2 发生死锁的四大必要条件
如果发生了死锁,那么一定是满足了死锁的四大必要条件,要避免死锁,只需要破坏这四个条件中的其中一个即可。
1.互斥 :至少一个资源是某个协程独占的,其他的协程都无法同时占有他。只能排队等待。
2.持有和等待 :某个协程不仅占用了其他协程需要的资源,同时自己还在申请其他协程占有的资源
3.不可剥夺 :资源只能由持有它的资源释放,没得协程没法抢占。
4.环路等待:像上面我们举得AB协程的例子,就是两个协程之间发生了环路等待,阻塞等待一圈下来结果形成了环路等待,造成了死锁。
1.4.3如何避免死锁
要避免死锁,就要破坏死锁产生的四大条件,不过go程序在运行时有死锁检测的机制,当发生死锁时,我们就根据具体业务需求来调整实现逻辑,从破坏产生死锁的四大条件方向来避免死锁。
2.使用进阶
锁的使用情况是go程序性能消耗的一个十分重要的因素,在对mutex的使用过程中,竞争锁的协程的数量是判断锁竞争激烈程度的一个重要指标。 我们要严格把控好锁的使用,就要控制好等待协程的数量。这里我们带来一些通过判断锁的状态来进一步高效使用锁的方法。
2.1 实现一个不会阻塞的获取锁方法
有时候我们并不希望某个协程尝试获取锁失败后就直接阻塞等待,根据业务需求,获取不到锁之后可能去执行其他操作,而不是直接阻塞等待,毕竟等待队列里的协程多了也是十分消耗性能的。这里带来一个通过使用atomic包来实现一个不会阻塞的获取锁的方法。
2.1.1首先我们来了解atomic原子包的简单使用
go
// Mutex 扩展一个Mutex结构
type Mutex struct {
sync.Mutex
}
func test(m *Mutex) {
m.Lock()
fmt.Println(atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex))))
}
func test2(m *Mutex) {
//m.Lock()
fmt.Println(atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex))))
}
func main() {
var m Mutex
test(&m)
m.Unlock()
test2(&m)
}
代码示例中使用了go原生的atomic 原子包中的函数,用来原子操作地获取mutex中state字段的值,如运行结果显示,test1函数,给mutex加锁,所以state的最后以为置1,标识已加锁,而在test2中对mutex解开了锁,会导致state最后一位置0,并且也没有其他携程在申请锁,所以此时state是0,此时打印结果就是0,所以可以看到,我们可以使用go原生提供的atomic原子包来获取mutex的状态,从而实现一些更高阶的用法。
一个不会产生阻塞等待协程的获取锁方法的实现
go
// 实现一个Mutex定义的变量
const ( // 判断mutex状态的掩码常量
// 将mutex源码中的掩码常量复制过来使用
mutexLocked = 1 << iota // 加锁标识位置 左移标识枚举值翻倍递增
mutexWoken // 唤醒标识位置
mutexStarving // 锁饥饿标识位置
mutexWaiterShift = iota // 标识waiter的其实bit位置
)
// Mutex 扩展一个Mutex结构
type Mutex struct {
sync.Mutex
}
func (m *Mutex) TryLock() bool {
// 如果能成功抢到锁
// 采用直接将state置1的操作抢占锁
if atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.Mutex)), 0, mutexLocked) {
return true
}
// 如果处于唤醒、加锁或者饥饿状态 这次请求就不参与竞争了,返回false
// 原子方式读取state值
old := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
if old&(mutexLocked|mutexStarving|mutexWoken) != 0 {
return false
}
// 尝试在竞争状态下请求锁
new := old | mutexLocked
return atomic.CompareAndSwapInt32((*int32)(unsafe.Pointer(&m.Mutex)), old, new)
}
2.2 获取等待者的数量指标
实现方法同理2.1
go
// Count
// 获取state字段的值
func (m *Mutex) Count() int {
// 获取state字段的值
v := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
v = v >> mutexWaiterShift //得到等待者的数值
v = v + (v & mutexLocked) //再加上锁持有者的数量,0或者1
return int(v)
}
// IsLocked 锁是否被持有
func (m *Mutex) IsLocked() bool {
state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
return state&mutexLocked == mutexLocked
}
// IsWoken 是否有等待者被唤醒
func (m *Mutex) IsWoken() bool {
state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
return state&mutexWoken == mutexWoken
}
// IsStarving 锁是否处于饥饿状态
func (m *Mutex) IsStarving() bool {
state := atomic.LoadInt32((*int32)(unsafe.Pointer(&m.Mutex)))
return state&mutexStarving == mutexStarving
}
// 测试
func count() {
var mu Mutex
for i := 0; i < 1000; i++ { // 启动1000个goroutine
go func() {
mu.Lock()
time.Sleep(time.Second)
mu.Unlock()
}()
}
time.Sleep(time.Second)
// 输出锁的信息
fmt.Printf("waitings: %d, isLocked: %t, woken: %t, starving: %t\n", mu.Count(), mu.IsLocked(), mu.IsWoken(), mu.IsStarving())
}
测试
2.3 使用mutex实现一个线程安全的队列
我们知道golang中既有线程不安全的普通map,也有线程安全的sync.map,这里的sync.map就是通过采用了mutex锁来实现以及采用操作分级的两个map、计数器等来实现的并发安全的map,同理,我们也可以使用mutex来简单实现一个并发安全的数据结构,比如队列。
go
// SliceQueue 自定义并发安全的队列
type SliceQueue struct {
data []interface{}
mu sync.Mutex
}
func NewSliceQueue(n int) (q *SliceQueue) {
return &SliceQueue{data: make([]interface{}, 0, n)}
}
// Enqueue 把值放在队尾
func (q *SliceQueue) Enqueue(v interface{}) {
q.mu.Lock()
q.data = append(q.data, v)
q.mu.Unlock()
}
// Dequeue 移去队头并返回
func (q *SliceQueue) Dequeue() interface{} {
q.mu.Lock()
if len(q.data) == 0 {
q.mu.Unlock()
return nil
}
v := q.data[0]
q.data = q.data[1:]
q.mu.Unlock()
return v
}
这里我们的实现比较简洁,只在出队和入队时加锁,用完了就解锁,从而实现了一个线程安全的队列,这仅仅是实现的最基础的并发安全类型,go自带的并发安全的sync.map的实现可比这要复杂的多,他还采用了计数器、操作分离的两个map等、map晋升机制等,所以mutex的使用多种多样,要能更高效的使用mutex,基础了解之后,往后还可以更加深入学习借鉴go原生的方法,实现自己需要的并发安全的类型。
总结
mutex是golang最基础、最重要的同步原语,是sync包的基石,go原生许多并发安全的类型中都有它的身影,sync.map、channel、以及下节要介绍的RWMutex等,所以,掌握mutex的原理以及熟练使用mutex是我们打好golang并发的必要条件。