單核 CPU,開兩個 Goroutine,其中一個死循環,會怎麼樣?
大家好,我是煎魚。
今天的男主角,是與 Go 工程師有調度相關的知識,那就是 “單核 CPU,開兩個 Goroutine,其中一個死循環,會怎麼樣?”
請在此處默唸自己心目中的答案,再往和煎魚一起研討一波 Go 的技術哲學。
問題定義
針對這個問題,我們需要把問題剖開來看看,其具有以下幾個元素:
-
運行 Go 程序的計算機只有一個單核 CPU。
-
兩個 Goroutine 在運行。
-
一個 Goroutine 死循環。
根據這道題的題意,可大致理解其想要問的是 Go 調度相關的一些知識理解。
單核 CPU
第一個要點,就是要明確 “計算機只有一個單核 CPU” 這一個變量定義,對 Go 程序會產生什麼影響,否則很難繼續展開。
既然明確涉及 Goroutine,這裏就會考察到你對 Go 的調度模型 GMP 的基本理解了。
從單核 CPU 來看,最大的影響就是 GMP 模型中的 P,因爲 P 的數量默認是與 CPU 核數(GOMAXPROCS)保持一致的。
-
G:Goroutine,實際上我們每次調用
go func
就是生成了一個 G。 -
P:Processor,處理器,一般 P 的數量就是處理器的核數,可以通過
GOMAXPROCS
進行修改。 -
M:Machine,系統線程。
這三者交互實際來源於 Go 的 M: N 調度模型。也就是 M 必須與 P 進行綁定,然後不斷地在 M 上循環尋找可運行的 G 來執行相應的任務。
Goroutine 受限
第二個要點,就是 Goroutine 的數量和運行模式都是受限的。有兩個 Goroutine,一個 Goroutine 在死循環,另外一個在正常運行。
這可以理解爲 Main Goroutine + 起了一個新 Goroutine 跑着死循環,因爲本身 main 函數就是一個主協程在運行着,沒毛病。
需要注意的是,Goroutine 裏跑着死循環,也就是時時刻刻在運行着 “業務邏輯”。這塊需要與單核 CPU 關聯起來,考慮是否會一直阻塞住,把整個 Go 進程運行給 hang 住了?
注:但若是在現場面試,可以先枚舉出這種場景,詮釋清楚後。再補充提問面試官,是否這類場景?
Go 版本的問題
第三個要點,是一個隱性的拓展點。如果你是一個老 Go 粉,經常關注 Go 版本的更新情況(至少大版本),則應該會知道 Go 的調度是會變動的(會在後面的小節講解)。
因此本文這個問題,在不同的 Go 語言版本中,結果可能會是不一樣的。但是面試官並沒有指出,這裏就需要考慮到:
-
面試官故意不指出,等着你指出。
-
面試官沒留意到這塊,沒想那麼多。
-
面試官自己都不知道這塊的 “新” 知識,他的知識可能還是老的。
如果你注意到了,是一個小亮點,說明你在這塊有一定的知識積累。
實戰演練
在剛剛過去的 3s 中,你已經把上面的考量都在大腦中過了一遍。接下來我們正式進入實戰演練,構造一個例子:
// Main Goroutine
func main() {
// 模擬單核 CPU
runtime.GOMAXPROCS(1)
// 模擬 Goroutine 死循環
go func() {
for {
}
}()
time.Sleep(time.Millisecond)
fmt.Println("腦子進煎魚了")
}
在上面的例子中,我們通過以下方式達到了面試題所需的目的:
-
設置
runtime.GOMAXPROCS
方法模擬了單核 CPU 下只有一個 P 的場景。 -
運行一個 Goroutine,內部跑一個 for 死循環,達到阻塞運行的目的。
-
運行一個 Goroutine,主函數(main)本身就是一個 Main Goroutine。
思考一下:這段程序是否會輸出 ” 腦子進煎魚了 “ 呢,爲什麼?
答案是:
-
在 Go1.14 前,不會輸出任何結果。
-
在 Go1.14 及之後,能夠正常輸出結果。
爲什麼
這是怎麼回事呢,這兩種情況分別對應了什麼原因和標準,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。
-
搶佔運行時間過長的 G。
該方法會檢測符合場景的 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