在Go中,互斥锁通过sync.Mutex
实现,该结构体实现了Locker
接口,实现了Lock
和 Unlock
方法,在进入需要保护的临界区之前,调用Lock
方法获取互斥锁,在退出临界区时,调用Unlock
方法释放互斥锁。
sync.Mutex
这样的便利的同步原语,在使用上非常的简单便捷,只有Lock
和Unlock
两个方法,正常使用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
字段记录当前锁的拥有者goroutine
的id
;recursion
是辅助字段,用于记录重入的次数。
尽管锁的拥有者可以多次调用Lock
,但是也必须调用相同次数的Unlock
,这样才能把锁释放掉,确保Lock
与Unlock
成对出现。
方案二 :调用 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
的重入加锁- 死锁
需要强调的是,在上述的易错场景中,手误和重入导致的死锁,是最常见的使用 Mutex
的 Bug
。
参考文章