并发性被认为是 Go 最吸引人的特性之一。该语言的使用者热衷于其原语的简单性,以表达正确的并发实现,而不必担心这些努力通常会带来的陷阱。本章涵盖理解和创建并行 Go 计划所需的主题,包括以下内容:
- 戈罗季斯
- 渠道
- 编写并发程序
- 同步包
- 检测竞争条件
- Go 中的平行性
如果您使用过其他语言,例如 Java 或 C/C++,那么您可能熟悉并发的概念。它是一个程序独立运行两条或多条执行路径的能力。这通常是通过直接向程序员公开线程原语来创建和管理并发来实现的。
Go 有自己的并发原语,称为goroutine,它允许程序启动一个函数(例程),以独立于调用函数执行。Goroutines 是轻量级执行上下文,在少量操作系统支持的线程之间进行多路复用,并由 Go 的运行时调度器进行调度。这使得创建它们的成本很低,而不需要真正内核线程的开销要求。因此,Go 程序可以启动数千(甚至数十万)个 GoRouting,而对性能和资源退化的影响最小。
Goroutines 使用go
语句启动,如下所示:
go<函数或表达式>
将创建一个 goroutine,该 goroutine 带有go
关键字,后跟用于计划执行的函数。指定的函数可以是现有函数、匿名函数或调用函数的表达式。下面的代码片段显示了 goroutines 的使用示例:
func main() {
go count(10, 50, 10)
go count(60, 100, 10)
go count(110, 200, 20)
}
func count(start, stop, delta int) {
for i := start; i <= stop; i += delta {
fmt.Println(i)
}
}
golang.fyi/ch09/goroutine0.go
在前面的代码示例中,当main
函数中遇到go count()
语句时,它会在独立的执行上下文中启动count
函数。main
和count
功能将同时执行。作为一种副作用,main
将在count
功能有机会将任何内容打印到控制台之前完成。
在本章后面,我们将了解如何惯用地处理 goroutine 之间的同步。现在,让我们使用fmt.Scanln()
阻塞并等待键盘输入,如下面的示例所示。在此版本中,并行函数在等待键盘输入时有机会完成:
func main() {
go count(10, 30, 10)
go count(40, 60, 10)
go count(70, 120, 20)
fmt.Scanln() // blocks for kb input
}
golang.fyi/ch09/goroutine1.go
goroutine 也可以直接在go
语句中定义为函数文本,如下面代码段中示例的更新版本所示:
func main() {
go count(10, 30, 10)
go func() {
count(40, 60, 10)
}()
...
}
golang.fyi/ch09/goroutine2.go
函数文本提供了一种方便的习惯用法,允许程序员直接在[T0]语句的位置组装逻辑。当将go
语句与函数文字一起使用时,它被视为具有非局部变量词法访问的常规闭包,如下例所示:
func main() {
start := 0
stop := 50
step := 5
go func() {
count(start, stop, step)
}()
}
golang.fyi/ch09/goroutine3.go
在前面的代码中,goroutine 能够访问和使用变量start
、stop
和step
。只要在 goroutine 启动后,闭包中捕获的变量不会更改,这是安全的。如果这些值在闭包之外更新,则可能会创建争用条件,导致 goroutine 在计划运行时读取意外值。
下面的代码片段显示了 goroutine 闭包从循环中捕获变量j
的示例:
func main() {
starts := []int{10,40,70,100}
for _, j := range starts{
go func() {
count(j, j+20, 10)
}()
}
}
golang.fyi/ch09/goroutine4.go
由于j
随着每次迭代而更新,因此无法确定闭包将读取什么值。在大多数情况下,goroutine 闭包将在执行时看到最后更新的值j
。通过将变量作为 goroutine 的函数文本中的参数传递,可以很容易地解决此问题,如下所示:
func main() {
starts := []int{10,40,70,100}
for _, j := range starts{
go func(s int) {
count(s, s+20, 10)
}(j)
}
}
golang.fyi/ch09/goroutine5.go
在每次循环迭代中调用的 goroutine 闭包通过函数参数接收j
变量的副本。这将创建一个具有正确值的j
值的本地副本,以便在 goroutine 计划运行时使用。
通常,所有 goroutine 都是独立运行的,如下图所示。创建 goroutine 的函数不会等待它返回,它会继续使用自己的执行流,除非存在阻塞条件。稍后,本章将介绍协调 goroutine 的同步习惯用法:
Go 的运行时调度器使用一种形式的协作调度来调度 GoRoutine。默认情况下,调度程序将允许运行的 goroutine 执行到完成。但是,如果发生以下事件之一,调度程序将自动让位于另一个 goroutine 执行:
- 在正在执行的 goroutine 中遇到一个[T0]语句
- 遇到通道操作(稍后将介绍通道)
- 遇到阻塞系统调用(例如文件或网络 IO)
- 在完成垃圾收集周期之后
调度程序将安排一个排队的 goroutine,当在运行的 goroutine 中遇到以前的事件之一时,该 goroutine 准备进入执行。需要指出的是,调度器不能保证 goroutine 的执行顺序。例如,当执行以下代码段时,每次运行都将以任意顺序打印输出:
func main() {
go count(10, 30, 10)
go count(40, 60, 10)
go count(70, 120, 20)
fmt.Scanln() // blocks for kb input
}
func count(start, stop, delta int) {
for i := start; i <= stop; i += delta {
fmt.Println(i)
}
}
golang.fyi/ch09/goroutine1.go
以下显示了上一个程序的可能输出:
10
70
90
110
40
50
60
20
30
当谈到并发性时,其中一个自然关注点是并发执行代码之间的数据安全和同步。如果您使用 Java 或 C/C++等语言进行过并发编程,那么您可能熟悉确保正在运行的线程可以安全地访问共享内存值以实现线程之间的通信和同步所需的编排(有时很脆弱)。
这是 Go 与其 C 血统不同的一个领域。Go 使用通道作为运行 Goroutine 之间的管道来通信和共享数据,而不是让并发代码通过共享内存位置进行通信。博客帖子有效去(https://golang.org/doc/effective_go.html 将这一概念简化为以下口号:
不要通过共享内存进行通信;相反,通过交流来共享内存。
通道的概念起源于通信顺序过程(CSP),这是著名计算机科学家 C.A.Hoare 使用通信原语对并发进行建模的工作。正如本节将讨论的,通道提供了在运行的 goroutine 之间同步和安全通信数据的方法。
本节讨论 Go channel 类型,并深入了解其特征。稍后,您将学习如何使用通道创建并发程序。
通道类型声明了一个通道,在该通道中,通道只能发送或接收给定元素类型的值。chan
关键字用于指定通道类型,如下声明格式所示:
禅<元素类型>
以下代码段声明了一个双向通道类型chan int
,分配给变量ch
,用于传递整数值:
func main() {
var ch chan int
...
}
在本章后面,我们将学习如何使用通道在运行程序的并发部分之间发送数据。
Go 使用<-
(箭头)操作符指示通道内的数据移动。下表总结了如何从通道发送或接收数据:
未初始化的通道具有nil零值,必须使用内置的make功能进行初始化。正如将在以下章节中讨论的,通道可以初始化为无缓冲或缓冲,具体取决于其指定的容量。每种类型的通道都有不同的特性,这些特性在不同的并发结构中得到利用。
当调用不带容量参数的make
函数时,它返回一个双向无缓冲通道。以下代码段显示了创建类型为chan int
的无缓冲通道的过程:
func main() {
ch := make(chan int) // unbuffered channel
...
}
下图说明了无缓冲通道的特性:
上图中的顺序(从左到右)显示了无缓冲通道的工作原理:
- 如果通道为空,接收器将阻塞,直到有数据为止
- 发送方只能发送到空通道,并在下一次接收操作之前阻塞
- 当通道有数据时,接收器可以继续接收数据。
如果操作没有包装在 goroutine 中,发送到无缓冲通道很容易导致死锁。发送12
到通道后,以下代码将被阻塞:
func main() {
ch := make(chan int)
ch <- 12 // blocks
fmt.Println(<-ch)
}
golang.fyi/ch09/chan-unbuff0.go
运行上一个程序时,将得到以下结果:
$> go run chan-unbuff0.go
fatal error: all goroutines are asleep - deadlock!
回想一下,发送方在发送到无缓冲通道时立即阻塞。这意味着任何后续语句(例如从通道接收的语句)都无法访问,从而导致死锁。以下代码显示了发送到无缓冲通道的正确方式:
func main() {
ch := make(chan int)
go func() { ch <- 12 }()
fmt.Println(<-ch)
}
golang.fyi/ch09/chan-unbuff1.go
请注意,send 操作包装在一个匿名函数中,该函数作为一个单独的 goroutine 调用。这允许main
功能在不阻塞的情况下到达接收操作。正如您稍后将看到的,无缓冲通道的这种阻塞特性被广泛用作 goroutine 之间的同步和协调习惯用法。
当make
函数使用 capacity 参数时,它返回一个双向缓冲通道,如下代码段所示:
func main
ch := make(chan int, 3) // buffered channel
}
前面的代码将创建容量为3
的缓冲通道。缓冲通道作为先进先出阻塞队列运行,如下图所示:
上图中所示的缓冲信道具有以下特征:
- 当信道为空时,接收器阻塞,直到至少有一个元素
- 只要通道没有容量,发送方总是成功
- 当信道处于容量时,发送方阻塞,直到至少接收到一个元素
使用缓冲通道,可以在同一 goroutine 中发送和接收值,而不会导致死锁。以下显示了使用容量为4
个元素的缓冲信道发送和接收的示例:
func main() {
ch := make(chan int, 4)
ch <- 2
ch <- 4
ch <- 6
ch <- 8
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
}
golang.fyi/ch09/chan0.go
前一示例中的代码能够将值2
、4
、6
和8
发送到ch
通道,而不存在阻塞风险。四条fmt.Println(<-ch)
语句用于依次接收通道中缓冲的值。但是,如果在第一次接收之前添加了第五次发送操作,则代码将死锁,如以下代码段中突出显示的:
func main() {
ch := make(chan int, 4)
ch <- 2
ch <- 4
ch <- 6
ch <- 8
ch <- 10
fmt.Println(<-ch)
...
}
在本章后面,您将阅读更多关于使用频道进行通信的惯用和安全的方法。
在声明时,信道类型还可以包括单向运算符(再次使用<-
箭头)以指示信道是仅发送还是仅接收,如下表所示:
var inCh chan<- int
| | 这是同一件事,这是同一件事,这是同一件事,这是同一件事。 | 声明一个只发送的通道,如下所示。
var outCh <-chan int
|
以下代码段显示了具有类型为chan <- int
的仅发送通道参数的函数makeEvenNums
:
func main() {
ch := make(chan int, 10)
makeEvenNums(4, ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
}
func makeEvenNums(count int, in chan<- int) {
for i := 0; i < count; i++ {
in <- 2 * i
}
}
golang.fyi/ch09/chan1.go
由于通道的方向性在类型中烘焙,因此将在编译时检测到访问冲突。因此在前面的示例中,in
信道只能用于接收操作。
双向通道可以显式或自动转换为单向通道。例如,当从main()
调用makeEvenNums()
时,它接收双向信道ch
作为参数。编译器会自动将通道转换为适当的类型。
len
和cap
函数可分别用于返回通道的长度和容量。len
函数返回接收器读取之前在通道中排队的当前元素数。例如,下面的代码片段将打印2:
func main() {
ch := make(chan int, 4)
ch <- 2
ch <- 2
fmt.Println(len(ch))
}
cap
函数返回信道类型的声明容量,与长度不同,该容量在信道的整个生命周期内保持不变。
无缓冲信道的长度和容量为零。
通道初始化后,即可进行发送和接收操作。通道将保持该打开状态,直到使用内置的关闭功能强制关闭,如下例所示:
func main() {
ch := make(chan int, 4)
ch <- 2
ch <- 4
close(ch)
// ch <- 6 // panic, send on closed channel
fmt.Println(<-ch)
fmt.Println(<-ch)
fmt.Println(<-ch) // closed, returns zero value for element
}
golang.fyi/ch09/chan2.go
通道关闭后,它具有以下属性:
- 后续的发送操作将导致程序死机
- 接收操作从不阻塞(无论是缓冲还是非缓冲)
- 所有接收操作都返回通道元素类型的零值
在前面的代码片段中,ch
通道在两次发送操作后关闭。如注释所示,第三次发送操作将导致死机,因为通道已关闭。在接收端,代码在关闭通道之前获取通道中的两个元素。第三个接收操作返回信道元素的零值0
。
Go 提供了一个长形式的接收操作,返回从通道读取的值,后跟一个指示通道关闭状态的布尔值。这可用于正确处理封闭通道中的零值,如以下示例所示:
func main() {
ch := make(chan int, 4)
ch <- 2
ch <- 4
close(ch)
for i := 0; i < 4; i++ {
if val, opened := <-ch; opened {
fmt.Println(val)
} else {
fmt.Println("Channel closed!")
}
}
}
golang.fyi/ch09/chan3.go
到目前为止,关于 goroutines 和 channels 的讨论仍然有意地分开,以确保每个主题都被适当地涵盖。然而,如本节所述,当通道和 goroutine 组合在一起创建并发程序时,它们的真正威力才得以实现。
通道的主要用途之一是运行 goroutine 之间的同步。为了说明这个用例,让我们检查下面的代码,它实现了一个单词直方图。程序从data
切片中读取单词,然后在单独的 goroutine 上收集每个单词的出现情况:
func main() {
data := []string{
"The yellow fish swims slowly in the water",
"The brown dog barks loudly after a drink ...",
"The dark bird bird of prey lands on a small ...",
}
histogram := make(map[string]int)
done := make(chan bool)
// splits and count words
go func() {
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
histogram[word]++
}
}
done <- true
}()
if <-done {
for k, v := range histogram {
fmt.Printf("%s\t(%d)\n", k, v)
}
}
}
golang.fyi/ch09/pattern0.go
上一个示例中的代码使用done := make(chan bool)
创建通道,该通道将用于同步程序中两个正在运行的 goroutine。main
函数启动一个辅助 goroutine,该 goroutine 进行字数计算,然后继续执行,直到阻塞<-done
表达式,使其等待。
同时,辅助 goroutine 将一直运行,直到完成其循环。然后,它向带有done <- true
的done
通道发送一个值,导致阻塞的main
例程变为未阻塞,并继续执行。
前面的代码有一个可能导致竞争条件的 bug。本章后面将介绍更正。
在前面的示例中,代码分配并实际发送用于同步的布尔值。进一步检查后,很明显,通道中的值是无关的,我们只是希望它发出信号。因此,我们可以进一步将同步习惯用法提取为口语形式,如以下代码段所示:
func main() {
...
histogram := make(map[string]int)
done := make(chan struct{})
// splits and count
go func() {
defer close(done) // closes channel upon fn return
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
histogram[word]++
}
}
}()
<-done // blocks until closed
for k, v := range histogram {
fmt.Printf("%s\t(%d)\n", k, v)
}
}
golang.fyi/ch09/pattern1.go
此版本的代码通过以下方式实现 goroutine 同步:
- 已完成的通道,声明为类型
chan struct{}
- 接收表达式
<-done
处的主 goroutine 块 - 当 done 通道关闭时,所有接收器均成功,无阻塞
尽管信令是使用不同的构造完成的,但此版本的代码相当于第一个版本(pattern0.go
。emtpystruct{}
类型不存储值,严格用于信令。此版本的代码关闭done
通道(而不是发送值)。这具有允许主 goroutine 解除阻止并继续执行的效果。
通道的自然用途是将数据从一个 goroutine 流到另一个 goroutine。此模式在 Go 代码中非常常见,要使其正常工作,必须执行以下操作:
- 在通道上连续发送数据
- 连续接收来自该通道的输入数据
- 向流的结尾发送信号,以便接收器可以停止
正如您将看到的,所有这些都可以使用单个通道完成。下面的代码片段是对前面示例的重写。它展示了如何使用单个通道将数据从一个 goroutine 流到另一个 goroutine。同一信道还用作信令设备,以指示流的结束:
func main(){
...
histogram := make(map[string]int)
wordsCh := make(chan string)
// splits lines and sends words to channel
go func() {
defer close(wordsCh) // close channel when done
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
wordsCh <- word
}
}
}()
// process word stream and count words
// loop until wordsCh is closed
for {
word, opened := <-wordsCh
if !opened {
break
}
histogram[word]++
}
for k, v := range histogram {
fmt.Printf("%s\t(%d)\n", k, v)
}
}
golang.fyi/ch09/pattern2.go
这个版本的代码像以前一样生成单词直方图,但引入了一种不同的方法。这是通过使用下表中所示代码的突出显示部分实现的:
| **代码** | **说明** | |wordsCh := make(chan string)
| 用于流式传输数据的通道。 | |
wordsCh <- word
| 发送方 goroutine 在文本行中循环并一次发送一个单词。然后它阻塞,直到接收(主)goroutine 接收到该字。 | |
defer close(wordsCh)
| 由于连续接收到这些字(见下文),发送方 goroutine 在完成时关闭通道。这将是发送给接收器的信号,它也应该停止。 | |
for {
word, opened := <-wordsCh
if !opened {
break
}
histogram[word]++
}
| 这是接收代码。它被放置在一个循环中,因为它无法提前知道需要多少数据。在循环的每次迭代中,代码执行以下操作:
- 从通道中提取数据
- 检查通道的打开状态
- 如果关闭,则断开循环
- 否则记录直方图
|
前一种模式在 Go 中非常常见,以至于成语以以下for…range
语句的形式嵌入到语言中:
对于<元素>:=范围<通道>{…}
在每次迭代中,for…range
语句将阻塞,直到它从指定的通道接收到传入数据,如以下代码段所示:
func main(){
...
go func() {
defer close(wordsCh)
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
wordsCh <- word
}
}
}()
for word := range wordsCh {
histogram[word]++
}
...
}
golang.fyi/ch09/pattern3.go
前面的代码使用 for range 语句[T0]显示代码的更新版本。依次从wordsCh
通道发出接收值。当通道关闭时(从 goroutine 开始),循环自动中断。
始终记住关闭通道,以便正确地向接收机发送信号。否则,程序可能会进入死锁,从而导致死机。
通道和 goroutine 为使用生成器函数实现生产者/生产者模式的形式提供了自然的基础。在这种方法中,goroutine 被包装在一个函数中,该函数生成通过该函数返回的通道发送的值。消费者 goroutine 在生成这些值时接收这些值。
单词直方图已更新为使用此模式,如以下代码段所示:
func main() {
data := []string{"The yellow fish swims...", ...}
histogram := make(map[string]int)
words := words(data) // returns handle to data channel
for word := range words {
histogram[word]++
}
...
}
// generator function that produces data
func words(data []string) <-chan string {
out := make(chan string)
go func() {
defer close(out) // closes channel upon fn return
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
out <- word
}
}
}()
return out
}
golang.fyi/ch09/pattern4.go
在本例中,声明为func words(data []string) <-chan string
的生成器函数返回字符串元素的仅接收通道。在本例中,消费者函数main()
接收生成器函数发出的数据,使用for…range
循环进行处理。
有时并发程序需要同时处理多个通道的发送和接收操作。为了促进这种努力,Go 语言支持在多个发送和接收操作中多路选择的select
语句:
选择{
案例<发送或接收表达>:
默认值:
}
case
语句的操作类似于带有case
子句的switch
语句。然而,select
语句选择成功的发送或接收案例之一。如果两个或多个通信案例同时准备就绪,将随机选择一个。如果没有其他案例成功,则始终选择默认案例。
下面的代码片段更新了直方图代码,以说明[T0]语句的用法。发电机功能words
在两个通道out
之间选择,如前所述发送数据,新通道stopCh
作为参数传递,用于检测中断信号,停止发送数据:
func main() {
...
histogram := make(map[string]int)
stopCh := make(chan struct{}) // used to signal stop
words := words(stopCh, data) // returns handle to channel
for word := range words {
if histogram["the"] == 3 {
close(stopCh)
}
histogram[word]++
}
...
}
func words(stopCh chan struct{}, data []string) <-chan string {
out := make(chan string)
go func() {
defer close(out) // closes channel upon fn return
for _, line := range data {
words := strings.Split(line, " ")
for _, word := range words {
word = strings.ToLower(word)
select {
case out <- word:
case <-stopCh: // succeeds first when close
return
}
}
}
}()
return out
}
golang.fyi/ch09/pattern5.go
在前面的代码段中,words
生成器功能将选择成功的第一个通信操作:out <- word
或<-stopCh
。只要main()
中的消费者代码继续从out
通道接收,发送操作将首先成功。但是请注意,main()
中的代码在遇到"the"
的第三个实例时关闭stopCh
通道。当这种情况发生时,它将导致 select 语句中的 receive 案例首先继续,从而导致 goroutine 返回。
Go 并发中经常遇到的一个流行习惯用法是使用前面介绍的 select 语句来实现超时。这是通过使用 select 语句,使用time
包(中的 API,在给定的时间内等待通道操作成功来实现的 https://golang.org/pkg/time/ [T2]。
下面的代码段显示了单词直方图示例的一个版本,如果程序计算和打印单词的时间超过 200 微秒,该示例将超时:
func main() {
data := []string{...}
histogram := make(map[string]int)
done := make(chan struct{})
go func() {
defer close(done)
words := words(data) // returns handle to channel
for word := range words {
histogram[word]++
}
for k, v := range histogram {
fmt.Printf("%s\t(%d)\n", k, v)
}
}()
select {
case <-done:
fmt.Println("Done counting words!!!!")
case <-time.After(200 * time.Microsecond):
fmt.Println("Sorry, took too long to count.")
}
}
func words(data []string) <-chan string {...}
golang.fyi/ch09/pattern6.go
此版本的直方图示例引入了done
通道,用于在处理完成时发出信号。在select
语句中,接收操作case``<-done:
阻塞,直到 goroutine 关闭done
通道。同样在select
语句中,time.After()
函数返回一个通道,该通道将在指定的持续时间后关闭。如果关闭前经过 200 微秒,则来自time.After()
的通道将首先关闭,导致超时情况首先成功。
在某些情况下,使用传统方法访问共享值比使用通道更简单、更合适。同步包(https://golang.org/pkg/sync/ 提供了几个同步原语,包括互斥(互斥)锁和同步屏障,用于安全访问共享值,如本节所述。
互斥锁通过使 goroutines 阻塞并等待锁释放,从而允许串行访问共享资源。下面的示例演示了一个具有[T0]类型的典型代码场景,它必须在准备使用之前启动。服务启动后,代码更新内部 bool 变量started
,以存储其当前状态:
type Service struct {
started bool
stpCh chan struct{}
mutex sync.Mutex
}
func (s *Service) Start() {
s.stpCh = make(chan struct{})
go func() {
s.mutex.Lock()
s.started = true
s.mutex.Unlock()
<-s.stpCh // wait to be closed.
}()
}
func (s *Service) Stop() {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.started {
s.started = false
close(s.stpCh)
}
}
func main() {
s := &Service{}
s.Start()
time.Sleep(time.Second) // do some work
s.Stop()
}
golang.fyi/ch09/sync2。
前面的代码段使用类型为sync.Mutex
的变量mutex
同步访问共享变量started
。为了有效地工作,started
变量被更新的所有有争议的区域必须使用相同的锁,并连续调用mutex.Lock()
和mutex.Unlock()
,如代码所示。
您经常会遇到的一个习惯用法是将[T0]类型直接嵌入到结构中,如下一个代码段所示。这有促进Lock()
和Unlock()
方法作为结构本身的一部分的作用:
type Service struct {
...
sync.Mutex
}
func (s *Service) Start() {
s.stpCh = make(chan struct{})
go func() {
s.Lock()
s.started = true
s.Unlock()
<-s.stpCh // wait to be closed.
}()
}
func (s *Service) Stop() {
s.Lock()
defer s.Unlock()
...
}
golang.fyi/ch09/sync3。
sync
包还提供了 RWMutex(读写互斥),可用于一个写入程序更新共享资源,而可能有多个读取程序的情况。编写器会像以前一样使用完全锁更新资源。但是,读卡器在读取共享资源时使用RLock()
/RUnlock()
方法对(分别用于读取锁定/读取解锁)应用只读锁定。RWMutex 类型将在下一节同步访问复合值中使用。
上一节讨论了共享对简单值的访问时的并发安全性。共享对复合类型值(如映射和切片)的访问权限时,必须同样小心,因为 Go 不提供这些类型的并发安全版本,如以下示例所示:
type Service struct {
started bool
stpCh chan struct{}
mutex sync.RWMutex
cache map[int]string
}
func (s *Service) Start() {
...
go func() {
s.mutex.Lock()
s.started = true
s.cache[1] = "Hello World"
...
s.mutex.Unlock()
<-s.stpCh // wait to be closed.
}()
}
...
func (s *Service) Serve(id int) {
s.mutex.RLock()
msg := s.cache[id]
s.mutex.RUnlock()
if msg != "" {
fmt.Println(msg)
} else {
fmt.Println("Hello, goodbye!")
}
}
golang.fyi/ch09/sync4
前面的代码在访问 map 变量cache
时使用sync.RWMutex
变量(参见前面章节与互斥锁同步)来管理锁。代码将更新操作包装到一对方法调用mutex.Lock()
和mutex.Unlock()
中的cache
变量。但是,当从cache
变量读取值时,使用mutex.RLock()
和mutex.RUnlock()
方法来提供并发安全性。
有时在使用 goroutines 时,您可能需要创建一个同步屏障,以便在继续之前等待所有正在运行的 goroutines 完成。sync.WaitGroup
类型是为这种场景设计的,允许多个 goroutine 在代码中的特定点会合。使用 WaitGroup 需要三件事:
- 通过 Add 方法在组中的参与者人数
- 每个 goroutine 调用 Done 方法来表示完成
- 使用 Wait 方法阻塞,直到完成所有 goroutine
WaitGroup 通常被用作实现工作分配模式的一种方式。下面的代码片段演示了计算3
和5
到MAX
的倍数之和的工作分配。代码使用WaitGroup
变量wg
创建一个并发屏障,等待两个 goroutine 计算数字的部分和,然后在完成所有 goroutine 后收集结果:
const MAX = 1000
func main() {
values := make(chan int, MAX)
result := make(chan int, 2)
var wg sync.WaitGroup
wg.Add(2)
go func() { // gen multiple of 3 & 5 values
for i := 1; i < MAX; i++ {
if (i%3) == 0 || (i%5) == 0 {
values <- i // push downstream
}
}
close(values)
}()
work := func() { // work unit, calc partial result
defer wg.Done()
r := 0
for i := range values {
r += i
}
result <- r
}
// distribute work to two goroutines
go work()
go work()
wg.Wait() // wait for both groutines
total := <-result + <-result // gather partial results
fmt.Println("Total:", total)
}
golang.fyi/ch09/sync5。
在前面的代码中,方法调用wg.Add(2)
配置了WaitGroup
变量wg
,因为工作分布在两个 goroutine 之间。work
函数调用defer wg.Done()
以在每次完成时将 WaitGroup 计数器减量 1。
最后,wg.Wait()
方法调用阻塞,直到其内部计数器达到零。如前所述,当两个 goroutines 的work
运行函数成功完成时,就会发生这种情况。发生这种情况时,程序将取消阻止并收集部分结果。重要的是要记住,如果wg.Wait()
的内部计数器从未达到零,wg.Wait()
将无限期阻塞。
调试带有竞争条件的并发代码可能非常耗时且令人沮丧。当一个竞争条件发生时,它通常是不一致的,并且显示很少或没有可识别的模式。幸运的是,自 1.1 版以来,Go 在其命令行工具链中包含了一个竞赛检测器。在构建、测试、安装或运行 Go 源代码时,只需添加-race
命令标志,即可启用代码的种族检测器检测。
例如,当源文件golang.fyi/ch09/sync1.go
(具有竞态条件的代码)使用-race
标志执行时,编译器的输出显示导致竞态条件的违规 goroutine 位置,如下输出所示:
$> go run -race sync1.go
==================
WARNING: DATA RACE
Read by main goroutine:
main.main()
/github.com/vladimirvivien/learning-go/ch09/sync1.go:28 +0x8c
Previous write by goroutine 5:
main.(*Service).Start.func1()
/github.com/vladimirvivien/learning-go/ch09/sync1.go:13 +0x2e
Goroutine 5 (running) created at:
main.(*Service).Start()
/github.com/vladimirvivien/learning-go/ch09/sync1.go:15 +0x99
main.main()
/github.com/vladimirvivien/learning-go/ch09/sync1.go:26 +0x6c
==================
Found 1 data race(s)
exit status 66
竞赛检测器列出了同时访问共享值的行号。它列出了读取操作,然后是写入操作可能同时发生的位置。即使在经过良好测试的代码中,代码中的活跃条件也可能被忽略,直到它随机地表现出来。如果您正在编写并发代码,强烈建议您将种族检测器集成为测试套件的一部分。
到目前为止,本章讨论的重点是同步并发程序。正如本章前面提到的,Go 运行时调度器会在可用的 OS 托管线程之间自动多路复用和调度 GoRoutine。这意味着可以并行化的并发程序能够利用底层处理器核心,而几乎不需要配置。例如,下面的代码将其工作单元(用于计算 3 和 5 的倍数之和)清晰地分离,以便通过启动[T0]个 goroutine 来计算:
const MAX = 1000
const workers = 2
func main() {
values := make(chan int)
result := make(chan int, workers)
var wg sync.WaitGroup
go func() { // gen multiple of 3 & 5 values
for i := 1; i < MAX; i++ {
if (i%3) == 0 || (i%5) == 0 {
values <- i // push downstream
}
}
close(values)
}()
work := func() { // work unit, calc partial result
defer wg.Done()
r := 0
for i := range values {
r += i
}
result <- r
}
//launch workers
wg.Add(workers)
for i := 0; i < workers; i++ {
go work()
}
wg.Wait() // wait for all groutines
close(result)
total := 0
// gather partial results
for pr := range result {
total += pr
}
fmt.Println("Total:", total)
}
golang.fyi/ch09/sync6。
当在多核机器上执行时,前面的代码将自动并行地启动每个 goroutine,并带有go work()
。默认情况下,Go runtime scheduler 将为调度创建一个数量等于 CPU 内核数量的 OS 支持线程。该数量由名为GOMAXPROCS的运行时值标识。
可以显式更改 GOMAXPROCS 值以影响调度程序可用的线程数。可以使用同名的命令行环境变量更改该值。也可以从运行时包(中的使用函数GOMAXPROCS()
中更新 GOMAXPROCShttps://golang.org/pkg/runtime )。这两种方法都允许程序员微调参与调度 goroutine 的线程数量。
在任何语言中,并发都可能是一个复杂的主题。本章介绍了引导读者在 Go 语言中使用并发原语的主要主题。本章的第一部分概述了 goroutines 的关键属性,包括[T0]go[T1]语句的创建和使用。接下来,本章介绍了 Go 的运行时调度器的机制以及用于运行 Goroutine 之间通信的通道的概念。最后,向用户介绍了几种并发模式,用于使用同步包中的 goroutine、channels 和同步原语创建并发程序。
接下来,将向您介绍标准 API,以便在 Go 中进行数据输入和输出。