sync.Mutex的常见易错场景以及使用进阶

序言

本文介绍了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并发的必要条件。

下节预告: RWMutex是啥,他和Mutex的区别是什么,有了Mutex为什么又要诞生RWMutex?

相关推荐
神奇小汤圆31 分钟前
浅析二叉树、B树、B+树和MySQL索引底层原理
后端
文艺理科生41 分钟前
Nginx 路径映射深度解析:从本地开发到生产交付的底层哲学
前端·后端·架构
千寻girling41 分钟前
主管:”人家 Node 框架都用 Nest.js 了 , 你怎么还在用 Express ?“
前端·后端·面试
南极企鹅43 分钟前
springBoot项目有几个端口
java·spring boot·后端
Luke君607971 小时前
Spring Flux方法总结
后端
define95271 小时前
高版本 MySQL 驱动的 DNS 陷阱
后端
忧郁的Mr.Li1 小时前
SpringBoot中实现多数据源配置
java·spring boot·后端
暮色妖娆丶2 小时前
SpringBoot 启动流程源码分析 ~ 它其实不复杂
spring boot·后端·spring
Coder_Boy_2 小时前
Deeplearning4j+ Spring Boot 电商用户复购预测案例中相关概念
java·人工智能·spring boot·后端·spring
Java后端的Ai之路2 小时前
【Spring全家桶】-一文弄懂Spring Cloud Gateway
java·后端·spring cloud·gateway