Goroutine 是如何運行的

在 Go 語言中,沒有線程,只有 goroutine,這也是 Go 語言原生支持高併發的關鍵。goroutine 是 Go 語言對協程的實現。goroutine 非常輕量級,一般只有幾 Kb 的大小,而一個線程最小都有 1 M。

goroutine 本身只是一個數據結構,真正讓 goroutine 運行起來的是調度器

  1. 爲什麼需要一個調度器

在計算機上運行的程序最終都是需要 CPU 去執行,協程只是運行在操作系統的用戶態。協程真正的執行依然需要依靠操作系統內核態的線程去執行。

操作系統並不知道協程的存在,會把協程當做普通的程序來執行。既然協程是爲了提高程序的執行效率,那麼一個理想的情況是一個線程上可以執行多個協程。

如果一個協程對於一個線程,那就相當於協程的創建和運行還是由內核態來執行,這樣的代價有點高。但如果一個線程上可以運行多個協程,如果其中的一個協程發生了阻塞,那麼其他的協程就都無法執行了。

所以理想的情況是協程是線程的關係是 m:n,這樣就可以克服 m:1 和 1:1 的缺點。但 m:n 的情況最爲複雜,需要自己來實現協程在多個線程的調度,充分利用計算機的多核能力,再配合協程的輕量級的特性,實現程序的高併發。

在 Go 的實現中,goroutine 與內核態線程的對應關係就是是 m:n,所以就需要自己實現一個協程的調度器。

  1. 調度器的結構

Go 調度器從最開始到現在也經歷了不斷的演進,最初的那個版本已經被放棄,目前使用的版本是在 2012 重新設計的,然後沿用至今。

現在用的這個調度器也被稱之爲 GMP 模型,3 個字母分表代表一個關鍵部件的名稱:

如果 M,也就是線程如果想要運行任務,就需要去獲取一個 P,然後從 P 的任務隊列中獲取 goroutine 來執行。

在 P 上,會有一個正在 M 上執行的 G,但是同時也會維護一個本地的隊列,裏面都是待執行的 G,其中 P 的數量由 GOMAXPROCS 環境變量或者 runtime.GOMAXPROCS() 來決定,這表示在同一時間,只有 GOMAXPROCS 數量個 goroutine 在執行。

P 與 M 的數量沒有固定的關係,如果當前的 M 阻塞了,P 就會去創建或者切換到另一個 M 上。

  1. 調度器是如何運作的

在介紹完 GMP 的結構之後,我們再來看一下 GMP 調度器是如何運行起來的。

在 Go 語言中,我們創建一個 goroutine 非常簡單,只需要使用 go 關鍵字:

go func() {
    fmt.Println("New goroutine")
}()

這樣就會創建上面所說的一個 G,然後放進調度器中開始調度。

每個 G 在被創建之後,都會被優先放入到本地隊列中,如果本地隊列已經滿了,就會被放入到全局隊列中。

然後每個 M 就開始執行 P 的本地隊列中的 G,如果某個 M 把任務都執行完成之後,然後就會去去全局隊列中拿 G,這裏需要注意,每次去全局隊列拿 G 的時候,都需要上鎖,避免同樣的任務被多次拿。

如果全局隊列都被拿完了,而當前 M 也沒有更多的 G 可以執行的時候,它就會去其他 P 的本地隊列中拿任務,這個機制被稱之爲 work stealing 機制,每次會拿走一半的任務,向下取整,比如另一個 P 中有 3 個任務,那一半就是一個任務。

這樣還有一個特別的場景需要說明,當一個 M 被阻塞時,M 就會與 P 解綁,讓 P 去找其他空閒的 M 綁定執行後面的 G,如果沒有空閒的 M,就會創建一個新的 M。當 M 阻塞結束之後,就會把 G 放入到全局隊列中,這個機制稱之爲 hand off 機制。

work stealing 和 hand off 機制提高了線程的使用效率,避免的線程重複創建和銷燬。

當全局隊列爲空,M 也沒辦法從其他的 P 中拿任務的時候,就會讓自身進入自選狀態,等待有新的 G 進來。最多隻會有 GOMAXPROCS 個 M 在自旋狀態,過多 M 的自旋會浪費 CPU 資源,多餘的 M 的就會與 P 解綁,進入到休眠狀態。

  1. 小結

爲了讓 goroutine 的運行更有效率,Go 實現了一個用戶態的調度器,這個調度器充分利用現代計算機的多核特性,同時讓多個 goroutine 運行,同時 goroutine 設計的很輕量級,調度和上下文切換的代價都比較小。而且利用 work stealing 和 hand off 機制,對線程進行復用,避免了線程的重複創建。

文 / Rayjun

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