本文由DeepL翻译 原文见末尾
简介
当新人加入Go-Miami小组时,他们总是写道,他们想了解更多关于Go的并发模型。并发似乎是这门语言的一大热门词汇。对我来说,当我第一次听说Go的时候就是这样。是Rob Pike的Go并发模式视频最终说服了我,我需要学习这门语言。
为了理解Go是如何让编写并发程序变得更容易、更不容易出错的,我们首先需要了解什么是并发程序,以及这种程序所带来的问题。我不会在这篇文章中谈论CSP(通信顺序进程),它是Go实现通道的基础。本帖将重点讨论什么是并发程序,goroutines发挥的作用,以及GOMAXPROCS环境变量和运行时函数如何影响Go运行时的行为和我们编写的程序。
进程和线程
当我们运行一个应用程序时,比如我用来写这篇文章的浏览器,操作系统就会为该应用程序创建一个进程。进程的工作是像一个容器一样,存放应用程序在运行时使用和维护的所有资源。这些资源包括诸如内存地址空间、文件的句柄、设备和线程。
线程是一个执行路径,由操作系统安排,针对处理器执行我们在函数中编写的代码。一个进程开始时有一个线程,即主线程,当该线程终止时,进程也就终止了。这是因为主线程是应用程序的起源。主线程可以依次启动更多的线程,而这些线程可以启动更多的线程。
操作系统将一个线程安排在一个可用的处理器上运行,无论该线程属于哪个进程。每个操作系统都有自己的算法来做这些决定,对我们来说,最好是编写不针对某种算法的并发程序。另外,这些算法会随着操作系统的每一个新版本而改变,所以这是一个危险的游戏。
goroutines和并行性
Go中的任何函数或方法都可以被创建为一个goroutine。我们可以认为主函数是作为一个goroutine来执行的,但是Go的运行时间并没有启动这个goroutine。goroutine被认为是轻量级的,因为它们使用的内存和资源很少,加上它们的初始堆栈大小很小。在1.2版本之前,堆栈大小从4K开始,现在从1.4版本开始是8K。堆栈有能力根据需要增长。
操作系统将线程安排在可用的处理器上运行,Go运行时将goroutine安排在逻辑处理器内运行,该处理器与单个操作系统线程绑定。默认情况下,Go运行时分配了一个逻辑处理器来执行为我们的程序创建的所有goroutine。即使只有这一个逻辑处理器和操作系统线程,也可以安排成百上千个goroutine同时运行,效率和性能都很惊人。不建议增加一个以上的逻辑处理器,但如果你想并行运行goroutines,Go提供了通过GOMAXPROCS环境变量或运行时函数增加更多的能力。
并发性不是并行性。并行是指两个或多个线程针对不同的处理器同时执行代码。如果你将运行时配置为使用一个以上的逻辑处理器,调度器将在这些逻辑处理器之间分配goroutine,这将导致goroutine在不同的操作系统线程上运行。然而,为了获得真正的并行性,你需要在具有多个物理处理器的机器上运行你的程序。否则,尽管Go运行时使用了多个逻辑处理器,但goroutines将在单个物理处理器上并发运行。
并发实例
让我们建立一个小程序,显示Go在并发地运行goroutines。在这个例子中,我们用一个逻辑处理器来运行代码。
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。当我们运行这个程序时,我们得到以下输出。
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中设置一个休眠,从而交换goroutine。
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。调用sleep会导致调度器交换两个goroutine。
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做它的事情。
并行例子
在我们过去的两个例子中,goroutine是同时运行的,但不是并行的。让我们对代码做一个修改,使goroutine能够并行运行。我们所要做的就是给调度器添加第二个逻辑处理器,以使用两个线程。
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")
}
以下是该程序的输出。
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
每次我们运行这个程序都会得到不同的结果。调度器在每次运行时的表现并不完全相同。我们可以看到,这些程序是真正的并行运行。两个goroutines都立即开始运行,你可以看到它们都在争夺标准输出来显示它们的结果。
结论
我们可以添加多个逻辑处理器供调度器使用,但这并不意味着我们应该这样做。Go团队以这样的方式为运行时设置默认值是有原因的。特别是只使用一个逻辑处理器的默认值。要知道,任意增加逻辑处理器和并行运行goroutines并不一定会为你的程序提供更好的性能。始终对你的程序进行剖析和基准测试,并确保只有在绝对必要时才改变Go运行时配置。
在我们的应用程序中建立并发性的问题是,我们的goroutines最终会试图访问相同的资源,可能是在同一时间。对共享资源的读和写操作必须始终是原子的。换句话说,读和写必须由一个goroutine一次完成,否则就会在我们的程序中产生竞赛条件。要了解更多关于竞赛条件的信息,请阅读我的文章。
通道是Go中编写安全和优雅的并发程序的方法,它消除了竞赛条件,使编写并发程序重新变得有趣。现在我们知道了goroutines是如何工作的,如何安排,如何使其并行运行,通道是我们需要学习的下一个问题。
原文地址: https://www.ardanlabs.com/blog/2014/01/concurrency-goroutines-and-gomaxprocs.html