mirror of
https://github.com/chai2010/advanced-go-programming-book.git
synced 2025-05-24 04:22:22 +00:00
commit
f27a6dee17
@ -1,22 +1,21 @@
|
||||
# 1.6. 常见的并发模式
|
||||
|
||||
Go语言最吸引人的地方是它内建的并发支持。Go语言并发体系的理论是C.A.R Hoare在1978年提出的CSP(Communicating Sequential Process,通讯顺序进程)。尽管CSP有着精确的数学模型,并实际应用在Hoare实际参与设计的T9000通用计算机上。但是作为对CSP有着20多年实战经验的Rob Pike来说,它更关注的始终是将CSP应用在通用编程语言上的潜力,从NewSqueak、Alef、Limbo到现在的Go语言。作为Go并发编程核心的CSP理论的核心其实只有一个概念:同步通信。关于同步通信的话题我们在前面一节已经讲过,我们现在开始简单介绍下Go语言中常见的并发模式。
|
||||
Go语言最吸引人的地方是它内建的并发支持。Go语言并发体系的理论是C.A.R Hoare在1978年提出的CSP(Communicating 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 (
|
||||
|
Loading…
x
Reference in New Issue
Block a user