gopl-zh.github.com/ch9/ch9-02.md

8.0 KiB
Raw Permalink Blame History

9.2. sync.Mutex互斥锁

在8.6节中我们使用了一个buffered channel作为一个计数信号量来保证最多只有20个goroutine会同时执行HTTP请求。同理我们可以用一个容量只有1的channel来保证最多只有一个goroutine在同一时刻访问一个共享变量。一个只能为1和0的信号量叫做二元信号量binary semaphore

gopl.io/ch9/bank2

var (
	sema    = make(chan struct{}, 1) // a binary semaphore guarding balance
	balance int
)

func Deposit(amount int) {
	sema <- struct{}{} // acquire token
	balance = balance + amount
	<-sema // release token
}

func Balance() int {
	sema <- struct{}{} // acquire token
	b := balance
	<-sema // release token
	return b
}

这种互斥很实用而且被sync包里的Mutex类型直接支持。它的Lock方法能够获取到token(这里叫锁)并且Unlock方法会释放这个token

gopl.io/ch9/bank3

import "sync"

var (
	mu      sync.Mutex // guards balance
	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访问bank变量时这里只有balance余额变量它都会调用mutex的Lock方法来获取一个互斥锁。如果其它的goroutine已经获得了这个锁的话这个操作会被阻塞直到其它goroutine调用了Unlock使该锁变回可用状态。mutex会保护共享变量。惯例来说被mutex所保护的变量是在mutex变量声明之后立刻声明的。如果你的做法和惯例不符确保在文档里对你的做法进行说明。

在Lock和Unlock之间的代码段中的内容goroutine可以随便读取或者修改这个代码段叫做临界区。锁的持有者在其他goroutine获取该锁之前需要调用Unlock。goroutine在结束后释放锁是必要的无论以哪条路径通过函数都需要释放即使是在错误路径中也要记得释放。

上面的bank程序例证了一种通用的并发模式。一系列的导出函数封装了一个或多个变量那么访问这些变量唯一的方式就是通过这些函数来做或者方法对于一个对象的变量来说。每一个函数在一开始就获取互斥锁并在最后释放锁从而保证共享变量不会被并发访问。这种函数、互斥锁和变量的编排叫作监控monitor这种老式单词的monitor是受“monitor goroutine”的术语启发而来的。两种用法都是一个代理人保证变量被顺序访问

由于在存款和查询余额函数中的临界区代码这么短——只有一行没有分支调用——在代码最后去调用Unlock就显得更为直截了当。在更复杂的临界区的应用中尤其是必须要尽早处理错误并返回的情况下就很难去靠人判断对Lock和Unlock的调用是在所有路径中都能够严格配对的了。Go语言里的defer简直就是这种情况下的救星我们用defer来调用Unlock临界区会隐式地延伸到函数作用域的最后这样我们就从“总要记得在函数返回之后或者发生错误返回时要记得调用一次Unlock”这种状态中获得了解放。Go会自动帮我们完成这些事情。

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

上面的例子里Unlock会在return语句读取完balance的值之后执行所以Balance函数是并发安全的。这带来的另一点好处是我们再也不需要一个本地变量b了。

此外一个deferred Unlock即使在临界区发生panic时依然会执行这对于用recover§5.10来恢复的程序来说是很重要的。defer调用只会比显式地调用Unlock成本高那么一点点不过却在很大程度上保证了代码的整洁性。大多数情况下对于并发程序来说代码的整洁性比过度的优化更重要。如果可能的话尽量使用defer来将临界区扩展到函数的结束。

考虑一下下面的Withdraw函数。成功的时候它会正确地减掉余额并返回true。但如果银行记录资金对交易来说不足那么取款就会恢复余额并返回false。

// NOTE: not atomic!
func Withdraw(amount int) bool {
	Deposit(-amount)
	if Balance() < 0 {
		Deposit(amount)
		return false // insufficient funds
	}
	return true
}

函数终于给出了正确的结果但是还有一点讨厌的副作用。当过多的取款操作同时执行时balance可能会瞬时被减到0以下。这可能会引起一个并发的取款被不合逻辑地拒绝。所以如果Bob尝试买一辆sports car时Alice可能就没办法为她的早咖啡付款了。这里的问题是取款不是一个原子操作它包含了三个步骤每一步都需要去获取并释放互斥锁但任何一次锁都不会锁上整个取款流程。

理想情况下,取款应该只在整个操作中获得一次互斥锁。下面这样的尝试是错误的:

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

上面这个例子中Deposit会调用mu.Lock()第二次去获取互斥锁但因为mutex已经锁上了而无法被重入译注go里没有重入锁关于重入锁的概念请参考java——也就是说没法对一个已经锁上的mutex来再次上锁——这会导致程序死锁没法继续执行下去Withdraw会永远阻塞下去。

关于Go的mutex不能重入这一点我们有很充分的理由。mutex的目的是确保共享变量在程序执行时的关键点上能够保证不变性。不变性的一层含义是“没有goroutine访问共享变量”但实际上这里对于mutex保护的变量来说不变性还包含更深层含义当一个goroutine获得了一个互斥锁时它能断定被互斥锁保护的变量正处于不变状态译注即没有其他代码块正在读写共享变量在其获取并保持锁期间可能会去更新共享变量这样不变性只是短暂地被破坏然而当其释放锁之后锁必须保证共享变量重获不变性并且多个goroutine按顺序访问共享变量。尽管一个可以重入的mutex也可以保证没有其它的goroutine在访问共享变量但它不具备不变性更深层含义。译注更详细的解释Russ Cox认为可重入锁是bug的温床是一个失败的设计

一个通用的解决方案是将一个函数分离为多个函数比如我们把Deposit分离成两个一个不导出的函数deposit这个函数假设锁总是会被保持并去做实际的操作另一个是导出的函数Deposit这个函数会调用deposit但在调用前会先去获取锁。同理我们可以将Withdraw也表示成这种形式

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) {
	mu.Lock()
	defer mu.Unlock()
	deposit(amount)
}

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

// This function requires that the lock be held.
func deposit(amount int) { balance += amount }

当然这里的存款deposit函数很小实际上取款Withdraw函数不需要理会对它的调用尽管如此这里的表达还是表明了规则。

封装§6.6用限制一个程序中的意外交互的方式可以使我们获得数据结构的不变性。因为某种原因封装还帮我们获得了并发的不变性。当你使用mutex时确保mutex和其保护的变量没有被导出在go里也就是小写且不要被大写字母开头的函数访问啦无论这些变量是包级的变量还是一个struct的字段。