單核 CPU,開兩個 Goroutine,其中一個死循環,會怎麼樣?

大家好,我是煎魚。

今天的男主角,是與 Go 工程師有調度相關的知識,那就是 “單核 CPU,開兩個 Goroutine,其中一個死循環,會怎麼樣?

請在此處默唸自己心目中的答案,再往和煎魚一起研討一波 Go 的技術哲學。

問題定義

針對這個問題,我們需要把問題剖開來看看,其具有以下幾個元素:

根據這道題的題意,可大致理解其想要問的是 Go 調度相關的一些知識理解。

單核 CPU

第一個要點,就是要明確 “計算機只有一個單核 CPU” 這一個變量定義,對 Go 程序會產生什麼影響,否則很難繼續展開。

既然明確涉及 Goroutine,這裏就會考察到你對 Go 的調度模型 GMP 的基本理解了。

從單核 CPU 來看,最大的影響就是 GMP 模型中的 P,因爲 P 的數量默認是與 CPU 核數(GOMAXPROCS)保持一致的。

這三者交互實際來源於 Go 的 M: N 調度模型。也就是 M 必須與 P 進行綁定,然後不斷地在 M 上循環尋找可運行的 G 來執行相應的任務。

Goroutine 受限

第二個要點,就是 Goroutine 的數量和運行模式都是受限的。有兩個 Goroutine,一個 Goroutine 在死循環,另外一個在正常運行。

這可以理解爲 Main Goroutine + 起了一個新 Goroutine 跑着死循環,因爲本身 main 函數就是一個主協程在運行着,沒毛病。

需要注意的是,Goroutine 裏跑着死循環,也就是時時刻刻在運行着 “業務邏輯”。這塊需要與單核 CPU 關聯起來,考慮是否會一直阻塞住,把整個 Go 進程運行給 hang 住了

注:但若是在現場面試,可以先枚舉出這種場景,詮釋清楚後。再補充提問面試官,是否這類場景?

Go 版本的問題

第三個要點,是一個隱性的拓展點。如果你是一個老 Go 粉,經常關注 Go 版本的更新情況(至少大版本),則應該會知道 Go 的調度是會變動的(會在後面的小節講解)。

因此本文這個問題,在不同的 Go 語言版本中,結果可能會是不一樣的。但是面試官並沒有指出,這裏就需要考慮到:

  1. 面試官故意不指出,等着你指出。

  2. 面試官沒留意到這塊,沒想那麼多。

  3. 面試官自己都不知道這塊的 “新” 知識,他的知識可能還是老的。

如果你注意到了,是一個小亮點,說明你在這塊有一定的知識積累。

實戰演練

在剛剛過去的 3s 中,你已經把上面的考量都在大腦中過了一遍。接下來我們正式進入實戰演練,構造一個例子:

// Main Goroutine 
func main() {
    // 模擬單核 CPU
    runtime.GOMAXPROCS(1)
    
    // 模擬 Goroutine 死循環
    go func() {
        for {
        }
    }()

    time.Sleep(time.Millisecond)
    fmt.Println("腦子進煎魚了")
}

在上面的例子中,我們通過以下方式達到了面試題所需的目的:

思考一下:這段程序是否會輸出 ” 腦子進煎魚了 “ 呢,爲什麼

答案是:

爲什麼

這是怎麼回事呢,這兩種情況分別對應了什麼原因和標準,Go 版本的變更有帶來了什麼影響?

不會輸出任何結果

顯然,這段程序是有一個 Goroutine 是正在執行死循環,也就是說他肯定無法被搶佔。

這段程序中更沒有涉及主動放棄執行權的調用(runtime.Gosched),又或是其他調用(可能會導致執行權轉移)的行爲。因此這個 Goroutine 是沒機會溜號的,只能一直打工...

那爲什麼主協程(Main Goroutine)會無法運行呢,其實原因是會優先調用休眠,但由於單核 CPU,其只有唯一的 P。唯一的 P 又一直在打工不願意下班(執行 for 死循環,被迫無限加班)。

因此主協程永遠沒有機會唄調度,所以這個 Go 程序自然也就一直阻塞在了執行死循環的 Goroutine 中,永遠無法下班(執行完畢,退出程序)。

正常輸出結果

那爲什麼 Go1.14 及以後的版本,又能正常輸出了呢?

主要還是在 Go1.14 實現了基於信號的搶佔式調度,以此來解決上述一些仍然無法被搶佔解決的場景。

主要原理是 Go 程序在啓動時,會在 runtime.sighandler 方法註冊並且綁定 SIGURG 信號:

func mstartm0() {
 ...
 initsig(false)
}

func initsig(preinit bool) {
 for i := uint32(0); i < _NSIG; i++ {
  ...
  setsig(i, funcPC(sighandler))
 }
}

綁定相應的 runtime.doSigPreempt 搶佔方法:

func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
    ...
    if sig == sigPreempt && debug.asyncpreemptoff == 0 {
        // 執行搶佔
        doSigPreempt(gp, c)
    }
}

同時在調度的 runtime.sysmon 方法會調用 retake 方法處理一下兩種場景:

該方法會檢測符合場景的 P,當滿足上述兩個場景之一時,就會發送信號給 M。M 收到信號後將會休眠正在阻塞的 Goroutine,調用綁定的信號方法,並進行重新調度。以此來解決這個問題。

注:在 Go 語言中,sysmon 會用於檢測搶佔。sysmon 是 Go 的 Runtime 的系統檢測器,sysmon 可進行 forcegc、netpoll、retake 等一系列騷操作(via @xiaorui)。

總結

在這篇文章中,我們針對 ” 單核 CPU,開兩個 Goroutine,其中一個死循環,會怎麼樣?“ 這個問題進行了展開剖析。

針對不同 Go 語言版本,不同程序邏輯的表現形式都不同,但背後的基本原理都是與 Go 調度模型和搶佔有關。


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