golang中你应该知道的可重入锁知识

探索锁和可重入锁之间的区别,理解Golang的独特立场,并在系统设计中导航可重入锁的复杂性。

Love is a friendship set to music.
— Joseph Campbell

1.1 锁与可重入锁的概念

解释锁与可重入锁,它是如何工作的?

锁是一个并发控制概念。当某个线程A已经持有了一个锁,当线程B尝试进入被这个锁保护的代码段的时候,就会被阻塞。由此可见锁的操作粒度是“线程”,而不是调用。

而“可重入”指的是某个线程已经获得某个锁,可以再次获取这个锁而不会出现死锁。这就意味着同一个线程再次进入同步代码的时候,可以使用已经获取到的锁,这就是可重入锁。

可重入锁的主要使用场景是线程需要多次进入临界区代码。这个直观的需求就要求锁必须是可重入的,也就是所谓的递归锁。

1.2 Golang 不支持互斥锁

Golang 不支持可重入锁,一些代码示例,帮助理解

在Java中,内置锁(synchronize)和ReentrantLock都是可重入锁。用Java 实现以下逻辑的代码不会有问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[]args) {
Lock lock new ReentrantLock();
lock.Lock();
System.out.println("lock 1");
lock.Lock();
System.out.println("lock 2");
lock.unlock();
System.out.println("unlock 2");
lock.unlock();
System.out.println("unlock 1");
}

// print
lock 1
lock 2
unlock 2
unlock 1

但在Golang中,类似的代码将会在产生死锁!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
var lock sync.Mutex
lock.Lock()
fmt.Println("lock 1")
lock.Lock()
fmt.Println("lock 2")
lock.Unlock()
fmt.Println("unlock 2")
lock.Unlock()
fmt.Println("unlock 1")
}
// print
lock 1
fatal error: all goroutines are asleep - deadlock!

Run it:Better Go Playground

让我们从读写锁的角度更深一步去理解一下:Golang中只有互斥锁,而所有互斥的读写锁的特点是什么?

  • 读与读之间不互斥
  • 读与写、写与写之间互斥

既然读锁之间是不互斥,也就是可加两次读锁,那么读锁必然是可重入的,这里就不具体举例了。但是读锁和写锁是互斥的,而且Golang 不支持可重入,习惯于Java等语言的人用Golang时就很可能遇到麻烦,以下构造了一段很可能出现的问题的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func main() {
var lock sync.RWMutex
var wg sync.WaitGroup
wg.Add(1)
lock.RLock() // ①
fmt.Println("rlock 1...")
go func() {
lock.Lock()
fmt.Println("Lock...") // ②
time.Sleep(time.Second)
lock.Unlock()
fmt.Println("unlock...")
wg.Done()
}()
time.Sleep(time.Second)

lock.RLock()
fmt.Println("rlock 2...") // ③
lock.RUnlock()
fmt.Println("runlock 1...")
lock.RUnlock()
fmt.Println("runlock 2...")
wg.Wait()
}
// print
rlock 1...
fatal error: all goroutines are asleep - deadlock!

Run it: Better Go Playground
这段代码按①、②、③顺序执行,第②段写锁需要等第①个读锁释放,第③段读锁需要等第②段写锁释放,最终就是一个死锁的逻辑。

而同样的逻辑在 Java 中就没有问题,感兴趣可以动手去试试。说到这里,有部分人可能会怀疑Golang 这么做合理吗?为什么会这样实现呢?毕竟从直觉来说:

  • 一个协程(或线程)已经获取到了读锁,别的协程(线程)获取写锁时必然需要等待读锁的释放。既然这个协程(或线程)已经拥有了这个读锁,那么为什么再次获取读锁时需要管别的写锁是否等待呢?

这就需要我们了解一下Golang排除可重入锁的设计原则了

1.3 Golang排除可重入锁的哲学

为什么Golang排除可重入锁的支持。

要回答上问的疑问,我们必须要先了解 Golang 互斥锁的设计原则,Golang 在互斥锁设计上会遵守这几个原则。如下:

  • 在调用 mutex.Lock 方法时,要保证这些变量的不变性保持,不会在后续的过程中被破坏。
  • 在调用 mu.Unlock 方法时,要保证:
    • 程序不再需要依赖那些不变量。
    • 如果程序在互斥锁加锁期间破坏了它们,则需要确保已经恢复了它们。

以可重入的角度我们可以举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
func F() {
mu.Lock()
... do some stuff ...
G()
... do some more stuff ...
mu.Unlock()
}

func G() {
mu.Lock()
... do some stuff ...
mu.Unlock()
}

在 F 方法中调用 mu.Lock 方法加上了锁。如果支持可重入锁,接着就会进入到 G 方法中。
此时就会有一个致命的问题,你不知道 F 和 G 方法加锁后是不是做了什么事情,从而导致破坏了一些不变量。
Experimenting with GO 有更多的细节,我理解其中的重点如下:

Recursive mutexes do not protect invariants.
Mutexes have only one job, and recursive mutexes don’t do it.

1.4 Golang实现可重入锁

既然我们知道了Golang 互斥锁的设计哲学,我们不妨多想一步,如果实现可重入锁,你会怎么做?

目前我们了解,Go语言的mutex只记录了加锁状态,没有记录锁的所有者,所以不支持可重入, 参考Java 的实现,我们可以抽象出实现一个可重入锁需要满足的两点:

  • 记住持有锁的协程
  • 统计重入的次数

统计重入的次数很容易实现,如果能将锁与Gorouting ID绑定,我们就能满足第一点,那么我们理论上就可以实现可重入锁。但是我觉得并没有太多实现的必要,因为至少 Go 语言团队目前并未提供支持。

我认为针对一些场景,更好的去使用互斥锁会对业务有更大帮助,而不是去依赖“可重入锁”。毕竟可重入锁也可能会引入一些新的问题。

1.5 可重入锁的潜在问题

了解可能影响Golang决策的可重入锁的潜在问题或陷阱。

潜在问题:

  1. 过度的锁嵌套浪费性能。
  2. 潜在的死锁风险,例如忘记释放锁。
  3. 实现与使用增加了复杂度。

因此,当你想要使用可重入锁时,我们需要仔细考虑其优势与劣势,以及是否真正需要其可重入特性。

而在Golang语言的世界中,我认为我们应该去遵守Golang的设计哲学。

1.6 参考

这不会又是一个Go的BUG吧?-腾讯云开发者社区-腾讯云
Go实现可重入锁的两种办法 - 掘金
Experimenting with GO

更多该系列文章,参考medium链接:
https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a

English post: https://programmerscareer.com/golang-reentry-lock/
作者:Wesley Wei – Twitter Wesley Wei – Medium
注意:原文在 2024-07-04 01:02 时创作于 https://programmerscareer.com/golang-reentry-lock/. 本文为作者原创,转载请注明出处。

你应该了解的 Golang 的语法糖 use-channel

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×