Mutex使用易错场景盘点

在Go中,互斥锁通过sync.Mutex实现,该结构体实现了Locker接口,实现了LockUnlock方法,在进入需要保护的临界区之前,调用Lock方法获取互斥锁,在退出临界区时,调用Unlock方法释放互斥锁。

sync.Mutex这样的便利的同步原语,在使用上非常的简单便捷,只有LockUnlock两个方法,正常使用Mutex,基本不会有什么错误,即使出现错误也是在一些复杂的场景中,比如跨函数调用Mutex ,再或者是在重构或者修补Bug时 误操作Mutex

但是,简单的使用,由于粗心大意也有可能会产生Bug,因此,需要做到Bug提前知,后面早防范 ,防范于未然。以下列举了使用Mutex常见的几个错误场景

使用Mutex常见的几个错误场景

1、Lock/Unlock方法不是成对出现

Lock方法与Unlock方法没有成对出现时,意味着不同的goroutine之间使用同一把锁时,很有可能会出现死锁,或者是因为某一·goroutine调用Unlock了一个未加锁的Mutex从而导致panic

缺少Unlock的场景有哪些呢?常见的场景有以下这几种:

  • 代码设计上,出现过多的if-else分支,有可能在某个分支写漏了Unlock
  • 在项目重构时,可能一个不小心,将Unlock逻辑给删除了;
  • 在编写代码时,将Unlock误写成了Lock

上述这几种情况,锁在被获取后,就不会再被释放了,这也意味着其他的goroutine永远都没有机会获得到这一把锁了。

缺少Unlock的场景可以举一个简单的例子:

go 复制代码
func mutexUse() {
    var mu sync.Mutex
    defer mu.Unlock()
    fmt.Println("mutex use")
}

上述缺少Unlock的场景,通常都是由于粗心大意,误删除了调用Lock方法的逻辑。比如说之前程序使用Mutex一直是正常的,但由于某些原因,由其他人进行代码重构时,出于对代码的不熟悉、粗心大意等原因导致Lock调用给删除了,从而程序出现了问题。

2、拷贝已使用的Mutex

拷贝已使用的Mutex,同样也会带来一些意想不到的错误,为什么拷贝已使用的Mutex,会产生错误呢? 原因在于Mutex是一个有状态的对象,它的state字段记录这个锁的状态。

go 复制代码
type Mutex struct {
    state int32
    sema  uint32
}

如果说复制了一个已经加锁了的Mutex并且将其赋值给一个新变量,由于值拷贝的缘故,这个新初始化的Mutex变量已经是已加锁的状态了,这显然会出现一些问题,一般情况下使用一个新的Mutex时,期望的是一个零值的Mutex。在并发环境下,很难确定赋值的Mutex处于什么状态,因为一个Mutex实例处于多个goroutine并发访问下,状态随时都会在变化。

我们都知道拷贝已使用的Mutex很有可能会出现严重的问题,也会格外去注意,但实际使用的时候,很有可能就会踩入这个陷阱,例如:

go 复制代码
type Counter struct {
    sync.Mutex
    Count int
}

// Counter参数通过值传递传入,进行了拷贝
func incr(counter Counter) {
    counter.Lock()
    defer counter.Unlock()
    fmt.Println("incr")
}

func main() {
    var counter Counter
    counter.Lock()
    defer counter.Unlock()
    counter.Count++
    incr(counter)
}

上述代码中,当调用incr函数,并将Counter结构体传入函数中,由于值传递的特性,调用者会将Mutex变量counter拷贝一份作为函数的参数,但是这个锁在被拷贝之前已经被使用,这导致赋值后的Counter 是一个带状态 Counter,因此上述代码执行后会造成死锁,所有goroutine都处于sleep

不过,现在的编译器能够识别到该代码,提示"通过值传递传递一个锁"来提示开发者。同样,在Go运行时,有死锁的检查机制(checkdead() 方法),它能够发现死锁的goroutine。程序运行的时候,死锁检查机制能够发现这种死锁情况并输出错误信息,如下图中错误信息以及错误堆栈:

如果说你不想再运行时才发现因为复制 Mutex 导致的死锁问题,可以通过使用 vet 工具,把检查写在 Makefile 文件中,在持续集成的时候运行程序,可以及时发现问题并及时修复。

上述终端提示出在incr的调用发生了lock value 复制的情况,可以根据打印出的代码行数定位到出现问题的地方并及时修复。

3、重入

熟悉Java的小伙伴都知道,Java中的ReentrantLock就是可重入锁。

