目录
一、前言
goroutine 是 Go 并行设计的核心,其本质就是协程,协程比线程小,也叫轻量级线程,它可以轻松创建上万个而不会导致系统资源的枯竭。十几个 goroutine 可能体现在底层就是五六个线程,一个线程可以有任意多个协程,但是某一个时刻只能有一个协程在运行,多个协程共享该线程分配到的计算机资源。创建 goroutine 只需在函数调用前加上 go 语句,就可以创建并发执行单元,开发人员无需了解任何执行细节,调度器会自动安排其到合适的系统线程上执行。
二、goroutine 的理解与使用
(一)goroutine 入门
首先如何创建一个 goroutine ,在调用的函数前加上 go 语句,该函数除了匿名函数也可以是 golang 包中自带的方法。
go func() {
// to do something
}()
//比如:
go fmt.Println(1) // 输出 1
在并发的程序中,通常是将一个过程分为几块,然后让每个 goroutine 各自负责一块工作,当程序启动时,主函数在一个单独的 goroutine 中运行,我们叫他 main goroutine , 启动程序时 main goroutine 则立刻运行,其他 goroutine 会用 go 语句来创建。下面定义了 3 个函数,前两个函数加上 go 创建两个 goroutine,分别为 goroutine_1, goroutine_2, 第 3 个 func_1 为普通函数,作为对比用。
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("goroutine_1")
}()
go func() {
fmt.Println("goroutine_2")
}()
func() {
fmt.Println("func_1")
}()
}
刚开始运行的时候只有 output1 这种输出,多运行几次之后出现了 output2 的输出。
两个输出结果不一样,第一个输出结果 geroutine_1 , goroutine_2都没有输出,第二个输出也只是出现 goroutine_1。造成以上问题是因为 goroutine 的运行时需要获得时间片,获取 CPU 时间片才能运行 goroutine,在运行队列中等待调用,并不会马上执行, 在 main goroutine 中的 fun_1 则立刻运行。现在再看上面的程序,一共定义了两个 goroutine ,执行完两个定义语句之后,goroutine 不会马上运行,而是等待获取时间片执行,然而这段代码还没等到两个 goroutine 被调度执行 main goroutine 就结束运行并退出,main goroutine 退出后,其他的工作 goroutine 也会自动退出,所以就看不到两个 goroutine 的输出。为了看到两个 goroutine 的执行可以暂时在程序的末尾加上休眠时间。
package main
import (
"fmt"
"time"
)
func main() {
go func() {
fmt.Println("goroutine_1")
}()
go func() {
fmt.Println("goroutine_2")
}()
func() {
fmt.Println("func_1")
}()
time.Sleep(time.Second) // 程序休眠1s
}
这时候就看到了两个 goroutine 输出结果。因为 goroutinue 的调度执行是随机的,所以每次运行的结果可能不一样,以下是两种输出的结果。fun_1因为是马上运行的,所以第一个输出。
但是我们总不能每次在运行程序的时候都要休眠,毕竟我们不能自己算出所有的 goroutine 什么时候被调用执行,以及他们什么时候才能结束,所以 sync 包中的 WaitGroup 就可以用来判断 goroutine 是否运行结束的方法。
(二)sync 包同步 goroutine
sync 包
sync.WaitGroup : 一个计数信号量,用来记录并维护 goroutine。
wg.Add(n) : 代表有 n 个正在运行的 goroutine。
wg.Done() : 其源码是 wg.Add(-1),说明有一个 goroutine 运行结束。
wg.Wait() : 如果 WaitGroup 的值大于 0 ,Wait 方法就会阻塞,直到等待队列的值到 0 并最终释放 main 函数。
package main
import (
"fmt"
"sync"
)
func main() {
// WaitGroup 是一个计数信号量,被
// 用来记录并维护 goroutine
var wg sync.WaitGroup
wg.Add(2) // 共有两个 goroutine
go func(){
fmt.Println("goroutine 1")
wg.Done() // goroutine 运行结束
}()
go func(){
fmt.Println("goroutine 2")
wg.Done()
}()
func() {
fmt.Println("func_1")
}()
wg.Wait() // 阻塞等待所有 goroutine 运行完毕
//time.Sleep(time.Second) // 程序休眠1s
}
这样就可以保证所有的 goroutine 都被运行。 以下代码可以显示了添加 WaitGroup 阻塞判断和没有阻塞判断程序所耗时间。首先是没有加 sync.WaitGroup 的代码。
package main
import (
"fmt"
"time"
)
func main() {
startTime := time.Now().UnixNano()
go func() {
fmt.Println("goroutine_1")
}()
go func() {
fmt.Println("goroutine_2")
}()
func() {
fmt.Println("func_1")
}()
endTime := time.Now().UnixNano()
nanoSeconds := float64((endTime - startTime))
fmt.Println(nanoSeconds)
}
输出结果为:
在这 27000 纳秒内,main goroutine 程序就已经运行完毕,但是 goroutine_1, goroutine_2 没有被调用执行。然后我们将阻塞等待机制加入以上代码中,来对比两段程序的运行时间。
package main
import (
"fmt"
"sync"
"time"
)
func main() {
startTime := time.Now().UnixNano()
var wg sync.WaitGroup
wg.Add(2)
go func() {
fmt.Println("goroutine_1")
wg.Done()
}()
go func() {
fmt.Println("goroutine_2")
wg.Done()
}()
func() {
fmt.Println("func_1")
}()
wg.Wait()
endTime := time.Now().UnixNano()
nanoSeconds := float64((endTime - startTime))
fmt.Println(nanoSeconds)
}
得到的输出为:
这段程序运行了 goroutine_1, goroutine_2 ,一共花费了 65000 纳秒的时间。 很显然加了阻塞判断机制的程序为了等待 goroutine_1, goroutine_2 被调用执行,花费了更多的时间,当然这个时间并不是唯一的,goroutine 的调用时随机的,可能两个 goroutine 调用的晚,程序花费较多的时间,goroutine 调用得早,花费较少的时间,这个视系统的环境而定。
三、并发与并行
(一)并发与并行的区别
Golang在运行时候会在逻辑处理器上调度 goroutine 来运行。每个逻辑处理器分别绑定绑定到单个操作系统线程。golang 运行时会默认为每个可用的物理处理器分配一个处理器,所以我们在运行代码的时候有时候会用 runtime.GOMAXPROCS(N) 来设置逻辑处理器的数量,其中 N 代表 N 个逻辑处理器,这些处理器会被用于执行所有的 goroutine。
关于并发与并行的区别,知乎上很赞的回答,可以帮助理解两者的不同。
你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。并发的关键是你有处理多个任务的能力,不一定要同时。
并行的关键是你有同时处理多个任务的能力。所以我认为它们最关键的点就是:是否是『同时』。
来源:知乎
并发是当正在运行的 goroutine 发生阻塞时,比如读取io,或者打开一个文件,通常都需要花费一定的时间等待。这类调用会让线程和 goroutine 从逻辑处理器分离,该线程会阻塞,并等待系统调用返回。调度器会重新创建一个新的线程,然后再继续从本地执行队列中选择一个 goroutine 运行。一旦被阻塞的 goroutine 执行完成并返回是,对应的 goroutine 会被放回本地运行队列,而之前的线程会被保存,以备之后继续使用,调度情况如图1.
图1 Go 调度器如何管理 goroutine
并发(concurrency)与并行(parallelism)是不同的。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情。在很多情况下,并发比并行的效果好,因为操作系统的和硬件的总资源一般很少,但能支持系统做很多事情。这种"使用较少资源做更多事情”的哲学也是知道 Go 语言设计的哲学。图2 展示了并发与并行的区别。
图2 并发与并行的区别
(二)runtime 包对 goroutine 控制
runtime 包
runtime.GOMAXPROCS(N) 设置 N 个逻辑处理器给调度器使用。当 N值为 runtime.NumCPU(),代表给每个可用的核心分配一个逻辑处理器。当 N 为 1,最多同时只能有一个 goroutine 被执行,当 N 为 2 时,两个 goroutine 可以被一起执行。
runtime.Gosched() 用于让出 CPU 时间片,让出当前 goroutine 的执行权限,调度器安排其他等待的任务先执行,并在下次再次获得 CPU 时间轮片的时候,从让出该 CPU 的位置恢复执行。
runtime.Goexit() 终止当前 goroutine 执行。
现在可以在刚才的代码上加上 go 语句,看看逻辑处理器的调度。先分配逻辑处理器,同时将两个 goroutine 方法改为输出 1 ~ 10。
// 输出 1 ~ n 的数字
func printNum(goroutine string, n int) {
// 设置随机种子,随机种子不同,生成的随机数不同。
rand.Seed(time.Now().Unix())
for i := 1; i <= n; i++ {
fmt.Printf("%s: %d\n", goroutine, i)
// 程序随机休眠 0~1 秒,方便观察 goroutine 之间的切换
time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
}
// 输出结束标志
fmt.Printf("%s: completed\n", goroutine)
}
输出 1~n 之间的数字,然后通过随机长度休眠模拟阻塞,阻塞时间为 [0,n) 秒,方便观察 goroutine 之间切换执行的并发效果。将以上代码整合到前面的程序中。
package main
import (
"fmt"
"math/rand"
"runtime"
"sync"
"time"
)
// 输出 1 ~ n 的数字
func printNum(goroutine string, n int) {
// 设置随机种子,随机种子不同,生成的随机数不同。
rand.Seed(time.Now().Unix())
for i := 1; i <= n; i++ {
fmt.Printf("%s: %d\n", goroutine, i)
// 程序随机休眠 0~1 秒,方便观察 goroutine 之间的切换
time.Sleep(time.Duration(rand.Intn(2)) * time.Second)
}
// 输出结束标志
fmt.Printf("%s: completed\n", goroutine)
}
func main() {
// WaitGroup 是一个计数信号量,被
// 用来记录并维护 goroutine
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
wg.Add(2) // 共有两个 goroutine
go func(){
printNum("1", 10)
wg.Done() // goroutine 运行结束
}()
go func(){
printNum("2", 10)
wg.Done()
}()
func() {
fmt.Println("func_1")
}()
wg.Wait() // 阻塞等待所有 goroutine 运行完毕
//time.Sleep(time.Second) // 程序休眠1s
}
运行程序后可以发现,当一个程序阻塞,调度器会切换到另外一个程序执行。以下为该程序的输出结果。
func_1
goroutine_2: 1
goroutine_1: 1 // 切换到 goroutine_1
goroutine_1: 2
goroutine_2: 2 // 切换到 goroutine_2
goroutine_2: 3
goroutine_2: 4
goroutine_2: 5
goroutine_2: 6
goroutine_2: 7
goroutine_2: 8
goroutine_2: 9
goroutine_1: 3 // 切换到 goroutine_1
goroutine_1: 4
goroutine_2: 10 // 切换到 goroutine_2
goroutine_2: completed
goroutine_1: 5 // 切换到 goroutine_1
goroutine_1: 6
goroutine_1: 7
goroutine_1: 8
goroutine_1: 9
goroutine_1: 10
goroutine_1: completed
Process finished with exit code 0
上图显示先是 goroutine_2 在运行输出,输出到数字 1 的时候,调度器将正在运行的 goroutine_2 转换为 goroutine_1,之后 goroutine_1 输出了数字 1~2,再次切换到 goroutine_2。通过这样的切换调度使得两个 goroutine 完成所有的工作。每次运行结果可能会不一样。如果将上面的休眠阻塞时间去掉,一般 goroutine 就会执行完所有的工作再切换另外一个。即 goroutine_1 输出完1~n,之后再切换 goroutine_2 输出 1~ n。对于 goroutine 的执行顺序,如果没有利用同步加以控制,那么 goroutine 的执行顺序是无法预测的。如果看到相同的执行顺序是因为测试的次数太少。
四、思考题
文章写到这里,我们对 goroutine 有了初步的了解,我在网上找了两个有趣的程序,用来加深理解。
程序1:
package main
import (
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
for i := 0; i < 10; i++ {
go func() {
println(i)
}()
}
time.Sleep(time.Second)
}
程序2:
package main
import (
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(1)
for i := 0; i < 10; i++ {
go println(i)
}
time.Sleep(time.Second)
}
请先思考一下两段程序的输出结果,相信理解了这两段程序之后可以对 goroutine 有更进一步的理解,输出结果在文末公布。
公布答案:
// 程序1 输出 10 个 10, 程序2 乱序输出 0~9 。为什么和程序1 结果不一样?
解析:
首先程序1:我们在 for 循环里面定义了一个匿名函数,匿名函数的功能是输出 i 的值,结果输出 10 个 10。前面讲过了,除了 goroutine 会等待系统调用执行,其他代码会立刻运行,所以 for 循环会很快就结束,同时为了看到 goroutine 被调用执行,我们让 main 函数休眠 1 秒。当程序运行到 go 语句的时候,编译器就把运行 goroutine 所需的函数和参数都保存了,程序1 中编译器保存的是{main.func_xxx, nil},第一个参数为函数,第二个参数为该函数接收的参数,此时应该注意到,该匿名函数是无参的,所以保存参数为 nil。当系统中的 goroutine 被调用执行时, 匿名函数里面的代码开始执行,此时需要用到 i 的值,然而 for 循环已经执行完毕,当前的 i 值为 10,所以后面输出的所有值都是10。
其次是程序2:相比于程序1 唯一不同的地方就是 go 语句后面的内容,代码看起来没什么差别,但是结果却不同。经过程序1 的分析,我们知道运行到 go 语句时,编译器会保存方法和参数,程序2 中编译器保存的是{println, current_i} ,不同的是,这次编译器保存的方法为 println 且是有参数的,参数值为当前 i 的值,所以 for 循环执行了 10次,分别传入 i 的值为 0~9 ,因此当 goroutine 被调用执行的时候,输出的值为乱序的 0~9,之所以乱序,这是因为 goroutine 的调用时随机的,所以每次执行 0~9 序列都不尽相同。
五、参考文献
[1]《 Go 语言实战》
[3] 并发与并行的区别?