假设我们有一个数据竞争:两个并发进程试图访问同一个内存区域,并且它们访问内存的方式不是原子的。 我们对之前的例子进行一些修改:
var data int
go func() { data++}()
if data == 0 {
fmt.Println("the value is 0.")
} else {
fmt.Printf("the value is %v.\n", data)
}
我们在这里添加了一个else子句,以便不管数据的值如何,总会得到一些输出。 请记住,正如它所写的那样,存在数据竞争,并且程序的输出将完全不确定。
程序中有一些操作需要独占访问共享资源。在这个例子中,我们找到三处:
- goroutine正在增加数据变量。
- if语句,它检查数据的值是否为0。
- fmt.Printf语句,用于检索输出数据的值。
有很多方法可以保护这些访问,Go有很好的方式来处理这个问题,解决这个问题的方法之一是让这些操作同步访问内存。 让我们看看该怎样做到这一点。
下面的代码不是Go的惯用法(我不建议你像这样解决数据竞争问题),但它很简单地演示了内存访问同步。如果这个例子中的任何类型,函数或方法对你来说都很陌生,那没问题。跟踪注释,关注同步访问内存的概念就行。
var memoryAccess sync.Mutex //1
var value int
go func() {
memoryAccess.Lock() //2
value++
memoryAccess.Unlock() //3
}()
memoryAccess.Lock() //4
if value == 0 {
fmt.Printf("the value is %v.\n", value)
} else {
fmt.Printf("the value is %v.\n", value)
}
memoryAccess.Unlock() //5
- 这里我们添加一个变量,它允许我们的代码同步对数据变量内存的访问。第三章的sync包会介绍sync.Mutex类型的细节。
- 在这里我们声明,除非解锁,否则我们的goroutine应该独占访问此内存。
- 在这里,我们声明这个对该内存的访问已经完成了。
- 在这里,我们再次声明接下来的条件语句应该独占访问数据变量的内存。
- 在这里,我们声明对内存的访问已经完成。
在这个例子中,我们为开发者制定了一个约定。任何时候开发人员都想访问data变量的内存,必须首先调用Lock,当完成访问操作时,必须调用Unlock。这两个语句之间的代码可以假定它拥有对数据的独占访问权; 我们已经成功地同步了对内存的访问。注意,如果开发者不遵循这个约定,我们就没有保证独占访问的权利!
你可能已经注意到,虽然我们已经解决了数据竞争,但我们并没有真正解决竞争条件!这个程序的操作顺序仍然不确定。 我们刚刚只是缩小了非确定性的范围。在这个例子中,仍然不确定goroutine是否会先执行,或者我们的if和else块是否都会执行。 稍后,我们将探索正确解决这类问题的工具。
从表面上看,这似乎很简单:如果你发现你有这样的需求,添加点来同步访问内存! 很简单,对吧?
但是!
确实,你可以通过同步访问内存来解决一些问题,但正如我们刚刚看到的,它不会自动解决数据竞争或逻辑正确性问题。 此外,它还可能导致维护和性能问题。
请注意,之前我们提到已经创建了一个声明需要对某些内存进行独占访问的约定。约定本身是没问题的,但实际开发中我们总是会丢三落四。更别说开发组里总会有这样或那样不遵守约定的人。值得庆幸的是,随后我们还将介绍一些更有效的方法。
以这种方式同步对内存的访问会导致性能下降。每次我们执行其中一项操作时,程序会暂停一段时间。 这带来了两个问题:
- 加锁的程序部分是否重复进入和退出?
- 加锁的程序对内存占用到底有多大?
要说清这两个问题简直是门艺术。
同步对内存的访问也与其他并发建模存在关联,我们将在下一节讨论这些问题。