可重入锁与互斥锁类似,但增加了一些扩展功能。

当一个线程准备获取到锁时,如果没有其他线程持有这把锁,则当前线程能成功获取到这把锁。在该线程未释放锁的期间,如果其它线程再请求这个锁,就会处于阻塞等待的状态。但是如果拥有这把锁的线程,再次请求这把锁时,反而不会阻塞,而是返回成功,即可重入锁。

在Go中,Mutex在设计上并不是可重入的锁

Mutex在设计上,并没有存储当前获取这把锁的goroutine是哪一个,因此没有办法计算重入,因为根本没有这个条件,一旦重入使用Mutex,则会产生panic。举个例子:

go 复制代码
func lockUser1(l sync.Locker) {
    fmt.Println("lock user1")
    l.Lock()
    lockUser2(l)
    l.Unlock()
}

func lockUser2(l sync.Locker) {
    l.Lock()
    fmt.Println("lock user2")
    l.Unlock()
}

func main() {
    lock := &sync.Mutex{}
    lockUser1(lock)
}

上述代码的执行结果为所有goroutine进入sleep,造成死锁。原因在于在调用lockUser2方法时,重入获取锁,Mutex在设计上并不是可重入的锁,所以获取锁阻塞,从而导致死锁。

虽然Mutex在设计上并不是可重入的锁,但是可以通过一些方法自己封装实现一个可重入锁,实现可重入锁的关键在于能够让锁记录下当前获取锁的goroutine是哪一个

方案一 :通过 hacker 的方式获取到 goroutine id,记录下获取锁的 goroutine id,它可以实现 Locker 接口。

需要注意,不同Go版本的goroutine的结构可能不同,所以需要根据 Go 的不同版本进行调整来获取到goroutine id,可以通过github上的一些第三方库来获取goroutine id。常用的库:petermattis/goid

获取到 goroutine id后,我们可以自己设计实现一个可重入锁:

go 复制代码
package RecursiveMutex

/**
可重入锁,记录goroutine ID,避免死锁
*/

import (
    "fmt"
    "github.com/petermattis/goid"
    "sync"
    "sync/atomic"
)

type RecursiveMutex struct {
    sync.Mutex
    owner     int64 // 当前持有锁的goroutine ID
    recursion int32 // 当前goroutine的重入次数
}

func (m *RecursiveMutex) Lock() {
    gid := goid.Get() // 获取当前goroutine的ID
    // 如果当前持有锁的goroutine ID与当前goroutine的ID相同,则递归加锁,说明是重入
    if atomic.LoadInt64(&m.owner) == gid {
       m.recursion++
       return
    }
    m.Mutex.Lock()
    // 获得锁的goroutine第一次调用,记录下它的goroutineID
    atomic.StoreInt64(&m.owner, gid)
    m.recursion = 1
}

func (m *RecursiveMutex) Unlock() {
    gid := goid.Get()
    // 非持有锁的goroutine尝试释放锁,错误的使用
    if atomic.LoadInt64(&m.owner) != gid {
       panic(fmt.Sprintf("wrong the owner(%d): %d!", m.owner, gid))
    }
    // 调用次数减一
    m.recursion--
    if m.recursion != 0 { // 如果这个goroutine还没有完全释放,则直接返回
       return
    }
    // 当前goroutine最后一次调用,需要释放锁
    atomic.StoreInt64(&m.owner, -1)
    m.Mutex.Unlock()
}

上述代码中:

  • owner字段记录当前锁的拥有者 goroutineid
  • recursion 是辅助字段,用于记录重入的次数。

尽管锁的拥有者可以多次调用Lock,但是也必须调用相同次数的Unlock,这样才能把锁释放掉,确保LockUnlock成对出现。

方案二 :调用 Lock/Unlock 方法时,由 goroutine 提供一个 token,用来标识goroutine本身,而不是通过 hacker的方式获取到 goroutine id,但这样一来就不满足Locker接口了。

调用者通过自己提供token,在获取锁时传入token标记是当前自己这个goroutine获取到该锁。释放锁时,同样也传入该token来告知是哪个goroutine进行锁的释放。通过传入的token替换方案一中 goroutine id,其它逻辑和方案一一致。

go 复制代码
package RecursiveMutex
/**
可重入锁,记录传入的token标识当前goroutine,避免死锁
*/

import (
    "fmt"
    "sync"
    "sync/atomic"
)

// TokenRecursiveMutex 可重入锁,记录传入的token标识当前goroutine,避免死锁
type TokenRecursiveMutex struct {
    sync.Mutex
    token     int64
    recursion int32
}

