并发 Go 程序中的共享变量 (二):锁

本系列是阅读 “The Go Programming Language” 理解和记录。

上一节我们提到了避免 data race 的一种方法是使用 lock,而 Go 的 Mutex type 正好就提供了能够满足需要的 lock,直接看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

import "sync"

var (
mu sync.Mutex
balance int
)

func Deposit(amount int){
mu.Lock()
balance = balance + amount
mu.Unlock()
}

func Balance() int {
mu.Lock()
b := balance
mu.Unlock()
return b
}

每当有 goroutine 需要获取 balance,首先需要通过 mutex 获得一个排他锁,如果其他的 goroutine 已经获得了 lock,当前 goroutine 会被阻塞直到这个锁被释放。Mutex lock 就是通过这种机制的来保护共享数据的安全的,我们把锁保护起来的区域称之为 critical section

通过锁来保护 shared data 是一种很常用的机制,其重点在于判别 critical section 适时释放锁,在上面的例子中锁的释放是在完成 balance 的操作之后开始释放,由于代码量很少,这样的写法并不会引起太大的问题。在复杂的程序中,critical section 的逻辑可能会很复杂,特别是在 critical section 发生错误需要提前返回这时候就需要对锁进行提前释放,因此合理安排 lock 和 unlock 的出现时机变得非常重要,幸运地是 Go 有 defer 语句可以很好的解决这个问题:借助 defer,unlock 能够在 critical section 正确结束或错误返回包括 panic 都能得到执行。

1
2
3
4
5
func Balance() int {
mu.Lock()
defer mu.Unlock()
return balance
}

除了锁的释放时机,critical section 的判定更是直接决定了程序能否按照正确的逻辑执行,一起开下面的例子。

1
2
3
4
5
6
7
8
func Withdraw(amount int) bool {
Deposit(-amount)
if Balance() < 0 {
Deposit(amount)
return false // insufficient funds
}
return true
}

DepositBalance 是上面我们加了锁的两个函数,虽然 Withdraw 的执行不会造成 balance 无故消失的错误但是却导致了另外一个问题,考虑 Withdraw 在多个 goroutine 中执行,如果一个 goroutine 执行的时候导致 balance 是负的,则会导致另一个 goroutine 逻辑不能正确执行。比如现实中 balance 有 100 ,有一个 goroutine 发起了 110 withdraw 操作,导致 balance 是负的,而另一个 goroutine 即使发起的是 10 withdraw 操作也会失败,虽然 balance 的最终结果是对的,但一个合法的 withdraw 却失败了,这在现实中是无法接受的,就好比明明账上有 100 却无法支付一顿 10 的早餐。导致这个错误的原因就是:Withdraw 不是原子操作

什么是原子操作 atomic operation?原子操作就是一组操作,要么全部执行要么全部不执行,不会发生部分执行,部分不执行的情况Withdraw 函数中的一些列操作虽然用 lock 锁住了,但是这些步骤是割裂的,并不是连续的,这样会导致 Withdraw 在并发执行过程中,其它的 goroutine 能够看到当前 Withdraw 未执行完成的结果。一个解决办法是 Withdraw 函数也加锁。

1
2
3
4
5
6
7
8
9
10
func Withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
Deposit(-amount)
if Balance() < 0 {
Deposit(amount)
return false // insufficient funds
}
return true
}

由于 mutex 是不可重入的,如果 Withdraw 函数也用了 lock 则会发生死锁:

1
fatal error: all goroutines are asleep - deadlock!

对于上面的问题,一种通用的做法是,把 Deposit 单独实现,一个是具有 lock 的,对外使用,一个是没有 lock 的,供有 lock 的调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func Deposit(amount int) {
mu.Lock()
deposit(amount)
mu.Unlock()
}

func Withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
deposit(-amount)
if balance < 0 {
deposit(amount)
return false // insufficient funds
}
return true
}

func deposit(amount int) {
balance = balance + amount
}

Withdraw 例子恰好说明了在使用锁的时候需要考虑 critical section ,这在任何时候使用 mutex 时都需要注意。

三月沙 wechat
扫描关注 wecatch 的公众号