GO 之 Goroutine 學習

JaguarJack發表於2019-03-31

Goroutine

Goroutine 算是 Go 語言最大的亮點了。在 Go 裡面,每一個併發執行的活動的都稱為 Goroutine。當開始接觸 Goroutine 的時候,可以將它類比為傳統的執行緒,但你絕不能把它當作執行緒,兩者是有本質的區別的。

當你開始寫 Go 程式的時候,必須有一個主函式 main,這個是由主 Goroutine 來呼叫。新的 goroutine 由關鍵字 go 來建立。並且 go 關鍵字支援匿名函式。像這樣:

go func() {
    //  執行  
    fmt.Println("this is a goroutine")
}()

就是這麼簡單,很輕鬆的建立一個 Goroutine,這樣就可以開啟你的併發之旅了。

Go 的併發模型

還記得上面講了的嗎?Goroutine 和執行緒是有本質區別的。那麼 Goroutine 到底是什麼呢?Goroutine 既不是 OS 執行緒,也不是綠色執行緒(使用者空間的執行緒,有執行的語言管理)。它們是更加抽象且輕量級的,稱之為協程。協程是非搶佔式的且不能被中斷的 Goroutine (函式,閉包或者方法),但是協程有多個點,可以暫停或者重新進入。

Goroutine 是由一個名為 M:N 的排程器實現的,他是將 M 個綠色執行緒對映到 N 個 OS 執行緒,然後再將 Goroutine 安排到綠色執行緒。當 Gouroutine 的數量超過了可用的綠色執行緒的時候,排程程式將處理分佈線上程上的 Gotoutine,並且確保這些 Goroutine 被阻塞的時候,其他的 Goroutine 可以執行。這就是 Goroutine 的併發模型。

Fork-Join 模型

Go 語言還遵循這個一個稱為 Fork-Join 的模型。fork 指的是在程式中的任意一點,他可以將執行的子分支與自己的父節點同時執行。join 指的是在將來的某個時候,這些分支會合並在一起。下面的示意圖:fork-join 模型 圖片來自網路

下面開看一個簡單的例子:

func main() {
    go func() {
        fmt.Println("hello")
    }()

    // do something
    fmt.Println("world")
}

本例中,使用了一個 Goroutine 開啟了一個協程執行,主函式則執行其餘部分。在這個例子中,其實是沒有 Join 點的,因為你不知道這個協程將在哪個時間點退出,這是不確定的。單單就這個例子而言,我們是不確定是否會列印出 “hello world” 的。Goroutine 雖然被建立了,並且在執行時執行,但是實際上很可能在 main goroutine 退出之後執行,你將看不到任何輸出。

ps:如果你看到了也不必有任何疑問,因為這是不確定的。

你可以增加一個競爭條件,例如在 main Goroutine 加入 time.Sleep,但這並不是建立 Join 點,因為你還是並不能保證 goroutine 一定執行,你還是不確定退出時間。Join 點是保證程式的正確性和消除競爭條件的關鍵。

所以想要建立一個 Join 點,你必須同步 main Goroutine 和 子 goroutine。這可以通過多種方式來實現,比如 Sync 包。下一篇就主要介紹一下這個包。

下面來看一個有意思的現象,關於 Join 點的問題。注意這裡將會使用 Sync 包。來看一下下面的兩段程式碼:

無 Join 點

    str := "hello"
    go func() {
        defer wg.Done()
        str = "world"
    }()
    fmt.Println(str)

有 Join 點

var wg sync.WaitGroup
    str := "hello"
    wg.Add(1)
    go func() {
        defer wg.Done()
        str = "world"
    }()
    wg.Wait() // 這裡就是 join 點
    fmt.Println(str)

當你執行第一段的程式碼的時候,結果在大多數可能下會是 hello。當然並不能說是一定,因為這是不確定的。除非你加入競爭條件(其實也是不確定的,只不過加大了概率)或者像第二段程式碼一樣加入了 Join 點。使用 Sync 包之後,引入 Join 點的情況下,可以很確定結果是 world。因為他保證了程式的正確性,以第二段程式碼為列的話,可以認為我們確定了 str 變數確確實實被重新賦予了新的值。這是一個確定的事件,所以結果也是確定的。

從上面的例子我們還可以看出來一點,就是 goroutine 在他們所建立的相同的地址空間執行(main goroutine 和 子 goroutine 都同時使用了 str),再來看一段程式碼:

var wg sync.WaitGroup
    str := "hello"
    for _, str = range []string{"H", "J", "K"} {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(str)
        }()
    }
    wg.Wait()

請思考一下結果是什麼?H, J, K 或者是 H, K, J ?? 很多人可能會認為結果會是無序列印三個值。可是結果並不是你想像的那樣,結果大多數情況下會是列印三個 K。雖然上面已經說了 goroutine 執行在相同的地址空間,但是為什麼並不會無序的列印出三個值呢?來看一下這整個過程,goroutine 內列印的是 str 的字串引用,也就是說整個迭代過程中 str 一直是在變的,當我們建立 goroutine 的時候,因為不確定 goroutine 的在什麼時間執行,很可能當 goroutine 執行的時候迭代已經結束了。所以這個例子的程式碼大概率將會看到三個 k 的輸出。可以加大陣列的長度加以驗證 goroutine 的執行的時間是不確定性的證明。那麼這裡還有一個問題 。

既然迭代結束了, str 的會不會被回收呢?當然從結果來看,GO 語言還是保留了 str 的引用。

那麼該如何解決這個問題呢?既然他每次只能引用到迭代處的值。那麼就只需要把迭代的值傳入就可以了。

var wg sync.WaitGroup
    str := "hello"
    for _, str = range []string{"H", "J", "K"} {
        wg.Add(1)
        go func(s  string) {
            defer wg.Done()
            fmt.Println(s)
        }(str)
    }
    wg.Wait()

閱讀中有任何建議和疑問歡迎反饋,有不恰當的地方歡迎修正。共同進步

相關文章