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...

相关推荐
专注VB编程开发20年5 分钟前
asp.net mvc如何简化控制器逻辑
后端·asp.net·mvc
用户67570498850235 分钟前
告别数据库瓶颈!用这个技巧让你的程序跑得飞快!
后端
千|寻1 小时前
【画江湖】langchain4j - Java1.8下spring boot集成ollama调用本地大模型之问道系列(第一问)
java·spring boot·后端·langchain
程序员岳焱1 小时前
Java 与 MySQL 性能优化:MySQL 慢 SQL 诊断与分析方法详解
后端·sql·mysql
龚思凯1 小时前
Node.js 模块导入语法变革全解析
后端·node.js
天行健的回响1 小时前
枚举在实际开发中的使用小Tips
后端
wuhunyu1 小时前
基于 langchain4j 的简易 RAG
后端
techzhi1 小时前
SeaweedFS S3 Spring Boot Starter
java·spring boot·后端
写bug写bug2 小时前
手把手教你使用JConsole
java·后端·程序员