竞态
竞态是指在多个 goroutine 按某些交错顺序执行时程序无法给出正确的结果。
一种避免数据竞态的方法是避免从多个 goroutine 访问同一个变量。由于其他 goroutine 无法直接访问相关变量,因此它们就必须使用通道来向受阻 goroutine 发送查询请求或者更新变量。这也是 Go 箴言的含义:不用通过共享内存来通信,而应该通过通信来共享内存。
另一种避免数据竞态的方法是允许多个 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 不能获取锁。
考虑如下Withdraw 函数。当成功时,余额减少了指定的数量,并且返回 true,但如果余额不足,无法完成交易,Withdraw 恢复余额并且返回 false.
// 注意: 不是原子操作
func Withdraw(amount int) bool {
Deposit(-amount)
if Balance() < 0 {
Deposit(amount)
return false // 余额不足
}
return true
}
这个函数最终能给出正确的答案,但它有一个不良的辅作用。在尝试进行超额提款时,在某个瞬间余额会降到0以下。这有可能会导致一个小额的取款会不合逻辑地拒绝掉。索引当 Bob 尝试购买一辆跑车时,却会导致 Alice 无法支付早上的咖啡。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 会一直被卡住。
一个常见的解决方案是把 Deposit 这样的函数拆分为两部分:一个不导出的函数 deposit ,它假定已经获得互斥锁,并完成实际的业务逻辑;以及一个导出的函数 Deposit,它用来获取锁并调用 deposit。这样我们可以用 deposit 实现 Withdraw:
package main
import "sync"
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
package main
import "image"
var icons map[string]image.Image
func loadIcons() {
icons = map[string]image.Image{
"spades.png": loadIcon("spades.png"),
"hearts.png": loadIcon("hearts.png"),
"diamonds.png": loadIcon("diamonds.png"),
"clubs.png": loadIcon("clubs.png"),
}
}
// 注意:并发不安全
func Icon(name string) image.Image {
if icons[name] == nil {
loadIcons()
}
return icons[name]
}
Icon 包含多个步骤:检测 icons 是否为空,再加载图标,最后更新 cions 为一个非 nil 值。直觉可能告诉你,竞态带来的最严重问题可能就是 loadIcons 函数会被调用多遍。当第一个 goroutine 整忙于加载图标时,其他 goroutine 进去 Icon 函数,会发现 icons 仍然是 nil,索引仍然会调用 loadIcons。
在缺乏显示同步的情况下,编译器和 CPU 在能保证每个 goroutine 都满足串行一致性的基础上可以自由地重排访问内存的顺序。loadIcons 一个可能的语句重排结果如下所示。它在填充数据之前把一个空 map 赋给 icons:
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")
}
因此,一个 goroutine 发现 icons 不是 nil 并不意味着变量的初始化肯定已经完成。
保证所有 goroutine 都能观察到 loadIcons 效果最简单的正确方法就是用一个互斥锁来做同步:
var mu sync.Mutex
var icons map[string]image.Image
// 并发安全
func Icon(name string) image.Image {
mu.Lock()
defer mu.Unlock()
if icons == nil {
loadIcons()
}
return icons[name]
}
采用互斥访问 icons 的额外代价是两个 goroutine 不能并发访问这个变量,即使在变量已经完全初始化且不再改变的情况下,也会造成这个结果,使用一个并发读的锁就可以改善这个问题:
var mu sync.RWMutex
var icons map[string]image.Image
// 并发安全
func Icon(name string) image.Image {
mu.RLock()
if icons != nil {
icon := icons[name]
mu.RUnlock()
return icon
}
mu.RUnlock()
// 获取互斥锁
mu.Lock()
if icons == nil { // 注意必须重新检查 nil 值
icons = loadIcons()
}
icon := icons[name]
mu.Unlock()
return icon
}
这里有两个临界区域。goroutine 首先获取一个读锁,查阅 map ,然后释放这个读锁。如果条目能找到(常见情况),就返回它。如果条目没找到,goroutine 再获取一个写锁。
上面的模式具有很好的并发性,但它更复杂并且容易出错。幸运的是,sync 包提供了针对一次性初始化问题的特化解决方案: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 可见。
竞态检测器
即使以最大努力的仔细,仍然很容易在并发上犯错误。幸运的是,Go 语言运行时和工具链装备了一个精致并易于使用的动态分析工具:竞态检测器。
简单地把 -race 命令行参数加到 go build、go run、go test 命令里边即可使用该功能。
goroutine 调度
OS 线程由OS 内核来调度。每隔几毫秒,一个硬件时钟中断发到CPU,CPU 调用一个叫调度器的内核函数。这个函数暂停当前正在运行的线程,把它的寄存信息保存到内存,查看线程列表并决定接下来运行哪一个线程,再从内存恢复线程的注册表信息,最后继续执行选中的线程。因为OS 线程由内核来调度,所以控制权限从一个线程到另外一个线程需要一个完整的上下文切换:即保存一个线程的状态到内存,再恢复另外一个线程的状态,最后再更新调度器的数据结构。
Go 运行时包含一个自己的调度器,这个调度器使用一个称为 m:n 调度的技术(因为它可以复用/ 调度 m 个 goroutine 到 n 个OS 线程)。
与操作系统的线程调度器不同的是,Go 调度器不是由硬件时钟来定期触发的,而是由特定的Go 语言结构来触发的。比如当一个 goroutine 调用 time.Sleep 或被通道阻塞或对互斥量操作时,调度器就会将这个 goroutine 设为休眠模式,并运行其他 goroutine 直到前一个 goroutine 可重新唤醒为止。因为它不需要切换到内核语境,索引调用一个 goroutine 比调度一个线程成本低很多。
GOMAXPROCS
Go 调度器使用 GOMAXPROCS 参数来确定需要使用多少个OS 线程来同时执行Go 代码。默认值是机器上的CPU 数量,所以在一个有8个CPU 的机器上,调度器会把Go 代码同时调度到 8 个 os 线程上。(GOMAXPROCS 是 m:n 调度中的 n。)正在休眠或者正被通道通信阻塞的goroutine 不需要占用线程。