Go 的并发机制
4.2 goroutine
go 关键字时用户程序启用 goroutine 的唯一途径。
4.2.1 go 语句和 goroutine
go 语句是由 go 关键字和表达式组成的,其中调用表达式使用的比较多。调用表达式所表达的就是针对函数或方法的调用,其中的函数可以是命名的,也可以是匿名的。
go func() {
println("goroutine")
}()
无论是否需要传递参数值给匿名函数,都需要在最后加上圆括号,它们代表了对函数的调用行为,也是调用表达式的必要组成部份。
Go 运行时系统对 go 语句中的函数的执行时并发的,更确切地说,当 go 语句执行的时候,其中的 go 函数会被单独放入一个 goroutine 中。这之后,该 go 函数的执行会独立于当前 goroutine 运行。
至于执行时机,一句话:go 函数并发执行,但是谁先谁后并不确定。还有就是,go 函数是可以有结果声明的,但是其返回的结果值会在执行完成时被丢弃。(可以使用 channel 传递给其他 goroutine)
package main
func main() {
go println("hello")
}
上述程序执行,大概率不会打印任何东西。一旦 main 函数执行结束,就意味着 Go 程序运行的结束,但这个时候运行 go 函数的 G 可能还没来得及执行。
可以使用 time.Sleep 或者 runtime.Gosched 暂停 main 函数所在的 goroutine,这样子就会执行打印的 goroutine。但是,复杂情况下这种方式也并不保险。
再介绍一个有趣的例子:
package main
import (
"fmt"
"time"
)
func main() {
names := []string{"rob", "marry", "bob", "alice"}
for _, name := range names {
go func() {
fmt.Printf("name: %v\n", name)
}()
}
time.Sleep(time.Millisecond)
}
上述程序运行结果会是什么呢?不妨先猜测一下,下面揭晓答案:
name: alice
name: alice
name: alice
name: alice
当然,如果读者拷贝上述代码在自己本地运行,可能并不是这个执行结果。那你的 go 版本一定是 1.22 版本及以上的,因为这些版本已经对此进行了优化调整。(之前是for 循环的变量只创建一次,在每次循环的时候都会更新,而闭包在访问 name 的时候实际都访问的是同一个内存地址,所以最终打印都是同一个值。)我们这里考虑1.21 版本之前的,因为实际运用中还有很多环境可能并没有升级到这个版本。
那么我们需要怎么调整来避免这个问题呢?只需要给闭包函数添加一个参数声明(who)即可。因为传入时,name的值会被复制并且在 go 函数中由 who 指代。后续 name 值改变,不会影响 go 函数里面的值。
package main
import (
"fmt"
"time"
)
func main() {
names := []string{"rob", "marry", "bob", "alice"}
for _, name := range names {
go func(who string) {
fmt.Printf("name: %v\n", who)
}(name)
}
time.Sleep(time.Millisecond)
}
4.2.2 主 goroutine 的运作
封装 main 函数的 goroutine 称为主 goroutine,由 runtime.m0 负责运行。主 goroutine 的工作内容如下:
- 设定每一个 goroutine 所能申请的栈空间的最大尺寸(32位250MB,64位1GB),如果后续有某个 goroutine 超过了,就会出现一个栈溢出的运行时恐慌,随机终止 Go 程序的运行。
- 随后,主 goroutine 会在当前 M 地 g0 上执行系统监测任务(为调度器查漏补缺)
- 接着,进行一系列的初始化工作
- 检查当前M 是否是 runtime.m0
- 创建一个特殊的 defer 语句,用于主 goroutine 退出时,做必要的善后处理
- 启用专用于后台清扫内存垃圾的 goroutine,并设置 GC 可用的标识
- 执行 main 包中的 init 函数
- 执行 main 函数,执行之后,检查主 goroutine 是否引发了运行时恐慌,是的话进行必要处理并结束自己以及当前进程的运行
在main 函数执行期间,运行时系统会根据 Go 程序中的 go 语句,复用或新建 goroutine 来封装 go 函数。这些 goroutine 都会放入相应 P 的可运行 G 队列中,然后等待调度器的调度。这里的等待时机虽然很短,但是可能会永远失去运行时机。