Go语言基于CSP模型实现并发的两大武器 协程goroutine 和 通道channel 相信你已经有了一个非常详细的了解和认知。今天起,我们就详细探究一下并发机制。尤其是在多goroutine之间的共享变量,并发问题的分析手段,以及解决这些问题的基本模式。在深入探讨之前,我们先学习一个概念:竟态!
一、何为竞态?
竞态(有时也叫竞争条件)是指在多个 goroutine 按某些交错顺序执行时程序无法给出正确的结果。
这种恶劣的“状态”平时还很难出现,可能就潜伏在你程序的某个角落里:它可能偶尔蹦出来,或者高并发大负载的时候偶发,又或是在某一个特定的编译器、平台甚至是架构里才会出现。使得这种问题非常难以分析和诊断。
这里给你透露一个小技巧——竞争检查器(the race detector):在go build、go run或者go test的时候通过 -race 参数开启竞态检测,有利于发现程序中的问题点。 另外,https://staticcheck.io 这个站点也是一个非常好的代码检测应用。平时可以使用它来检查程序中可能存在的问题。
数据竞争是最常见的竞争条件之一:数据竞争会在两个以上的goroutine并发访问相同的变量且至少其中一个为写操作时发生。
既然竞态问题这么烦人,那么我们该怎么尽量避免程序中出现数据竞争这些问题呢?
二、如何避免竞态?
根据数据竞争的定义,我们可以发现,并发访问 和 写操作,是问题产生的必要条件。所以,规避数据竞争的方法自然也要从这两方面入手:
- 第一种方法是不要去写变量(并发读取是不会产生任何问题的)。
- 第二种方法是避免从多个goroutine访问变量,也就是保证变量是在一个“线性”环境中访问的(无论是绑定到单个goroutine中,还是多个goroutine形成的类似管道的环境)。
- 第三种方法是允许很多goroutine去访问变量,但是在同一个时刻最多只有一个 goroutine在访问。这种方式被称为“互斥”。
三、实现“互斥”的方法:互斥锁(sync.Mutex)
互斥锁模式应用非常广泛,所以 sync 包有一个单独的 Mutex 类型来支持这种模式。它的 Lock 方法用来获取令牌 (token,此过程也称为上锁),Unlock 方法用于释放令牌:
package main
import "sync"
var (
mu sync.Mutex // 保护 balance
balance int
)
func Deposit(amount int) {
mu.Lock()
balance += amount
mu.Unlock()
}
func Balance() int {
mu.Lock()
b := balance
mu.Unlock()
return b
}
在 Lock 和 Unlock 之间的代码,可以自由地读取和修改共享变量,这一部分称为临界区,在锁的持有人调用 Unlock 之前,其他的 goroutine 不能获取锁。上面的bank程序例证了一种通用的并发模式:
- 一系列的导出函数封装了一个或多个变量
- 访问这些变量唯一的方式就是通过这些函数
- 每一个函数在一开始就获取互斥锁并在最后释放锁,从而保证共享变量不会被并发访问
这种函数、互斥锁和变量的编排叫作监控monitor。上面的临界区里只有一行代码,比较简单。再看看下面的示例程序:
// 注意: 不是原子操作
func Withdraw(amount int) bool {
Deposit(-amount)
if Balance() < 0 {
Deposit(amount)
return false // 余额不足
}
return true
}
这个函数的问题在于不是原子操作:它包含三个串行的操作,每个操作都申请并释放了互斥锁,但对于整个序列没有上锁。所以,我们需要用锁把这三个串行的操作框在临界区里。但如果还用mu.Lock()的话,因为是互斥锁,如果Deposit再次调用的话,会导致死锁,所以要么再造一把锁,要么就把Deposit拆成两部分:一个不导出的函数deposit,这样我们用不导出的deposit来实现Withdraw:
var (
mu sync.Mutex // 保护 balance
balance int
)
func Withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
deposit(-amount)
if balance < 0 {
deposit(amount)
return false
}
return true
}
func Deposit(amount int) {
mu.Lock()
defer mu.Unlock()
deposit(amount)
}
func Balance() int {
mu.Lock()
defer mu.Unlock()
return balance
}
// 这个函数要求已经获取互斥锁
func deposit(amount int) {
balance += amount
}
四、读写互斥锁(sync.RWMutex)
因为 Balance 函数只须读取变量的状态,所以多个 Balance 请求其实可以安全地并发运行,只要 Deposit 和 Withdraw 请求没有同时运行即可。在这种情况下,我们需要一种特殊类型的锁,它允许只读操作可以并发执行,但写操作需要获得完全读享的访问权限。这种锁称为多读单写锁,Go 语言中的 sync.RWMutex 可以提供这种功能:
var (
mu sync.RWMutex
balance int
)
func Balance() int {
mu.RLock() // 读锁(共享锁)
defer mu.RUnlock()
return balance
}
Balance 函数现在可以调用 RLock 和 RUlock 方法来分别获取和释放一个读锁(也称为共享锁)。Deposit 函数无需修改,它通过调用 mu.Lock 和 mu.Unlock 来分别获取一个写锁(它称为互斥锁)。
五、延迟初始化(sync.Once)
如果初始化成本比较大的话,那么将初始化延迟到需要的时候再去做就是一个比较好的选择。如果在程序启动的时候就去做这类初始化的话,会增加程序的启动时间,并且因为执行的时候可能也并不需要这些变量,所以实际上有一些浪费。
从概念上来讲,Once 包含一个布尔变量和一个互斥量,布尔变量记录初始化是否已经完成,互斥量则保护这个布尔变量和客户端的数据结构。Once 的唯一方法 Do 以初始化函数作为它的参数。
var loadIconsOnce sync.Once
var icons map[string]image.Image
func loadIcons() {
icons := make(map[string]image.Image)
icons["spades.png"] = loadIcon("spades.png")
icons["hearts.png"] = loadIcon("hearts.png")
icons["diamonds.png"] = loadIcon("diamonds.png")
icons["clubs.png"] = loadIcon("clubs.png")
}
// 并发安全
func Icon(name string) image.Image {
loadIconsOnce.Do(loadIcons)
return icons[name]
}
每次调用 Do(loadIcons) 时会先锁定互斥量并检查里边的布尔变量。在第一次调用时,这个布尔变量为假,Do 会调用 loadIcons 然后把变量设置为真。后续的调用相当于空操作,只是通过互斥量的同步来保证 loadIcons 对内存产生的效果(在这里是icons变量)对所有的 goroutine 可见。
好了,到此,基于共享变量的并发相关的内容都总结完了,你都掌握了吗?