并发、Goroutine 与 GOMAXPROCS

每当有新人加入 Go-Miami 小组的时候,他们总会提到他们有多想学习更多有关 Go 并发模型的东西。似乎并发就像这个语言的大新闻一样。不过,在我第一次听说 Go 时确实如此 – 实际上正是 Rob Pike 的 Go 并发模式这个视频让我确信我需要去学这门语言。

要想理解为什么用 Go 编写并发程序会更加容易而且更难出错,我们首先得了解一个并发程序是什么样的,以及它可能会出现哪些问题。我不会在这篇文章中讨论 CSP(Communicating Sequential Processes,通信顺序进程),尽管它确实是 Go 的 Channel 实现的基础。这篇文章主要讲述一个并发程序会是什么样的、Goroutine 在这之中起着什么样的作用、以及 GOMAXPROCS 环境变量和运行时函数会如何影响 Go 运行时和我们编写的程序的行为。

进程与线程

在我们启动一个应用程序,例如我现在正在用来写这篇文章的浏览器时,操作系统会为应有程序创建一个进程。进程的作用就像一个容器,装着应用程序在运行的过程中会使用并且维护的资源,包括内存地址空间、指向文件或设备的句柄以及线程。

一条线程是一个由操作系统负责调度的执行路径,负责在一个处理器上执行我们在函数中编写的代码。一个进程在开始时只有一条线程,即主线程。当主线程结束时,进程也随之终止,因为主线程是整个应用程序的起点。主线程可以启动新线程,而这些新线程也可以启动更多的新线程。

操作系统负责调度线程到可用的处理器上执行,且不会考虑该线程属于哪个进程。每个操作系统都会有它自己的调度算法,因此对我们来说最好还是不要编写依赖于某种调度算法的并发程序。再说了,这些调度算法在每次操作系统发布新版本时都可能会变化。

Goroutine 与并行

Go 的任何函数和方法都可以被创建为一个 Goroutine。我们可以认为 main 函数就作为一个 Goroutine 在执行,尽管 Go 运行时并没有启动这个 Goroutine。Goroutine 是轻量级的,因为它们通常只会占用很少的内存和资源,以及它们的初始栈空间很小。在 1.2 版之前的初始栈空间为 4K 而在 1.4 版之后初始栈空间为 8K。Goroutine 的栈还可以按需增长。

操作系统负责将线程调度到可用的处理器上执行,而 Go 运行时则将 Goroutine 调度到与单个操作系统线程相绑定的逻辑处理器上执行。在默认情况下,Go 运行时会使用一个逻辑处理器来运行我们程序创建的所有 Goroutine。不过,即使只用这唯一一个逻辑处理器和操作系统线程,成千上万个 Goroutine 仍然可以以惊人的效率和性能并发执行。尽管并不推荐你添加更多的逻辑处理器,但如果你想要并行地运行 Goroutine,Go 也允许你通过 GOMAXPROCS 环境变量和运行时函数来添加逻辑处理器。

并发(Concurrency)并不是并行(Parallelism)。并行是指多个线程在多个处理器上同时执行代码。如果你通过配置让运行时使用多个逻辑处理器,调度器就会将 Goroutine 分配到这些逻辑处理器上,如此一来这些 Goroutine 便会运行在不同的操作系统线程中。然而,要想真正实现并行,你需要将你的程序运行在一个拥有多个物理处理器的机器上。否则,这些 Goroutine 只会在一个物理处理器上并发执行,即使 Go 运行时在使用多个逻辑处理器。

并发案例

接下来我们来创建一个小程序来看看 Go 是如何并发运行 Goroutine 的。在执行这个案例时我们会使用一个逻辑处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"fmt"
"runtime"
"sync"
)

func main() {
runtime.GOMAXPROCS(1) // <-----

var wg sync.WaitGroup
wg.Add(2)

fmt.Println("Starting Go Routines")
go func() {
defer wg.Done()

for char := 'a'; char < 'a'+26; char++ {
fmt.Printf("%c ", char)
}
}()

go func() {
defer wg.Done()

for number := 1; number < 27; number++ {
fmt.Printf("%d ", number)
}
}()

fmt.Println("Waiting To Finish")
wg.Wait()

fmt.Println("\nTerminating Program")
}

这个程序使用 go 关键字和两个匿名函数启动了两个 Goroutine。第一个 Goroutine 负责以小写字母打印英文字母表,第二个 Goroutine 则打印数字 1 到数字 26。如果我们运行该程序我们将得到如下输出:

