这是我参与8月更文挑战的第 26 天,活动详情查看: 8月更文挑战
互斥锁
互斥锁模式应用很广泛,所以sync包有一个单独的Mutex类型来支持这种模式。它的Lock方法用于获取令牌(token,此过程也称为上锁),Unlock方法用于释放令牌(解锁),还是上一篇文章中银行转账的例子,使用锁来保证并发安全
package bank
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)之前,它都必须先调用互斥量的Lock方法来获取一个互斥锁。如果其他goroutine已经取走了互斥锁,那么操作会一直阻塞到其他goroutine调用unlock之后(此时互斥锁再度可用)。互斥量保护共享变量。按照惯例,被互斥量保护的变量声明应当紧接在互斥量的声明之后
在Lock和Unlock之间的代码,可以自由地读取和修改共享变量,这一部分称为临界区域。在锁的持有人调用Unlock之前,其他goroutine不能获取锁。所以很重要的一点是,goroutine在使用完成后就应当释放锁,另外,需要包括函数的所有分支,特别是错误分支
上面的银行程序展现了一个典型的并发模式。几个导出函数(全局函数,首字母大写可跨包访问)封装了一个或多个变量,于是只能通过这些函数来访问这些变量(对于一个对象的变量,则用方法来封装)。每个函数在开始时申请一个互斥锁,在结束时再释放掉,通过这种方式来确保共享变量不会被并发访问。这种函数、互斥锁、变量的组合方式称为监控模式。(之前在监控goroutine 中也使用了监控(monitor)这个词,都代表使用一个代理人(broker)来确保变量按顺序访问)
因为Deposit和Balance函数中的临界区域都很短(只有一行,也没有分支),所以直接在函数结束时调用Unlock也很方便。在更复杂的临界场景中,特别是必须通过提前返回来处理错误的场景,很难确定在所有的分支中Lock和Unlock都成对执行了。Go语言的defer语句就可以解决这个问题:通过延迟执行Unlock就可以把临界区域隐式扩展到当前函数的结尾,避免了必须在一个或者多个远离Lock的位置插入一条Unlock语句
func Balance() int {
mu.Lock()
defer mu.Unlock()
return balance
)
在上面的例子中,Unlock在return语句已经读完balance变量之后执行,所以Balance 函数就是并发安全的。另外,也不需要使用局部变量b了
而且,在临界区域崩溃时延迟执行的Unlock也会正确执行,这在使用recover的情况下尤其重要(关于Go中的异常处理,可以点这里)。在处理并发程序时,永远应当优先考虑清晰度,并且拒绝过早优化。 在可以使用的地方,就尽量使用defer来让临界区域扩展到函数结尾处
看一下下边的Withdraw函数。当成功时,余额减少了指定的数量,并且返回true,但如果余额不足,无法完成交易,Withdraw恢复余额并且返回false
func Withdraw(amount int) bool {
Deposit(-amount)
if Balance() < 0 {
Deposit(amount)
return false //余额不足
}
return true
}
这个函数最终是可以给出正确结果的,但是它有一个副作用。在进行超额提款的时候,在某个瞬间,余额是会降到0元以下的。这有可能就会导致一个小额的取款会被不合逻辑的被拒掉。所以,当B在尝试购买一辆跑车时,会导致A无法支付一杯咖啡钱。Withdraw的问题在于它不是原子操作。它本身包含三个串行操作,每个操作都申请并释放了互斥锁,但对于整个序列没有上锁
下边有一种不正确的方式,为整个操作上锁
func Withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
Deposit(-amount)
if Balance() < 0 {
Deposit(amount)
return false //余额不足
}
return true
}
Deposit会通过调用mu.Lock。来尝试再次获取互斥锁,但由于互斥锁是不能再入的(无法对一个已经上锁的互斥量再上锁),因此这会导致死锁Withdraw会一直被卡住(之前整理过操作系统中的死锁,感兴趣的点这里)
Go语言的互斥量是不可再入的,后边会说明。互斥量的目的是在程序执行过程中维持基于共享变量的特定不变量。其中一个不变量是“没有goroutine正在访问这个共享变量”,但有可能互斥量也保护针对数据结构的其他不变量。当goroutine获取一个互斥锁的时候,它可能会假定这些不变量是满足的。当它获取到互斥锁之后,它可能会更新共享变量的值,这样可能会临时不满足之前的不变量。当它释放互斥锁时,它必须保证之前的不变量已经还原且又能重新满足。尽管一个可重入的互斥量可以确保没有其他goroutine可以访问共享变量,但是无法保护这些变量的其他不变量
一个常见的解决方案是把Deposit这样的函数拆分为两部分:一个不导出(私有)的函数deposit,它假定已经获得互斥锁,并完成实际的业务逻辑;以及一个导出(公共)的函数Deposit,,它用来获取锁并调用deposit。这样我们就可以用deposit来实现Withdraw:
func Deposit(amount int) {
mu.Lock()
defer mu.Unlock()
deposit(amount)
}
func Withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
deposit(-amount)
if Balance() < 0 {
deposit(amount)
return false //余额不足
}
return true
}
func Balance() int {
mu.Lock()
defer mu.Unlock()
return balance
}
//该函数要求调用前,已获取互斥锁
func deposit(amount int) {
balance += amount
}
封装,即通过在程序中减少对数据结构的非预期交互,来帮助我们保证数据结构中的不变量。因为类似的原因,封装也可以用来保持并发中的不变性。所以无论是为了保护包级别的变量,还是结构中的字段,当你使用一个互斥量时,都请确保互斥量本身以及被保护的变量都没有导出
读写互斥锁
B发现自己存的100元存款消失了,没留下任何线索,B感到很焦虑,为了解决这个问题,B写了一个程序,每秒钟查询数百次他的账户余额。这个程序同时在他家里、公司里和他的手机上运行。银行注意到快速增长的业务请求正在拖慢存款和取款操作,因为所有的 Balance请求都是串行运行的,持有互斥锁并暂时妨碍了其他goroutine运行
因为Balance函数只须读取变量的状态,所以多个Balance请求其实可以安全地并发运行,只要Deposit和Withdraw请求没有同时运行即可。在这种场景下,就需要一种特殊类型的锁,它允许只读操作可以并发执行,但写操作需要获得完全独享的访问权限。这种锁称为多读单写锁,Go语言中的sync.RWMutex可以提供这种功能:
var mu sync.RWMutex
var balance int
func Balance() int {
mu.RLock() // 读锁
defer mu.RUnlock()
return balance
}
Balance函数现在可以调用RLock和RUnlock方法来分别获取和释放一个读锁(也称为共享锁)。Deposit函数无须更改,它通过调用mu.Lock和mu.Unlock来分别获取和释放一个写锁 (也称为互斥锁)
经过上面的修改之后,B的绝大部分Balance请求可以并行运行且能更快完成。因此,锁可用的时间比例会更大,Deposit请求也能得到更及时的响应
RLock仅可用于在临界区域内对共享变量无写操作的情形。一般来说,我们不应当假定那些逻辑上只读的函数和方法不会更新一些变量。比如,一个看起来只是简单访问器的方法可能会递增内部使用的计数器,或者更新一个缓存来让重复的调用更快
仅在绝大部分goroutine都在获取读锁并且锁竞争比较激烈时(即goroutine一般都需要等待后才能获到锁),RWMutex才有优势。因为RWMutex需要更复杂的内部簿记工作,所以在竞争不激烈时它比普通的互斥锁慢
参考
《Go程序设计语言》—-艾伦 A. A. 多诺万
《Go语言学习笔记》—-雨痕