Golang之Mutex扩展

603 阅读8分钟

使用 Mutex 常见的错误场景有 4 类,分别是Lock/Unlock 不是成对出现Copy 已使用的 Mutex、重入和死锁。

Lock/Unlock 不是成对出现

Lock/Unlock 没有成对出现,就意味着会出现死锁的情况,或者是因为 Unlock 一个未加锁的Mutex而导致 panic。 我们先来看看缺少Unlock 的场景,常见的有三种情况

  1. 代码中有太多的if-else 分支,可能在某个分支中漏写了Unlock;
  2. 在重构的时候把 Unlock 给删除了
  3. Unlock 误写成了 Lock。

在这种情况下,锁被获取之后,就不会被释放了,这也就意味着,其它的 goroutine 永远都没机会获取到锁。

Copy 已使用的 Mutex

第二种误用是 Copy 已使用的 Mutex。在正式分析这个错误之前,我先交代一个小知识点那就是 Package sync的同步原语在使用后是不能复制的。我们知道 Mutex 是最常用的一个同步原语,那它也是不能复制的。为什么呢?

原因在于,Mutex 是一个有状态的对象,它的 state 字段记录这个锁的状态。如果你要复制一个已经加锁的 Mutex 给一个新的变量,那么新的刚初始化的变量居然被加锁了,这显然不符合你的期望,因为你期望的是一个零值的 Mutex。关键是在并发环境下,你根本不知道要复制的 Mutex 状态是什么,因为要复制的 Mutex 是由其它 goroutine 并发访问的,状态可能总是在变化。

type Counter struct {
	sync.Mutex
	Count int
}

func main() {
	var c Counter
	c.Lock()
	defer c.Unlock()
	c.Count++
	foo(c) // 复制锁
}

//这里counter的参数是通过复制的方式传入的
func foo(c Counter) {
	c.Lock()
	defer c.Unlock()
	fmt.Println("in foo")
}

怎么办呢? Go 在运行时,有死锁的检查机制 (@ checkdead0 方法),它能够发现死锁的goroutine。这个例子中因为复制了一个使用了的 Mutex,导致锁无法使用,程序处于死锁的状态。程序运行的时候,死锁检查机制能够发现这种死锁情况并输出错误信息

你肯定不想运行的时候才发现这个因为复制 Mutex 导致的死锁问题,那么你怎么能够及时发现问题呢?可以使用 vet 工具,把检查写在 Makefile 文件中,在持续集成的时候跑一跑,这样可以及时发现问题,及时修复。我们可以使用 go vet 检查这个 Go文件:go vet copy.go

重入

接下来,我们来讨论“重入”这个问题。在说这个问题前,我先解释一下个概念,叫“可重入锁”

什么是可重入锁,当一个线程获取锁时,如果没有其它线程拥有这个锁,那么,这个线程就成功获取到这个锁之后,如果其它线程再请求这个锁,就会处于阻塞等待的状态。但是,如果拥有这把锁的线程再请求这把锁的话,不会阻塞,而是成功返回,所以叫可重入锁(有时候也叫做递归锁)。只要你拥有这把锁,你可以可着劲儿地调用,比如通过递归实现一些算法,调用者不会阻塞或者死锁。

了解了可重入锁的概念,那我们来看 Mutex 使用的错误场景。划重点了:Mutex 不是可重的锁。 想想也不奇怪,因为 Mutex 的实现中没有记录哪个 goroutine 拥有这把锁。理论上,任何goroutine 都可以随意地 Unlock 这把锁,所以没办法计算重入条件。

虽然标准库 Mutex 不是可重入锁,但是如果我就是想要实现一个可重入锁,可以吗? 可以,那我们就自己实现一个。这里的关键就是,实现的锁要能记住当前是哪个 goroutine持有这个锁。我来提供两个方案

  • 方案一: 通过 hacker 的方式获取到 goroutine id,记录下获取锁的 goroutine id,它可以实现 Locker 接口。
  • 方案二: 调用Lock/Unlock 方法时,由 goroutine 提供一个 token,用来标识它自己,而不是我们通过 hacker 的方式获取到 goroutine id,但是,这样一来,就不满足 Locker 接口了

可重入锁(递归锁)解决了代码重入或者递归调用带来的死锁问题,同时它也带来了另一个好处,就是我们可以要求,只有持有锁的 goroutine 才能 unlock 这个锁。这也很容易实现,因为在上面这两个方案中,都已经记录了是哪一个 goroutine 持有这个锁