1
2
3
4
5
Starting Go Routines
Waiting To Finish
a b c d e f g h i j k l m n o p q r s t u v w x y z 1 2 3 4 5 6 7 8 9 10 11
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
Terminating Program

从输出来看,我们可以看到代码是并发执行的。在两个 Goroutine 启动后,主 Goroutine 开始等待这两个 Goroutine 完成。这么做的原因是一旦主 Goroutine 结束后,应用程序就终止了。使用 WaitGroup 即可很好地让 Goroutine 相互知会它们何时结束执行。

我们可以看到,第一个 Goroutine 在完成打印所有 26 个字母后才轮到第二个 Goroutine 打印它需要打印的 26 个数字。因为第一个 Goroutine 在一微秒之内就完成了它的工作,因此我们没能看到调度器在第一个 Goroutine 执行完成前中断它。我们可以通过在第一个 Goroutine 中调用 Sleep 函数来让调度器切换 Goroutine:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
"fmt"
"runtime"
"sync"
"time"
)

func main() {
runtime.GOMAXPROCS(1)

var wg sync.WaitGroup
wg.Add(2)

fmt.Println("Starting Go Routines")
go func() {
defer wg.Done()

time.Sleep(1 * time.Microsecond) // <---------
for char := 'a'; char < 'a'+26; char++ {
fmt.Printf("%c ", char)
}
}()

go func() {
defer wg.Done()

for number := 1; number < 27; number++ {
fmt.Printf("%d ", number)
}
}()

fmt.Println("Waiting To Finish")
wg.Wait()

fmt.Println("\nTerminating Program")
}

这次我们在第一个 Goroutine 启动时就调用了 Sleep 函数,这使得调度器切换了两个 Goroutine:

1
2
3
4
5
Starting Go Routines
Waiting To Finish
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 a
b c d e f g h i j k l m n o p q r s t u v w x y z
Terminating Program

并行案例

在我们上面的两个示例中,Goroutine 都是在并发执行而不是并行执行。接下来我们对代码做些修改来让 Goroutine 并行执行。我们只需要添加第二个逻辑处理器来让调度器使用两条线程即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

import (
"fmt"
"runtime"
"sync"
)

func main() {
runtime.GOMAXPROCS(2) // <------------

var wg sync.WaitGroup
wg.Add(2)

fmt.Println("Starting Go Routines")
go func() {
defer wg.Done()

for char := 'a'; char < 'a'+26; char++ {
fmt.Printf("%c ", char)
}
}()

go func() {
defer wg.Done()

for number := 1; number < 27; number++ {
fmt.Printf("%d ", number)
}
}()

fmt.Println("Waiting To Finish")
wg.Wait()

fmt.Println("\nTerminating Program")
}

程序输出如下:

1
2
3
4
5
Starting Go Routines
Waiting To Finish
a b 1 2 3 4 c d e f 5 g h 6 i 7 j 8 k 9 10 11 12 l m n o p q 13 r s 14
t 15 u v 16 w 17 x y 18 z 19 20 21 22 23 24 25 26
Terminating Program

我们每次运行该程序时都会得到不一样的结果。调度器的行为在每次程序执行时都不尽相同。我们可以看到 Goroutine 是真的在并行执行。两个 Goroutine 都同时开始运行,而且你能看到它们都在争夺标准输出来输出它们的结果。

结语

我们可以为调度器添加更多的逻辑处理器,但这并不意味着我们应该这么做。Go 开发团队如此设计运行时的默认设置是有原因的,由其是默认只使用一个逻辑处理器的设置。你要记住,随意地添加逻辑处理器以并行地执行 Goroutine 并不一定能为你的程序带来更高的性能。永远要记得为你的程序进行基准测试和性能分析并在绝对必要时才去修改 Go 的运行时配置。

为我们的程序引入并发的问题在于,我们的 Goroutine 最终都会开始尝试访问同一个资源,有可能还是同时尝试。对共享资源执行的读写操作必须是原子的。也就是说,同一时间只能有一个 Goroutine 进行读写,否则我们的程序就会出现竞态条件。要想了解更多有关竞态条件的事可以阅读我的另一篇文章

Channel 便是我们在 Go 中编写安全而优雅的并发应用程序的方法,使用它可以很好地消除竞态条件并让编写并发程序变得有趣起来。既然现在我们知道 Goroutine 是如何工作、如何被调度以及如何能并行执行,Channel 就是下一个我们需要学习的东西了。

作者

Robert Peng

发布于

2017-06-14

更新于

2017-06-14

许可协议

评论