同步
创建一个协程是微不足道的, 它们开销很小我们可以启动很多; 但是,需要协调并发代码。为了解决这个问题, Go 提供了 通道
。 在我们学习 通道
之前,我认为了解并发编程的基础知识非常重要。
编写并发代码要求您特别注意在哪里读取和写入一个值。 在某些方面, 例如没有垃圾回收的语言 — 它需要您从一个新的角度去考虑您的数据,始终警惕着可能存在的危险。 例如:
package main
import (
"fmt"
"time"
)
var counter = 0
func main() {
for i := 0; i < 20; i++ {
go incr()
}
time.Sleep(time.Millisecond * 10)
}
func incr() {
counter++
fmt.Println(counter)
}
你觉得将会输出什么呢?
如果你认为输出的是 1, 2, ... 20
这既不对也没错。如果你运行了以上的代码确实可能得到这个输出。可是,这个操作就很让人懵逼的。 啥?因为我们可能有多个 (这个情况下两个) 协程 同时写入一个相同变量 counter
。或者,同样糟糕的是,一个协程要读取 counter
时,另一个协程正在写入。
这个真的很危险吗?当然啦! counter++
看起来可能是一行很简单的代码,但它是实际上被拆分为多个汇编语句 — 确切的性质依赖于你跑程序的平台。如果你运行这个例子,你将经常看到那些数字是以一种乱七八糟的顺序打印的,亦或数字是重复的/丢失的。别着急还会有更糟糕的情况, 比方说系统崩溃或者访问并增加任意区块的数据!
从变量中读取变量是唯一安全的并发处理变量的方式。 你可以有想要多少就多少的读取者, 但是写操作必须要得同步。 有太多的方法可以做到这个了,包括使用一些依赖于特殊的 CPU 指令集的真原子操作。然而, 常用的操作还是使用互斥量(译者注:mutex):
package main
import (
"fmt"
"time"
"sync"
)
var (
counter = 0
lock sync.Mutex
)
func main() {
for i := 0; i < 20; 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。 首先,并不是经常能很明显知道什么代码需要保护。使用这样粗糙的锁操作(覆盖着大量代码的锁操作)确实很诱人,这就违背了我们当初进行并发编程的初心了。 我们肯定是需要个优雅的锁操作; 否则,我们最终会把多条快速通道走成单车道的。
另外一个问题是与死锁有关。 使用单个锁时,这没有问题,但是如果你在代码中使用两个或者更多的锁,很容易出现一种危险的情况,当协程A拥有锁 lockA ,想去访问锁 lockB ,同时协程B拥有锁 lockB 并需要访问锁 lockA 。
实际上我们使用一个锁时也有可能发生死锁的问题,就是当我们忘记释放它时。 但是这和多个锁引起的死锁行为相比起来,这并不像多锁死锁那样危险(因为这真的 很难发现),当你试着运行下面的代码时,您可以看见发生了什么:
package main
import (
"time"
"sync"
)
var (
lock sync.Mutex
)
func main() {
go func() { lock.Lock() }()
time.Sleep(time.Millisecond * 10)
lock.Lock()
}
到现在为止还有很多并发编程我们没有看到过。 首先,有一个常见的锁叫读写互斥锁。它主要提供了两种锁功能: 一个锁定读取和一个锁定写入。它的区别是允许多个同时读取,同时确保写入是独占的。在 Go 中, sync.RWMutex
就是这种锁。另外 sync.Mutex
结构不但提供了Lock
和 Unlock
方法 ,也提供了RLock
和 RUnlock
方法;其中 R
代表 Read.。虽然读写锁很常用,它也给开发人员带来了额外的负担:我们不但要关注我们正在访问的数据,还要注意如何访问。
此外,部分并发编程不只是通过为数不多的代码按顺序的访问变量; 它也需要协调多个协程。 例如,休眠10毫秒并不是一个特别优雅的解决方案。如果一个协程消耗的时间需要超过10毫秒怎么办?如果协程消耗更少的时间而我们浪费周期怎么办?又或者可以等待协程运行完毕, 我们想另外一个协程 嗨, 我有新的数据需要你处理?
这些事在没有 通道
的情况下都是可以完成的。当然对于更简单的情况,我相信你 应该 使用基本的功能比如 sync.Mutex
和 sync.RWMutex
, 但正如我们将会在下一节中看到的那样, 通道
旨在让并发编程更简洁和不容易出错。