且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

Go语言与数据库开发:01-08

更新时间:2022-06-07 05:55:27

基于共享变量的并发

.竞争条件

在一个线性(就是说只有一个goroutine的)的程序中,程序的执行顺序只由程序的逻辑来决定。
例如,我们有一段语句序列,第一个在第二个之前(废话),以此类推。在有两个或更多
goroutine的程序中,每一个goroutine内的语句也是按照既定的顺序去执行的,但是一般情况
下我们没法去知道分别位于两个goroutine的事件x和y的执行顺序,x是在y之前还是之后还是
同时发生是没法判断的。当我们能够没有办法自信地确认一个事件是在另一个事件的前面或
者后面发生的话,就说明x和y这两个事件是并发的。
考虑一下,一个函数在线性程序中可以正确地工作。如果在并发的情况下,这个函数依然可
以正确地工作的话,那么我们就说这个函数是并发安全的,并发安全的函数不需要额外的同
步工作。我们可以把这个概念概括为一个特定类型的一些方法和操作函数,如果这个类型是
并发安全的话,那么所有它的访问方法和操作就都是并发安全的。
在一个程序中有非并发安全的类型的情况下,我们依然可以使这个程序并发安全。确实,并
发安全的类型是例外,而不是规则,所以只有当文档中明确地说明了其是并发安全的情况
下,你才可以并发地去访问它。我们会避免并发访问大多数的类型,无论是将变量局限在单
一的一个goroutine内还是用互斥条件维持更高级别的不变性都是为了这个目的。我们会在本
章中说明这些术语。
相反,导出包级别的函数一般情况下都是并发安全的。由于package级的变量没法被限制在单
一的gorouine,所以修改这些变量“必须”使用互斥条件。
一个函数在并发调用时没法工作的原因太多了,比如死锁(deadlock)、活锁(livelock)和饿死
(resource starvation)。我们没有空去讨论所有的问题,这里我们只聚焦在竞争条件上。
竞争条件指的是程序在多个goroutine交叉执行操作时,没有给出正确的结果。竞争条件是很
恶劣的一种场景,因为这种问题会一直潜伏在你的程序里,然后在非常少见的时候蹦出来,
或许只是会在很大的负载时才会发生,又或许是会在使用了某一个编译器、某一种平台或者
某一种架构的时候才会出现。这些使得竞争条件带来的问题非常难以复现而且难以分析诊
断。
传统上经常用经济损失来为竞争条件做比喻,所以我们来看一个简单的银行账户程序。

// Package bank implements a bank with only one account.
package bank
var balance int
func Deposit(amount int) { balance = balance + amount }
func Balance() int { return balance }
(当然我们也可以把Deposit存款函数写成balance += amount,这种形式也是等价的,不过长
一些的形式解释起来更方便一些。)

对于这个具体的程序而言,我们可以瞅一眼各种存款和查余额的顺序调用,都能给出正确的
结果。也就是说,Balance函数会给出之前的所有存入的额度之和。然而,当我们并发地而不
是顺序地调用这些函数的话,Balance就再也没办法保证结果正确了。考虑一下下面的两个
goroutine,其代表了一个银行联合账户的两笔交易:
// Alice:
go func() {
bank.Deposit(200) // A1
fmt.Println("=", bank.Balance()) // A2
}()
// Bob:
go bank.Deposit(100) // B
Alice存了$200,然后检查她的余额,同时Bob存了$100。因为A1和A2是和B并发执行的,我
们没法预测他们发生的先后顺序。直观地来看的话,我们会认为其执行顺序只有三种可能
性:“Alice先”,“Bob先”以及“Alice/Bob/Alice”交错执行。下面的表格会展示经过每一步骤后
balance变量的值。引号里的字符串表示余额单。
Alice first Bob first Alice/Bob/Alice
0 0 0
A1 200 B 100 A1 200
A2 "=200" A1 300 B 300
B 300 A2 "=300" A2 "=300"
所有情况下最终的余额都是$300。唯一的变数是Alice的余额单是否包含了Bob交易,不过无
论怎么着客户都不会在意。
但是事实是上面的直觉推断是错误的。第四种可能的结果是事实存在的,这种情况下Bob的存
款会在Alice存款操作中间,在余额被读到(balance + amount)之后,在余额被更新之前
(balance = ...),这样会导致Bob的交易丢失。而这是因为Alice的存款操作A1实际上是两个操
作的一个序列,读取然后写;可以称之为A1r和A1w。下面是交叉时产生的问题:
Data race
0
A1r 0 ... = balance + amount
B 100
A1w 200 balance = ...
A2 "= 200"
在A1r之后,balance + amount会被计算为200,所以这是A1w会写入的值,并不受其它存款
操作的干预。最终的余额是$200。银行的账户上的资产比Bob实际的资产多了$100。(译注:
因为丢失了Bob的存款操作,所以其实是说Bob的钱丢了)

