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?

相关推荐
计算机学姐2 分钟前
基于SpringBoot+Vue的高校运动会管理系统
java·vue.js·spring boot·后端·mysql·intellij-idea·mybatis
程序员陆通1 小时前
Spring Boot RESTful API开发教程
spring boot·后端·restful
无理 Java1 小时前
【技术详解】SpringMVC框架全面解析:从入门到精通(SpringMVC)
java·后端·spring·面试·mvc·框架·springmvc
cyz1410012 小时前
vue3+vite@4+ts+elementplus创建项目详解
开发语言·后端·rust
liuxin334455663 小时前
大学生就业招聘:Spring Boot系统的高效实现
spring boot·后端·mfc
向上的车轮3 小时前
ASP.NET Zero 多租户介绍
后端·asp.net·saas·多租户
yz_518 Nemo3 小时前
django的路由分发
后端·python·django
AIRust编程之星4 小时前
Rust中的远程过程调用实现与实践
后端
Stark、4 小时前
异常处理【C++提升】(基本思想,重要概念,异常处理的函数机制、异常机制,栈解旋......你想要的全都有)
c语言·开发语言·c++·后端·异常处理
逢生博客5 小时前
Rust 语言开发 ESP32C3 并在 Wokwi 电子模拟器上运行(esp-hal 非标准库、LCD1602、I2C)
开发语言·后端·嵌入式硬件·rust