6.2 同步

创建一个协程没有什么难度,并且启动很多协程开销也不大。但是,并发执行的代码需要协同。为了帮助我们解决这个问题,go提供了通道(channels)。在学习通道之前,我认为有必要先学习了并发编程的基本知识。

在编写并发执行的代码时,你需要特别的关注在哪里和如何读写一个值。出于某些原因,例如没有垃圾回收的语言,需要你从一个新的角度去考虑你的数据,总是警惕着可能存在的危险。例如:

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. var counter = 0
  7. func main() {
  8. for i := 0; i < 2; i++ {
  9. go incr()
  10. }
  11. time.Sleep(time.Millisecond * 10)
  12. }
  13. func incr() {
  14. counter++
  15. fmt.Println(counter)
  16. }

你认为会输出什么?

如果你觉得输出是12,不能说你对或者错。如果你运行上面的代码,确实如此。你很有可能得到那样的输出。但是,实际上这个输出是不确定的。为什么?因为我们可能有多个(这个例子中是2个)go协程同时写同一个变量counter。或者更糟的情况是一个协程正在读counter,而另一个协程正在写counter

这确实危险吗?绝对是的。counter++似乎看起来只是一行简单的代码,但是实际上它被拆分为很多汇编指令,具体依赖于你运行的软件和硬件平台。在上面的例子中,确实在大多数情况下运行良好。然而,另外一个可能的结果是counter等于0 时被2个协程同时读取,那么你将得到一个输出是1,1。还有更坏的结果,例如系统崩溃或者得到一个任意值然后自增。

在并发程序中,如果想安全的操作一个变量,唯一的手段就是读取该变量。你可以有任意多的程序去读,但是写必须是同步的。这里有几种方式实现,包括使用依赖于特殊cpu架构的一些真正的原子操作。然而,大多数时候都是使用一个互斥锁:

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. "time"
  6. )
  7. var (
  8. counter = 0
  9. lock sync.Mutex
  10. )
  11. func main() {
  12. for i := 0; i < 2; i++ {
  13. go incr()
  14. }
  15. time.Sleep(time.Millisecond * 10)
  16. }
  17. func incr() {
  18. lock.Lock()
  19. defer lock.Unlock()
  20. counter++
  21. fmt.Println(counter)
  22. }

互斥锁可以使你按顺序访问代码。因为sync.Mutex默认值是没有锁的,所以我们简单的定义了一个锁lock sync.Mutex

看起来似乎很简单?上面的例子带有欺骗性。当做并发编程时会发现一些列很严重的bug。首先,那些代码需要被保护一直都不是容易发现。虽然它可能是想使用一个低级锁(这个锁涉及了很多代码),这些潜在出错的地方是我们做并发编程首先要去考虑的。我们常常想要精确的锁,或者我们最终由一个10车道的高速突然转变成一个单车道道路。

另外一个问题是如何处理死锁。当使用一个锁时,这没有问题,但是如果你在代码中使用2个或者更多的锁,很容易出现一种危险的情况,即协程A拥有锁lockA,想去访问锁lockB,同时协程B拥有lockB并需要访问锁lockA

实际上使用一个锁也有可能发生死锁问题,即当我们忘记释放它时。但是这和多个锁引起的死锁为比起来,危害性不大(因为这真的很难发现),但是当你试着运行下面代码时,你可以看见发生了什么:

  1. package main
  2. import (
  3. "sync"
  4. "time"
  5. )
  6. var (
  7. lock sync.Mutex
  8. )
  9. func main() {
  10. go func() { lock.Lock() }()
  11. time.Sleep(time.Millisecond * 10)
  12. lock.Lock()
  13. }

迄今为止有很多并发编程我们都还没用见过。首先,由于我们可以同时有多个读操作,有一种常见的锁叫读写锁。它主要提供2中锁功能:一个锁定读和一个锁定写。在go语言中,sync.RWMutex就是这种锁。另外sync.Mutex结构不但提供了LockUnlock方法,也提供了RLockRLock方法,这里的R代表Read。虽然读写锁很常用,但是他们也给开发者带来一些额外的负担:我们不但要关注我们正在访问的数据,而且也要关注如何访问。

此外,部分并发编程不只是通过为数不多代码按顺序的访问变量,也需要协调多个go协程。例如,休眠10毫秒不是一种优雅的方法。如果一个go协程消耗的时间不止10毫秒呢?如果go协程消耗少于10毫秒,我们只是浪费了cpu?又或者可以等待go协程运行完毕,我们告诉另外一个go协程:嗨,我有一些新数据给你处理?

所有的这些事在没有通道(channels)的情况下都是可以实现的。当然,对于更简单的例子,我认为你应该使用基本的功能例如sync.Mutexsync.RWMutex。但是在下一节我们将看到,通道的主要目的是为了使并发编程更简洁和不易出错。

链接