Golang 裏有趣的小細節

前幾天一個小夥伴在公司 slack 問到如下 Golang 代碼爲什麼會卡死(Go Playground):

**package main
import (
    "fmt"
    "runtime")
func main() {
    var i byte
    go func() {
        for i = 0; i <= 255; i++ {
        }
    }()
    fmt.Println("Dropping mic")
    // Yield execution to force executing other goroutines    runtime.Gosched()
    runtime.GC()
    fmt.Println("Done")}**

這個問題很有意思,大概涉及到 Golang 中以下三個概念:

**.byte 是什麼
.goroutine 如何調度
.Golang GC 時會發生什麼**

本文嘗試簡單解釋下爲什麼上面的程序會卡死。

首先,先看下 main 函數裏啓動的 goroutine 事實上是什麼東西:

**var i bytego func() {
    for i = 0; i <= 255; i++ {
    }}()**

Golang 中,byte 其實被 alias 到 uint8 上了。所以上面的 for 循環會始終成立,因爲 i++ 到
i=255 的時候會溢出,i <= 255 一定成立。也即是, for 循環永遠無法退出,所以上面的代碼其實可以等價於這樣:

**go func() {
    for {}}**

其次,Goroutine 的調度是一個非常複雜的問題,這裏並不打算詳細介紹完整細節。
大概描述一下,目前版本的 Golang 中 goroutine 的調度(Scalable Go Scheduler Design Doc)基於 GPM 模型,G 代表 goroutine,M 可以看做真實的資源(OS Threads)。P 是 G-M 的中間層,組織多個 goroutine 跑在同一個 OS Thread 上。大概的模型如下:
圖偷自 Google 圖片搜索

如上圖可以看到,一個 P 上會掛着多個 G,當一個 G 執行結束時,P 會選擇下一個 G 繼續執行。而當一個 G 執行太久沒有結束,總也要給後面的 G 運行的機會吧。所以,Go scheduler 除了在一個 goroutine 執行結束時會調度後面的 goroutine 執行,還會在正在被執行的 goroutine 發生以下情況時讓出當前 goroutine 的執行權,並調度後面的 goroutine 執行:

IO 操作

Channel 阻塞

system call

運行較長時間

前三種這裏我們不關心,最後一種情況下,如果一個 goroutine 執行時間太長,scheduler 會在其 G 對象上打上一個標誌( preempt),當這個 goroutine 內部發生函數調用的時候,會先主動檢查這個標誌,如果爲 true 則會讓出執行權。(這裏說得比較粗略,實際會複雜一些,不過並不是本文重點所以暫不關注細節。)

回到本文開始時的例子,main 函數裏啓動的 goroutine 其實是一個沒有 IO 阻塞、沒有 Channel 阻塞、沒有 system call、沒有函數調用的死循環。也就是,它無法主動讓出自己的執行權,即使已經執行很長時間,scheduler 已經標誌了 preempt。

如上圖所示,一旦這個 G ( goroutine ) 拿到執行權,它後面的 G 將無法再被當前 P 調度獲得執行權。上面程序爲了讓這個 G 對象一定拿到執行權,在 main goroutine 中主動執行 runtime.Gosched() 讓出了執行權。

P 的數量由 GOMAXPROCS 設置,默認爲機器的 CPU 數量。
這裏又分爲兩種情況:

.當這個程序跑在單核機器上的時候,P 默認只有一個,所以一旦調度到這個 G 對象就會卡死,因爲永遠沒有機會再調度回 main goroutine 了。

.當這個程序跑在多核機器上的時候,程序到這一步並不會卡死,因爲另一個 P 所關聯的 G 隊列執行完了之後,會通過 Work-Stealing 算法偷取別的 P 對象上的 G,所以 main goroutine 還是有機會被別的 P 調度到。

可是文章開始時的代碼,不論是在單核的機器上,還是在多核的機器上,都會卡死。
這就涉及到第三個點了:Golang 的 GC。

Golang 的 GC 本質上是基於標記-清除實現的(基於此不斷改進過)。 見名知意,標記-清除分爲兩個階段: - 標記 - 清除

其中,標記階段是需要 STW( Stop The World )的,也就是會讓所有正在運行的 goroutine 停下來。大概源碼在這個位置:

**func gcStart(mode gcMode, trigger gcTrigger) {
    // ......    systemstack(stopTheWorldWithSema)
    // ......}**

到這一步,死循環這個 goroutine 由於上面介紹的原因永遠無法停下來,但是 main goroutine 阻塞在 GC STW 這裏,等待所有 goroutine 停止執行。main goroutine 在等待一個永遠不會爲它停下的 G,於是,程序卡死了。

類似的,在設置 GOMAXPROCS 的時候,也需要 STW,所以下面的代碼,和本文開始時的代碼,卡死的原因是一樣一樣的(Go Playground):

**package main
import (
    "fmt"
    "runtime"
    "time")
func forever() {
    for {
    }}
func main() {
    go forever()**
    **time.Sleep(time.Millisecond)  // 讓出執行權    runtime.GOMAXPROCS(1926)      // 等待 stw    fmt.Println("Done")           // 永遠執行不到}**
    

區區幾行代碼,裏面的奧妙真不少呀。

更多知識
進羣: 313074041

進羣口令(Y1)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章