// Lock 请求锁,需要传入token标识当前goroutine
func (m *TokenRecursiveMutex) Lock(token int64) {
    if atomic.LoadInt64(&m.token) == token { // 如果传入的token和持有锁的token一致,说明是递归调用,重入
       m.recursion++
       return
    }
    m.Mutex.Lock() // 传入的token不一致,说明不是递归调用
    // 获取到锁后记录这个token
    atomic.StoreInt64(&m.token, token)
    m.recursion = 1
}

// Unlock 释放锁
func (m *TokenRecursiveMutex) Unlock(token int64) {
    if atomic.LoadInt64(&m.token) != token { // 当前传入的token和持有锁的token不一致,说明不是加锁的goroutine进行解锁,返回panic
       panic(fmt.Sprintf("wrong the owner(%d): %d!", m.token, token))
    }
    m.recursion--         // 当前持有这个锁的token释放锁
    if m.recursion != 0 { // 还没有回退到最初的递归调用,还存在重入
       return
    }
    atomic.StoreInt64(&m.token, 0) // 没有递归调用了,释放锁
    m.Mutex.Unlock()
}

4、死锁

说到死锁,已经是老生常谈的内容了,那么什么是死锁呢?

当两个或者两个以上的进程(或者线程、goroutine)在执行的过程中,因争抢共享资源而处于一种互相等待的状态,如果没有外部干涉的情况下,它们都处于无法继续推进的状态,此时,系统处于死锁状态或者产生了死锁。

通常来说,产生死锁的必要条件有四个,只要破坏者四个条件中的一个或者几个,就可以解除死锁。

  • 互斥:共享资源具有排他性,只有一个线程能够独享资源,在资源被获取后,其他线程想要获取资源必须处于等待状态,直到资源被释放。
  • 持有和等待 :当goroutine持有一个资源,并且还在请求其他goroutine持有的资源时,双方互相等待对方的资源释放,从而导致死锁。
  • 不可剥 夺:goroutine持有的资源只能由它自己释放,其他goroutine无法剥夺。
  • 环路等待:存在一组等待的进程,P={P1,P2,...,PN},P1 等待 P2 持有的资源,P2 等待 P3 持有的资源,依此类推,最后是 PN 等待 P1 持有的资源,多个进程之间形成了一个环路等待的死结。

举个例子:

go 复制代码
package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var oneLock sync.Mutex
    var twoLock sync.Mutex

    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
       defer wg.Done()

       oneLock.Lock()
       defer oneLock.Unlock()

       time.Sleep(5 * time.Second)

       twoLock.Lock()
       twoLock.Unlock()
    }()

    go func() {
       defer wg.Done()

       twoLock.Lock()
       defer twoLock.Unlock()

       time.Sleep(5 * time.Second)

       oneLock.Lock()
       oneLock.Unlock()
    }()

    wg.Wait()
    fmt.Println("success")
}

执行上述代码,可以发现产生了死锁,原因在于main中启动的两个goroutine互相等待对方持有的资源从而导致了死锁。Go运行时,有死锁探测的功能,能够检查出是否出现了死锁的情况。

总结

虽然Mutex互斥锁在使用上非常的便捷,但是我们在使用时,仍然需要留个心眼,避免因为不必要的错误而产生不可预估的后果。

使用Mutex常见的几个错误场景:

  • Lock/Unlock方法不是成对出现
  • 拷贝已使用的Mutex
  • Mutex的重入加锁
  • 死锁

需要强调的是,在上述的易错场景中,手误和重入导致的死锁,是最常见的使用 MutexBug


参考文章

time.geekbang.org/column/arti...

相关推荐
杨哥带你写代码1 小时前
足球青训俱乐部管理:Spring Boot技术驱动
java·spring boot·后端
AskHarries2 小时前
读《show your work》的一点感悟
后端
A尘埃2 小时前
SpringBoot的数据访问
java·spring boot·后端
yang-23072 小时前
端口冲突的解决方案以及SpringBoot自动检测可用端口demo
java·spring boot·后端
Marst Code2 小时前
(Django)初步使用
后端·python·django
代码之光_19802 小时前
SpringBoot校园资料分享平台:设计与实现
java·spring boot·后端
编程老船长2 小时前
第26章 Java操作Mongodb实现数据持久化
数据库·后端·mongodb
IT果果日记3 小时前
DataX+Crontab实现多任务顺序定时同步
后端
姜学迁4 小时前
Rust-枚举
开发语言·后端·rust
爱学习的小健4 小时前
MQTT--Java整合EMQX
后端