golang[64]-同步和锁

Race Conditions

在某些情况下,如果并发的情况操作同一个变量可以会出现问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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 }


考虑下面的情况:

// Alice:
go func() {
bank.Deposit(200) // A1
fmt.Println("=", bank.Balance()) // A2
}()

// Bob:
go bank.Deposit(100) // B

//会发生非常严重单位问题,不同的顺序可能会导致不同的结果:

// 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"

如果说第3中还能够保证金额的话,第4种可能性连金额都有问题
// Data race
// 0
// A1r 0 ... = balance + amount
// B 100
// A1w 200 balance = ...
// A2 "= 200"

定义

A data race occurs whenever two goroutines access the same variable concurrently and at least one of the accesses is a write. It
follows from this definition that there are three ways to avoid a data race.

解决办法

1、不写数据,只读取数据
2、通过通道来通信,修改只限制在唯一的协程中。或者通过管道的方式,将数据限制在唯一的管道中。
3、互斥锁

互斥锁

第一种方式是通过1个buffle的管道,来达到互斥的目的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package bank

//!+
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

package bank

//!+
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
}

最好是使用defer,延迟释放

1
2
3
4
5
func Balance() int {
mu.Lock()
defer mu.Unlock()
return balance
}

只读锁

只能读,相对于互斥锁,速度更快。

1
2
3
4
5
6
7
var mu sync.RWMutex
var balance int
func Balance() int {
mu.RLock() // readers lock
defer mu.RUnlock()
return balance
}

内存同步

你可能比较纠结为什么Balance方法需要用到互斥条件,无论是基于channel还是基于互斥量。毕竟和存款不一样,它只由一个简单的操作组成,所以不会碰到其它goroutine在其执行"中"执行其它的逻辑的风险。这里使用mutex有两方面考虑。第一Balance不会在其它操作比如Withdraw“中间”执行。第二(更重要)的是"同步"不仅仅是一堆goroutine执行顺序的问题;同样也会涉及到内存的问题。
在现代计算机中可能会有一堆处理器,每一个都会有其本地缓存(local cache)。为了效率,对内存的写入一般会在每一个处理器中缓冲,并在必要时一起flush到主存。这种情况下这些数据可能会以与当初goroutine写入顺序不同的顺序被提交到主存。像channel通信或者互斥量操作这样的原语会使处理器将其聚集的写入flush并commit,这样goroutine在某个时间点上的执行结果才能被其它处理器上运行的goroutine得到。
考虑一下下面代码片段的可能输出:
var x, y int
go func() {
x = 1 // A1
fmt.Print(“y:”, y, " ") // A2
}()
go func() {
y = 1 // B1
fmt.Print(“x:”, x, " ") // B2
}()
因为两个goroutine是并发执行,并且访问共享变量时也没有互斥,会有数据竞争,所以程序的运行结果没法预测的话也请不要惊讶。我们可能希望它能够打印出下面这四种结果中的一种,相当于几种不同的交错执行时的情况:
y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1
第四行可以被解释为执行顺序A1,B1,A2,B2或者B1,A1,A2,B2的执行结果。然而实际的运行时还是有些情况让我们有点惊讶:
x:0 y:0
y:0 x:0
但是根据所使用的编译器,CPU,或者其它很多影响因子,这两种情况也是有可能发生的。那么这两种情况要怎么解释呢?
在一个独立的goroutine中,每一个语句的执行顺序是可以被保证的;也就是说goroutine是顺序连贯的。但是在不使用channel且不使用mutex这样的显式同步操作时,我们就没法保证事件在不同的goroutine中看到的执行顺序是一致的了。尽管goroutine A中一定需要观察到x=1执行成功之后才会去读取y,但它没法确保自己观察得到goroutine B中对y的写入,所以A还可能会打印出y的一个旧版的值。
尽管去理解并发的一种尝试是去将其运行理解为不同goroutine语句的交错执行,但看看上面的例子,这已经不是现代的编译器和cpu的工作方式了。因为赋值和打印指向不同的变量,编译器可能会断定两条语句的顺序不会影响执行结果,并且会交换两个语句的执行顺序。如果两个goroutine在不同的CPU上执行,每一个核心有自己的缓存,这样一个goroutine的写入对于其它goroutine的Print,在主存同步之前就是不可见的了。
所有并发的问题都可以用一致的、简单的旣定的模式来规避。所以可能的话,将变量限定在goroutine内部;如果是多个goroutine都需要访问的变量,使用互斥条件来访问。

竞争条件检测

1
2
3
4
5
卽使我们小心到不能再小心,但在并发程序中犯错还是太容易了。幸运的是,Go的runtime和工具链为我们装备了一个复杂但好用的动态分析工具,竞争检查器(the race detector)。
只要在go build,go run或者go test命令后面加上-race的flag,就会使编译器创建一个你的应用的“修改”版或者一个附带了能够记录所有运行期对共享变量访问工具的test ,并且会记录下每一个读或者写共享变量的goroutine的身份信息。另外,修改版的程序会记录下所有的同步事件,比如go语句,channel操作,以及对(*sync.Mutex).Lock,(*sync.WaitGroup).Wait等等的调用。(完整的同步事件集合是在The Go Memory Model文档中有说明,该文档是和语言文档放在一起的。译注:https://golang.org/ref/mem )
竞争检查器会检查这些事件,会寻找在哪一个goroutine中出现了这样的case,例如其读或者写了一个共享变量,这个共享变量是被另一个goroutine在没有进行干预同步操作便直接写入的。这种情况也就表明了是对一个共享变量的并发访问,卽数据竞争。这个工具会打印一份报告,内容包含变量身份,读取和写入的goroutine中活跃的函数的调用栈。这些信息在定位问题时通常很有用。9.7节中会有一个竞争检查器的实战样例。
竞争检查器会报告所有的已经发生的数据竞争。然而,它只能检测到运行时的竞争条件;并不能证明之后不会发生数据竞争。所以为了使结果尽量正确,请保证你的测试并发地覆盖到了你到包。
由于需要额外的记录,因此构建时加了竞争检测的程序跑起来会慢一些,且需要更大的内存,卽时是这样,这些代价对于很多生产环境的工作来说还是可以接受的。对于一些偶发的竞争条件来说,让竞争检查器来干活可以节省无数日夜的debugging。(译注:多少服务端C和C艹程序员为此尽折腰)