Go语言中常见的并发模式


Go语言中常见的并发模式

文章插图
 
Go语言最吸引人的地方是它内建的并发支持 。Go语言并发体系的理论是C.A.R Hoare在1978年提出的通信顺序进程(Communicating Sequential Process,CSP) 。CSP有着精确的数学模型,并实际应用在了Hoare参与设计的T9000通用计算机上 。从Newsqueak、Alef、Limbo到现在的Go语言,对于对CSP有着20多年实战经验的Rob Pike来说,他更关注的是将CSP应用在通用编程语言上产生的潜力 。作为Go并发编程核心的CSP理论的核心概念只有一个:同步通信 。关于同步通信的话题我们在前文已经讲过,本节我们将简单介绍Go语言中常见的并发模式 。
首先要明确一个概念:并发不是并行 。并发更关注的是程序的设计层面,并发的程序完全是可以顺序执行的,只有在真正的多核CPU上才可能真正地同时运行 。并行更关注的是程序的运行层面,并行一般是简单的大量重复,例如,GPU中对图像处理都会有大量的并行运算 。为了更好地编写并发程序,从设计之初Go语言就注重如何在编程语言层级上设计一个简洁安全高效的抽象模型,让程序员专注于分解问题和组合方案,而且不用被线程管理和信号互斥这些烦琐的操作分散精力 。
在并发编程中,对共享资源的正确访问需要精确地控制,在目前的绝大多数语言中,都是通过加锁等线程同步方案来解决这一困难问题,而Go语言却另辟蹊径,它将共享的值通过通道传递(实际上多个独立执行的线程很少主动共享资源) 。在任意给定的时刻,最好只有一个Goroutine能够拥有该资源 。数据竞争从设计层面上就被杜绝了 。为了提倡这种思考方式,Go语言将其并发编程哲学化为一句口号:“不要通过共享内存来通信,而应通过通信来共享内存 。”(Do not communicate by sharing memory; instead, share memory by communicating.)
这是更高层次的并发编程哲学(通过通道来传值是Go语言推荐的做法) 。虽然像引用计数这类简单的并发问题通过原子操作或互斥锁就能很好地实现,但是通过通道来控制访问能够让你写出更简洁正确的程序 。
1.6.1 并发版本的“Hello, World”先以在一个新的Goroutine中输出“你好, 世界”,main等待后台线程输出工作完成之后退出的简单的并发程序作为热身 。
并发编程的核心概念是同步通信,但是同步的方式却有多种 。先以大家熟悉的互斥量sync.Mutex来实现同步通信 。根据文档,我们不能直接对一个未加锁状态的sync.Mutex进行解锁,这会导致运行时异常 。下面这种方式并不能保证正常工作:
func main() { var mu sync.Mutex go func(){ fmt.Println("你好, 世界") mu.Lock() }() mu.Unlock()}因为mu.Lock()和mu.Unlock()并不在同一个Goroutine中,所以也就不满足顺序一致性内存模型 。同时它们也没有其他的同步事件可以参考,这两个事件不可排序也就是可以并发的 。因为可能是并发的事件,所以main()函数中的mu.Unlock()很有可能先发生,而这个时刻mu互斥对象还处于未加锁的状态,因而会导致运行时异常 。
下面是修复后的代码:
func main() { var mu sync.Mutex mu.Lock() go func(){ fmt.Println("你好, 世界") mu.Unlock() }() mu.Lock()}修复的方式是在main()函数所在线程中执行两次mu.Lock(),当第二次加锁时会因为锁已经被占用(不是递归锁)而阻塞,main()函数的阻塞状态驱动后台线程继续向前执行 。当后台线程执行到mu.Unlock()时解锁,此时打印工作已经完成了,解锁会导致main()函数中的第二个mu.Lock()阻塞状态取消,此时后台线程和主线程再没有其他的同步事件参考,它们退出的事件将是并发的:在main()函数退出导致程序退出时,后台线程可能已经退出了,也可能没有退出 。虽然无法确定两个线程退出的时间,但是打印工作是可以正确完成的 。
使用sync.Mutex互斥锁同步是比较低级的做法 。我们现在改用无缓存通道来实现同步:
func main() { done := make(chan int) go func(){ fmt.Println("你好, 世界") <-done }() done <- 1}根据Go语言内存模型规范,对于从无缓存通道进行的接收,发生在对该通道进行的发送完成之前 。因此,后台线程<-done接收操作完成之后,main线程的done <- 1发送操作才可能完成(从而退出main、退出程序),而此时打印工作已经完成了 。
上面的代码虽然可以正确同步,但是对通道的缓存大小太敏感:如果通道有缓存,就无法保证main()函数退出之前后台线程能正常打印了 。更好的做法是将通道的发送和接收方向调换一下,这样可以避免同步事件受通道缓存大小的影响:


推荐阅读