下面我们具体来看这两个方案怎么实现

方案一: goroutineid

这个方案的关键第一步是获取 goroutine id,方式有两种,分别是简单方式和 hacker 方式。 简单方式,就是通过runtime.Stack 方法获取栈信息,栈信息里包含 goroutine id。

获取id的方法

func GoID() int {
	var buf [64]byte
	n := runtime.Stack(buf[:], false)
	// 得到id字符串
	idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0]
	id,err := strconv.Atoi(idField)
	if err != nil {
	panic(fmt.Sprintf("cannot get goroutine id: %v", err))
	}
	return id
}

了解了简单方式,接下来我们来看hacker 的方式,这也是我们方案一采取的方式 首先,我们获取运行时的g 指针,反解出对应的g 的结构。每个运行的 goroutine 结构的g指针保存在当前 goroutine 的一个叫做 TLS 对象中

  • 第一步:我们先获取到 TLS 对象
  • 第二步:再从TLS 中获取 goroutine 结构的g 指;
  • 第三步:再从g 指针中取出 goroutine id。

需要注意的是,不同 Go 版本的 goroutine 的结构可能不同,所以需要根据 Go的@不同版本进行调整。当然了,如果想要搞清楚各个版本的 goroutine 结构差异,所涉及的内容又过于底层而且复杂,学习成本太高。怎么办呢?我们可以重点关注一些库。我们没有必要重复发明轮子,直接使用第三方的库来获取 goroutine id 就可以了

好消息是现在已经有很多成熟的方法了,可以支持多个 Go版本的 goroutine id,给你推荐一个常用的库: @petermattis/goid。 知道了如何获取 goroutine id,接下来就是最后的关键一步了,我们实现一个可以使用的可重入锁:

// RecursiveMutex 包装一个Mutex,实现可重入
type RecursiveMutex struct {
	sync.Mutex
	owner int64 // 当前持有锁的goroutine id
	recursion int32 // 这个goroutine 重入的次数
}

func (m *RecursiveMutex) Lock() {
	gid := goid.Get()
	// 如果当前持有锁的goroutine就是这次调用的goroutine,说明是重入
	if atomic.LoadInt64(&m.owner) == gid {
		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()
	
	// 非持有锁的goroutine尝试释放锁,错误的使用
	if atomic.LoadInt64(&m.owner) != gid {
		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()
}

方案二: token

方案一是用 goroutine id 做 goroutine 的标识,我们也可以让 goroutine 自己来提供标识不管怎么说,Go 开发者不期望你利用 goroutine id 做一些不确定的东西,所以,他们没有暴露获取 goroutine id 的方法

下面的代码是第二种方案。调用者自己提供一个 token,获取锁的时候把这个 token 传入,释放锁的时候也需要把这个 token 传入。通过用户传入的 token 替换方案一中 goroutineid,其它逻辑和方案一一致。

// Token方式的递归锁
type TokenRecursiveMutex struct {
	sync.Mutex
	token int64
	recursion int32
}

// 请求锁,需要传入token
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
}

// 释放锁
func (m *TokenRecursiveMutex) Unlock(token int64) {
	if atomic.LoadInt64(&m.token) != token { // 释放其它token持有的锁
		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()
}

死锁

接下来,我们来看第四种错误场景:死锁。

我先解释下什么是死锁。两个或两个以上的进程(或线程,goroutine)在执行过程中,因 争夺共享资源而处于一种互相等待的状态,如果没有外部干涉,它们都将无法推进下去, 此时,我们称系统处于死锁状态或系统产生了死锁。

我们来分析一下死锁产生的必要条件。如果你想避免死锁,只要破坏这四个条件中的一个 或者几个,就可以了。

  1. 互斥: 至少一个资源是被排他性独享的,其他线程必须处于等待状态,直到资源被释 放。

  2. 持有和等待:goroutine 持有一个资源,并且还在请求其它 goroutine 持有的资源,也 就是咱们常说的“吃着碗里,看着锅里”的意思。

  3. 不可剥夺:资源只能由持有它的 goroutine 来释放。

  4. 环路等待:一般来说,存在一组等待进程,P={P1,P2,…,PN},P1 等待 P2 持有的 资源,P2 等待 P3 持有的资源,依此类推,最后是 PN 等待 P1 持有的资源,这就形成 了一个环路等待的死结。