曹大帶我學 Go(2)—— 迷惑的 goroutine 執行順序

你好,我是小 X。

曹大最近開 Go 課程了,小 X 正在和曹大學 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 以及碼農桃花源~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/WWfm7Ui7g_gGlb8XkIZigg