曹大带我学 Go—— 迷惑的 goroutine 执行顺序

Posted qcrao

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了曹大带我学 Go—— 迷惑的 goroutine 执行顺序相关的知识,希望对你有一定的参考价值。

你好,我是小X。

曹大最近开 Go 课程了,小X 正在和曹大学 Go。

这个系列会讲一些从课程中学到的让人醍醐灌顶的东西,拨云见日,带你重新认识 Go。

上一篇文章我们讲了 Go 调度的本质是一个生产-消费流程。

生产端是正在运行的 goroutine 执行 go func(){}() 语句生产出 goroutine 并塞到三级队列中去。

消费端则是 Go 进程中的 m 在不断地执行调度循环,从三级队列中拿到 goroutine 来运行。

生产-消费过程

今天我们来通过 2 个实际的代码例子来看看 goroutine 的执行顺序是怎样的。

第一个例子

首先来看第一个例子:

package main

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

func main() {
    runtime.GOMAXPROCS(1)
    for i := 0; i < 10; i++ {
        i := i
        go func() {
            fmt.Println(i)
        }()
    }

    var ch = make(chan int)
    <- ch
}

首先通过 runtime.GOMAXPROCS(1) 设置只有一个 P,接着创建了 10 个 goroutine,并分别打印出 i 值。你可以先想一下输出会是什么,再对着答案会有更深入的理解。

揭晓答案:

9
0
1
2
3
4
5
6
7
8
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
        /home/raoquancheng/go/src/hello/main.go:16 +0x96
exit status 2

程序输出的 fatal error 是因为 main goroutine 正在从一个 channel 里读数据,而这时所有的 channel 都已经挂了,因此出现死锁。这里先忽略这个,只需要关注 i 输出的顺序:9, 0, 1, 2, 3, 4, 5, 6, 7, 8

我来解释一下原因:因为一开始就设置了只有一个 P,所以 for 循环里面“生产”出来的 goroutine 都会进入到 P 的 runnext 和本地队列,而不会涉及到全局队列。

每次生产出来的 goroutine 都会第一时间塞到 runnext,而 i 从 1 开始,runnext 已经有 goroutine 在了,所以这时会把 old goroutine 移动 P 的本队队列中去,再把 new goroutine 放到 runnext。之后会重复这个过程……

因此这后当一次 i 为 9 时,新 goroutine 被塞到 runnext,其余 goroutine 都在本地队列。

之后,main goroutine 执行了一个读 channel 的语句,这是一个好的调度时机:main goroutine 挂起,运行 P 的 runnext 和本地可运行队列里的 gorotuine。

而我们又知道,runnext 里的 goroutine 的执行优先级是最高的,因此会先打印出 9,接着再执行本地队列中的 goroutine 时,按照先进先出的顺序打印:0, 1, 2, 3, 4, 5, 6, 7, 8

是不是非常有意思?

第二个例子

别急,我们再来看第 2 个例子:

package main

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

func main() {
    runtime.GOMAXPROCS(1)
    for i := 0; i < 10; i++ {
        i := i
        go func() {
            fmt.Println(i)
        }()
    }

    time.Sleep(time.Hour)
}

和第一个例子的不同之处是我们把读 channel 的代码换成 Sleep 操作。这一次,你还能正确回答 i 的输出顺序是什么吗?

我们直接揭晓答案。

当我们用 go1.13 运行时:

$ go1.13.8 run main.go

0
1
2
3
4
5
6
7
8

而当我们用 go1.14 及之后的版本运行时:

$ go1.14 run main.go

9
0
1
2
3
4
5
6
7
8

可以看到,用 go1.14 及之后的版本运行时,输出顺序和之前的一致。而用 go1.13 运行时,却先输出了 0,这又是什么原因呢?

这就要从 Go 1.14 修改了 timer 的实现开始说起了。

go 1.13 的 time 包会生产一个名字叫 timerproc 的 goroutine 出来,它专门用于唤醒挂在 timer 上的时间未到期的 goroutine;因此这个 goroutine 会把 runnext 上的 goroutine 挤出去。因此输出顺序就是:0, 1, 2, 3, 4, 5, 6, 7, 8, 9

go 1.14 把这个唤醒的 goroutine 干掉了,取而代之的是,在调度循环的各个地方、sysmon 里都是唤醒 timer 的代码,timer 的唤醒更及时了,但代码也更难看懂了。所以,输出顺序和第一个例子是一致的。

总结

今天通过 2 个实际的例子再次复习了 Go 调度消费端的流程,也学到了 time 包在不同 go 版本下的不同之处以及它对程序输出造成的影响。

有些人还会把例子中的 10 改成比 256 更大的数去尝试。曹大说这是考眼力,不要给自己找事。因为这时 P 的本地队列装不下这么多 goroutine 了,只能放到全局队列。这下程序的输出顺序就不那么直观了。

所以,记住本文的核心内容就行了:

  1. runnext 的优先级最高。

  2. time.Sleep 在老版本中会创建一个 goroutine,在 1.14(包含)之后不会创建 goroutine 了。

如果被别人考到,知道三级队列,以及 time 包在 1.14 的变更就行了。

好了,这就是今天全部的内容了~ 我是小X,我们下期再见~


欢迎关注曹大的 TechPaper 以及码农桃花源~

以上是关于曹大带我学 Go—— 迷惑的 goroutine 执行顺序的主要内容,如果未能解决你的问题,请参考以下文章

曹大带我学 Go——调度的本质

『曹大带我学 Go 』系列文章汇总

曹大带我学 Go(12)—— 面向火焰图编程

曹大带我学 Go—— 哪里来的 goexit

曹大带我学 Go—— 初识 ast 的威力

曹大带我学 Go—— 一个打点引发的事故