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

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

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

import "sync"

var (
    mu    sync.Mutex
    balance int
)

func Deposit(amountint){
    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 都能得到执行。

func Balance()int {
    mu.Lock()
    defer mu.Unlock()
    return balance
}

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

func Withdraw(amountint)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 函数也加锁。

func Withdraw(amountint)bool {
    mu.Lock()
    defer mu.Unlock()
    Deposit(-amount)
    if Balance() < 0 {
        Deposit(amount)
        return false // insufficient funds
    }
    return true
}

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

fatal error: all goroutines are asleep - deadlock!

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

func Deposit(amountint) {
    mu.Lock()
    deposit(amount)
    mu.Unlock()
}

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

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

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