6.2 同步
创建一个协程没有什么难度,并且启动很多协程开销也不大。但是,并发执行的代码需要协同。为了帮助我们解决这个问题,go提供了通道(channels)。在学习通道之前,我认为有必要先学习了并发编程的基本知识。
在编写并发执行的代码时,你需要特别的关注在哪里和如何读写一个值。出于某些原因,例如没有垃圾回收的语言,需要你从一个新的角度去考虑你的数据,总是警惕着可能存在的危险。例如:
package main
import (
"fmt"
"time"
)
var counter = 0
func main() {
for i := 0; i < 2; i++ {
go incr()
}
time.Sleep(time.Millisecond * 10)
}
func incr() {
counter++
fmt.Println(counter)
}
你认为会输出什么?
如果你觉得输出是1
和2
,不能说你对或者错。如果你运行上面的代码,确实如此。你很有可能得到那样的输出。但是,实际上这个输出是不确定的。为什么?因为我们可能有多个(这个例子中是2个)go协程同时写同一个变量counter
。或者更糟的情况是一个协程正在读counter
,而另一个协程正在写counter
。
这确实危险吗?绝对是的。counter++
似乎看起来只是一行简单的代码,但是实际上它被拆分为很多汇编指令,具体依赖于你运行的软件和硬件平台。在上面的例子中,确实在大多数情况下运行良好。然而,另外一个可能的结果是counter
等于0 时被2个协程同时读取,那么你将得到一个输出是1,1
。还有更坏的结果,例如系统崩溃或者得到一个任意值然后自增。
在并发程序中,如果想安全的操作一个变量,唯一的手段就是读取该变量。你可以有任意多的程序去读,但是写必须是同步的。这里有几种方式实现,包括使用依赖于特殊cpu架构的一些真正的原子操作。然而,大多数时候都是使用一个互斥锁:
package main
import (
"fmt"
"sync"
"time"
)
var (
counter = 0
lock sync.Mutex
)
func main() {
for i := 0; i < 2; i++ {
go incr()
}
time.Sleep(time.Millisecond * 10)
}
func incr() {
lock.Lock()
defer lock.Unlock()
counter++
fmt.Println(counter)
}
互斥锁可以使你按顺序访问代码。因为sync.Mutex
默认值是没有锁的,所以我们简单的定义了一个锁lock sync.Mutex
。
看起来似乎很简单?上面的例子带有欺骗性。当做并发编程时会发现一些列很严重的bug。首先,那些代码需要被保护一直都不是容易发现。虽然它可能是想使用一个低级锁(这个锁涉及了很多代码),这些潜在出错的地方是我们做并发编程首先要去考虑的。我们常常想要精确的锁,或者我们最终由一个10车道的高速突然转变成一个单车道道路。
另外一个问题是如何处理死锁。当使用一个锁时,这没有问题,但是如果你在代码中使用2个或者更多的锁,很容易出现一种危险的情况,即协程A拥有锁lockA
,想去访问锁lockB
,同时协程B拥有lockB
并需要访问锁lockA
。
实际上使用一个锁也有可能发生死锁问题,即当我们忘记释放它时。但是这和多个锁引起的死锁为比起来,危害性不大(因为这真的很难发现),但是当你试着运行下面代码时,你可以看见发生了什么:
package main
import (
"sync"
"time"
)
var (
lock sync.Mutex
)
func main() {
go func() { lock.Lock() }()
time.Sleep(time.Millisecond * 10)
lock.Lock()
}
迄今为止有很多并发编程我们都还没用见过。首先,由于我们可以同时有多个读操作,有一种常见的锁叫读写锁。它主要提供2中锁功能:一个锁定读和一个锁定写。在go语言中,sync.RWMutex
就是这种锁。另外sync.Mutex
结构不但提供了Lock
和Unlock
方法,也提供了RLock
和RLock
方法,这里的R
代表Read
。虽然读写锁很常用,但是他们也给开发者带来一些额外的负担:我们不但要关注我们正在访问的数据,而且也要关注如何访问。
此外,部分并发编程不只是通过为数不多代码按顺序的访问变量,也需要协调多个go协程。例如,休眠10毫秒不是一种优雅的方法。如果一个go协程消耗的时间不止10毫秒呢?如果go协程消耗少于10毫秒,我们只是浪费了cpu?又或者可以等待go协程运行完毕,我们告诉另外一个go协程:嗨,我有一些新数据给你处理?
所有的这些事在没有通道(channels)的情况下都是可以实现的。当然,对于更简单的例子,我认为你应该使用基本的功能例如sync.Mutex
和sync.RWMutex
。但是在下一节我们将看到,通道的主要目的是为了使并发编程更简洁和不易出错。