这个程序包含了一个特定的竞争条件,叫作数据竞争。无论任何时候,只要有两个goroutine
并发访问同一变量,且至少其中的一个是写操作的时候就会发生数据竞争。
如果数据竞争的对象是一个比一个机器字(译注:32位机器上一个字=4个字节)更大的类型时,
事情就变得更麻烦了,比如interface,string或者slice类型都是如此。下面的代码会并发地更
新两个不同长度的slice:
var x []int
go func() { x = make([]int, 10) }()
go func() { x = make([]int, 1000000) }()
x[999999] = 1 // NOTE: undefined behavior; memory corruption possible!
最后一个语句中的x的值是未定义的;其可能是nil,或者也可能是一个长度为10的slice,也可
能是一个程度为1,000,000的slice。但是回忆一下slice的三个组成部分:指针(pointer)、长度
(length)和容量(capacity)。如果指针是从第一个make调用来,而长度从第二个make来,x就
变成了一个混合体,一个自称长度为1,000,000但实际上内部只有10个元素的slice。这样导致
的结果是存储999,999元素的位置会碰撞一个遥远的内存位置,这种情况下难以对值进行预
测,而且定位和debug也会变成噩梦。这种语义雷区被称为未定义行为,对C程序员来说应该
很熟悉;幸运的是在Go语言里造成的麻烦要比C里小得多。
尽管并发程序的概念让我们知道并发并不是简单的语句交叉执行。我们将会在9.4节中看到,
数据竞争可能会有奇怪的结果。许多程序员,甚至一些非常聪明的人也还是会偶尔提出一些
理由来允许数据竞争,比如:“互斥条件代价太高”,“这个逻辑只是用来做logging”,“我不介意
丢失一些消息”等等。因为在他们的编译器或者平台上很少遇到问题,可能给了他们错误的信
心。一个好的经验法则是根本就没有什么所谓的良性数据竞争。所以我们一定要避免数据竞
争,那么在我们的程序中要如何做到呢?
我们来重复一下数据竞争的定义,因为实在太重要了:数据竞争会在两个以上的goroutine并
发访问相同的变量且至少其中一个为写操作时发生。根据上述定义,有三种方式可以避免数
据竞争:
第一种方法是不要去写变量。考虑一下下面的map,会被“懒”填充,也就是说在每个key被第
一次请求到的时候才会去填值。如果Icon是被顺序调用的话,这个程序会工作很正常,但如果
Icon被并发调用,那么对于这个map来说就会存在数据竞争。

var icons = make(map[string]image.Image)
func loadIcon(name string) image.Image
// NOTE: not concurrency-safe!
func Icon(name string) image.Image {
icon, ok := icons[name]
if !ok {
icon = loadIcon(name)
icons[name] = icon
}
return icon
}
反之,如果我们在创建goroutine之前的初始化阶段,就初始化了map中的所有条目并且再也
不去修改它们,那么任意数量的goroutine并发访问Icon都是安全的,因为每一个goroutine都
只是去读取而已。

var 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"),
}
// Concurrency-safe.
func Icon(name string) image.Image { return icons[name] }
上面的例子里icons变量在包初始化阶段就已经被赋值了,包的初始化是在程序main函数开始
执行之前就完成了的。只要初始化完成了,icons就再也不会修改的或者不变量是本来就并发
安全的,这种变量不需要进行同步。不过显然我们没法用这种方法,因为update操作是必要
的操作,尤其对于银行账户来说。
第二种避免数据竞争的方法是,避免从多个goroutine访问变量。这也是前一章中大多数程序
所采用的方法。例如前面的并发web爬虫(§8.6)的main goroutine是唯一一个能够访问seen
map的goroutine,而聊天服务器(§8.10)中的broadcaster goroutine是唯一一个能够访问clients
map的goroutine。这些变量都被限定在了一个单独的goroutine中。
由于其它的goroutine不能够直接访问变量,它们只能使用一个channel来发送给指定的
goroutine请求来查询更新变量。这也就是Go的口头禅“不要使用共享数据来通信;使用通信来
共享数据”。一个提供对一个指定的变量通过cahnnel来请求的goroutine叫做这个变量的监控
(monitor)goroutine。例如broadcaster goroutine会监控(monitor)clients map的全部访问。