1
0
mirror of https://github.com/chai2010/advanced-go-programming-book.git synced 2025-05-24 12:32:21 +00:00
This commit is contained in:
lewgun 2018-01-04 11:18:40 +08:00 committed by GitHub
parent 08c1314806
commit e13d2ba11c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,22 +1,21 @@
# 1.6. 常见的并发模式
Go语言最吸引人的地方是它内建的并发支持。Go语言并发体系的理论是C.A.R Hoare在1978年提出的CSPCommunicating Sequential Process通讯顺序进程尽管CSP有着精确的数学模型并实际应用在Hoare实际参与设计的T9000通用计算机上。但是作为对CSP有着20多年实战经验的Rob Pike来说它更关注的始终是将CSP应用在通用编程语言上的潜力从NewSqueak、Alef、Limbo到现在的Go语言。作为Go并发编程核心的CSP理论的核心其实只有一个概念同步通信。关于同步通信的话题我们在前面一节已经讲过我们现在开始简单介绍下Go语言中常见的并发模式。
Go语言最吸引人的地方是它内建的并发支持。Go语言并发体系的理论是C.A.R Hoare在1978年提出的CSPCommunicating Sequential Process通讯顺序进程。CSP有着精确的数学模型并实际应用在Hoare实际参与设计的T9000通用计算机上。从NewSqueak、Alef、Limbo到现在的Go语言对于对CSP有着20多年实战经验的Rob Pike来说他更关注的是将CSP应用在通用编程语言上的潜力。作为Go并发编程核心的CSP理论的核心概念只有一个同步通信。关于同步通信的话题我们在前面一节已经讲过本节我们将简单介绍下Go语言中常见的并发模式。
首先要明确一个概念并发不是并行。并发更关注的是程序的设计层面并发的程序完全是可以顺序执行的只有在真正的多核CPU上才可能真正地同时运行。并行更关注的是程序的运行层面并行一般是简单的大量重复例如GPU中对图像处理都会有大量的并行运算。Go语言从开始设计开始,就围绕着如何能在编程语言的层级,对并发程序的支持设计一个简洁安全高效的抽象模型,让程序员专注于分解问题和组合方案,而且不用担心被线程管理和信号互斥这些繁琐的操作分散精力。
首先要明确一个概念并发不是并行。并发更关注的是程序的设计层面并发的程序完全是可以顺序执行的只有在真正的多核CPU上才可能真正地同时运行。并行更关注的是程序的运行层面并行一般是简单的大量重复例如GPU中对图像处理都会有大量的并行运算。Go语言从开始设计,就围绕着如何能在编程语言的层级,为更好的编写并发程序设计一个简洁安全高效的抽象模型,让程序员专注于分解问题和组合方案,而且不用被线程管理和信号互斥这些繁琐的操作分散精力。
在并发编程中,为实现对共享资源的正确访问需要精确的控制,这在多数环境下都很困难。Go语言另辟蹊径它将共享的值通过信道传递实际上多个独立执行的线程很少主动共享资源。在任意给定的时刻最好只有一个Go能够拥有该资源。数据竞争从设计层面上就被杜绝了。为了提倡这种思考方式Go语言将其并发编程哲学化为一句口号
在并发编程中,对共享资源的正确访问需要精确的控制,在目前的绝大多数语言中都是通过加锁等线程同步方案来解决这一困难问题而Go语言却另辟蹊径它将共享的值通过信道传递(实际上多个独立执行的线程很少主动共享资源)。在任意给定的时刻最好只有一个Goroutine能够拥有该资源。数据竞争从设计层面上就被杜绝了。为了提倡这种思考方式Go语言将其并发编程哲学化为一句口号
> Do not communicate by sharing memory; instead, share memory by communicating.
> 不要通过共享内存来通信,而应通过通信来共享内存。
因此通过管道来传值是推荐的做法。这是更高层次的并发编程哲学。虽然像引用计数这类低层的并发问题通过原子操作或互斥锁来很好地实现但是通过信道来控制访问能够让你写出更简洁正确的程序。这个经验虽然是从UINX中通过管道链接各个进程的经验总结而来但是UNIX的管道和CSP其实有着相同的思路。
这是更高层次的并发编程哲学(通过管道来传值是Go语言推荐的做法)。虽然像引用计数这类简章的并发问题通过原子操作或互斥锁就能很好地实现,但是通过信道来控制访问能够让你写出更简洁正确的程序。
## 并发版本的Hello world
我们在一个新的Goroutine中输出“Hello world”`main`等待后台线程输出工作完成之后退出。我们先以这个简单的并发程序作为一个热身。
我们先以在一个新的Goroutine中输出“Hello world”`main`等待后台线程输出工作完成之后退出,这样一个简单的并发程序作为热身。
并发编程的核心概念是同步通信,但是同步的方式却有多种。我们先以大家熟悉的互斥量`sync.Mutex`来实现同步通信。根据文档,我们不能直接对一个未加锁状态的`sync.Mutex`进行解锁,这会导致一个运行时异常。下面这种方式并不能保证正常工作:
并发编程的核心概念是同步通信,但是同步的方式却有多种。我们先以大家熟悉的互斥量`sync.Mutex`来实现同步通信。根据文档,我们不能直接对一个未加锁状态的`sync.Mutex`进行解锁,这会导致运行时异常。下面这种方式并不能保证正常工作:
```go
func main() {
@ -31,7 +30,7 @@ func main() {
}
```
因为`mu.Lock()``mu.Unock()`并不在同一个Goroutine中因此也不满足顺序一致性内存模型。同时它们也没有其它的同步事件可以参考,这两个事件不可排序也就是并发的。因为是并发的事件,`main`函数中的`mu.Unock()`很有可能先发生,而这个时刻`mu`互斥对象还处于未加锁的状态,从而会导致运行时异常。
因为`mu.Lock()``mu.Unock()`并不在同一个Goroutine中所以也就不满足顺序一致性内存模型。同时它们也没有其它的同步事件可以参考,这两个事件不可排序也就是可以并发的。因为可能是并发的事件,所以`main`函数中的`mu.Unock()`很有可能先发生,而这个时刻`mu`互斥对象还处于未加锁的状态,从而会导致运行时异常。
下面是修复后的代码:
@ -49,9 +48,9 @@ func main() {
}
```
修复的方式是在`main`函数所在线程中执行两次`mu.Lock()`,当第二次加锁时会因为锁已经被占用(不是递归锁)而阻塞,`main`函数的阻塞状态驱动后台线程继续向前执行。当后台线程执行到`mu.Unock()`时解锁,解锁会导致`main`函数中第二个`mu.Lock()`阻塞状态取消,但是这时已经确保打印工作完成了。但是解锁后,后台线程和主线程再没有其它的同步事件参考,它们何时退出的事件将是并发的:在`main`函数退出导致程序退出时,后台线程可能已经退出了,也可能没有退出。虽然无法确定两个线程退出的时间,但是打印工作是可以正确完成的。
修复的方式是在`main`函数所在线程中执行两次`mu.Lock()`,当第二次加锁时会因为锁已经被占用(不是递归锁)而阻塞,`main`函数的阻塞状态驱动后台线程继续向前执行。当后台线程执行到`mu.Unock()`时解锁,此时打印工作已经完成了,解锁会导致`main`函数中的第二个`mu.Lock()`阻塞状态取消,此时后台线程和主线程再没有其它的同步事件参考,它们退出的事件将是并发的:在`main`函数退出导致程序退出时,后台线程可能已经退出了,也可能没有退出。虽然无法确定两个线程退出的时间,但是打印工作是可以正确完成的。
使用`sync.Mutex`互斥锁同步是比较低级的做法。我们现在可以该用无缓存的管道来实现同步:
使用`sync.Mutex`互斥锁同步是比较低级的做法。我们现在用无缓存的管道来实现同步:
```go
func main() {
@ -127,11 +126,11 @@ func main() {
}
```
其中`wg.Add(1)`用于增加等待事件的个数,必须确保在后台线程启动之前执行(如果放到后台线程之中执行这不能保证被正常执行到)。当后台现在完成打印工作之后,调用`wg.Done()`表示完成一个事件。`main`函数的`wg.Wait()`是等待全部的事件完成。
其中`wg.Add(1)`用于增加等待事件的个数,必须确保在后台线程启动之前执行(如果放到后台线程之中执行则不能保证被正常执行到)。当后台线程完成打印工作之后,调用`wg.Done()`表示完成一个事件。`main`函数的`wg.Wait()`是等待全部的事件完成。
## 生产者消费者模型
并发编程中最常见的例子就是生产者消费者模式该模式主要通过平衡生产线程和消费线程的工作能力来提高程序的整体处理数据的速度。简单地说就是生产者生产一些数据然后放到成果队列中同时消费者从成果队列中来取这些数据。这样就让生产消费变成了异步的两个过程。当成果队列中没有数据时消费者就进入饥饿的等待中而当成果队列中数据已满时生产者则面临因产品挤压导致CPU被剥夺的下岗问题。
并发编程中最常见的例子就是生产者/消费者模式该模式主要通过平衡生产线程和消费线程的工作能力来提高程序的整体处理数据的速度。简单地说就是生产者生产一些数据然后放到成果队列中同时消费者从成果队列中来取这些数据。这样就让生产消费变成了异步的两个过程。当成果队列中没有数据时消费者就进入饥饿的等待中而当成果队列中数据已满时生产者则面临因产品挤压导致CPU被剥夺的下岗问题。
Go语言实现生产者消费者并发很简单
@ -180,7 +179,7 @@ func main() {
}
```
我们这个例子中有2个生产者并且个生产者之间并无同步事件可参考,它们是并发的。因此,消费者输出的结果序列的顺序是不确定的,这并没有问题,生产者和消费者依然可以相互配合工作。
我们这个例子中有2个生产者并且2个生产者之间并无同步事件可参考,它们是并发的。因此,消费者输出的结果序列的顺序是不确定的,这并没有问题,生产者和消费者依然可以相互配合工作。
## 发布订阅模型
@ -317,7 +316,7 @@ func main() {
}
```
在发布订阅模型中,每条消息都会传送给多个订阅者。发布者通常不会知道、也不关心哪一个订阅者正在接收主题消息。订阅者和发布者可以在运行时动态添加是一种松散的耦合关心,这使得系统的复杂性可以随时间的推移而增长。在现实生活中,不同城市的象天气预报之类的应用就可以应用这个并发模式。
在发布订阅模型中,每条消息都会传送给多个订阅者。发布者通常不会知道、也不关心哪一个订阅者正在接收主题消息。订阅者和发布者可以在运行时动态添加是一种松散的耦合关心,这使得系统的复杂性可以随时间的推移而增长。在现实生活中,不同城市的象天气预报之类的应用就可以应用这个并发模式。
## 赢者为王
@ -349,7 +348,7 @@ func main() {
## 控制并发数
很多用户在适应了Go语言强大的并发特性之后都倾向于编写最大并发的程序因为这样似乎可以提供最大的性能。很多时候我们确实需要放慢我们的脚步享受生活,并发的程序也是一样:有时候我们需要适当地控制并发的程度,因为这样不仅仅可给其它的应用让出一定的CPU资源,给新的任务预留一定的计算资源,也可以适当降低功耗缓解电池的压力。
很多用户在适应了Go语言强大的并发特性之后都倾向于编写最大并发的程序因为这样似乎可以提供最大的性能。在现实中我们行色匆匆,但有时却需要我们放慢脚步享受生活,并发的程序也是一样:有时候我们需要适当地控制并发的程度,因为这样不仅仅可给其它的应用/任务让出/预留一定的CPU资源也可以适当降低功耗缓解电池的压力。
在Go语言自带的godoc程序实现中有一个`vfs`的包对应虚拟的文件系统,在`vfs`包下面有一个`gatefs`的子包,`gatefs`子包的目的就是为了控制访问该虚拟文件系统的最大并发数。`gatefs`包的应用很简单:
@ -808,7 +807,7 @@ func (p *Service) AddJob(job interface{}) {
}
```
主程序可以是一个wen服务器:
主程序可以是一个web服务器:
```go
var (