Go語言核心36講(Go語言進階技術十)–學習筆記

16 | go語句及其執行規則(上)

我們已經知道,通道(也就是 channel)類型的值,可以被用來以通訊的方式共享數據。更具體地說,它一般被用來在不同的 goroutine 之間傳遞數據。那麼 goroutine 到底代表著什麼呢?

簡單來說,goroutine 代表著並發編程模型中的用戶級執行緒。你可能已經知道,作業系統本身提供了進程和執行緒,這兩種並發執行程式的工具。

前導內容:進程與執行緒

進程,描述的就是程式的執行過程,是運行著的程式的代表。換句話說,一個進程其實就是某個程式運行時的一個產物。如果說靜靜地躺在那裡的程式碼就是程式的話,那麼奔跑著的、正在發揮著既有功能的程式碼就可以被稱為進程。

我們的電腦為什麼可以同時運行那麼多應用程式?我們的手機為什麼可以有那麼多 App 同時在後台刷新?這都是因為在它們的作業系統之上有多個代表著不同應用程式或 App 的進程在同時運行。

再來說說執行緒。首先,執行緒總是在進程之內的,它可以被視為進程中運行著的控制流(或者說程式碼執行的流程)。

一個進程至少會包含一個執行緒。如果一個進程只包含了一個執行緒,那麼它裡面的所有程式碼都只會被串列地執行。每個進程的第一個執行緒都會隨著該進程的啟動而被創建,它們可以被稱為其所屬進程的主執行緒。

相對應的,如果一個進程中包含了多個執行緒,那麼其中的程式碼就可以被並發地執行。除了進程的第一個執行緒之外,其他的執行緒都是由進程中已存在的執行緒創建出來的。

也就是說,主執行緒之外的其他執行緒都只能由程式碼顯式地創建和銷毀。這需要我們在編寫程式的時候進行手動控制,作業系統以及進程本身並不會幫我們下達這樣的指令,它們只會忠實地執行我們的指令。

不過,在 Go 程式當中,Go 語言的運行時(runtime)系統會幫助我們自動地創建和銷毀系統級的執行緒。這裡的系統級執行緒指的就是我們剛剛說過的作業系統提供的執行緒。

而對應的用戶級執行緒指的是架設在系統級執行緒之上的,由用戶(或者說我們編寫的程式)完全控制的程式碼執行流程。用戶級執行緒的創建、銷毀、調度、狀態變更以及其中的程式碼和數據都完全需要我們的程式自己去實現和處理。

這帶來了很多優勢,比如,因為它們的創建和銷毀並不用通過作業系統去做,所以速度會很快,又比如,由於不用等著作業系統去調度它們的運行,所以往往會很容易控制並且可以很靈活。

但是,劣勢也是有的,最明顯也最重要的一個劣勢就是複雜。如果我們只使用了系統級執行緒,那麼我們只要指明需要新執行緒執行的程式碼片段,並且下達創建或銷毀執行緒的指令就好了,其他的一切具體實現都會由作業系統代勞。

但是,如果使用用戶級執行緒,我們就不得不既是指令下達者,又是指令執行者。我們必須全權負責與用戶級執行緒有關的所有具體實現。

作業系統不但不會幫忙,還會要求我們的具體實現必須與它正確地對接,否則用戶級執行緒就無法被並發地,甚至正確地運行。畢竟我們編寫的所有程式碼最終都需要通過作業系統才能在電腦上執行。這聽起來就很麻煩,不是嗎?

不過別擔心,Go 語言不但有著獨特的並發編程模型,以及用戶級執行緒 goroutine,還擁有強大的用於調度 goroutine、對接系統級執行緒的調度器。

這個調度器是 Go 語言運行時系統的重要組成部分,它主要負責統籌調配 Go 並發編程模型中的三個主要元素,即:G(goroutine 的縮寫)、P(processor 的縮寫)和 M(machine 的縮寫)。

其中的 M 指代的就是系統級執行緒。而 P 指的是一種可以承載若干個 G,且能夠使這些 G 適時地與 M 進行對接,並得到真正運行的中介。

從宏觀上說,G 和 M 由於 P 的存在可以呈現出多對多的關係。當一個正在與某個 M 對接並運行著的 G,需要因某個事件(比如等待 I/O 或鎖的解除)而暫停運行的時候,調度器總會及時地發現,並把這個 G 與那個 M 分離開,以釋放計算資源供那些等待運行的 G 使用。

而當一個 G 需要恢復運行的時候,調度器又會儘快地為它尋找空閑的計算資源(包括 M)並安排運行。另外,當 M 不夠用時,調度器會幫我們向作業系統申請新的系統級執行緒,而當某個 M 已無用時,調度器又會負責把它及時地銷毀掉。

正因為調度器幫助我們做了很多事,所以我們的 Go 程式才總是能高效地利用作業系統和電腦資源。程式中的所有 goroutine 也都會被充分地調度,其中的程式碼也都會被並發地運行,即使這樣的 goroutine 有數以十萬計,也仍然可以如此。

image

M、P、G 之間的關係(簡化版)

由於篇幅原因,關於 Go 語言內部的調度器和運行時系統的更多細節,我在這裡就不再深入講述了。你需要知道,Go 語言實現了一套非常完善的運行時系統,保證了我們的程式在高並發的情況下依舊能夠穩定、高效地運行。

下面,我會從編程實踐的角度出發,以go語句的用法為主線,向你介紹go語句的執行規則、最佳實踐和使用禁忌。

我們來看一下今天的問題:什麼是主 goroutine,它與我們啟用的其他 goroutine 有什麼不同?

我們具體來看一道我在面試中經常提問的編程題。

package main

import "fmt"

func main() {
  for i := 0; i < 10; i++ {
    go func() {
      fmt.Println(i)
    }()
  }
}

在 demo38.go 中,我只在main函數中寫了一條for語句。這條for語句中的程式碼會迭代運行 10 次,並有一個局部變數i代表著當次迭代的序號,該序號是從0開始的。

在這條for語句中僅有一條go語句,這條go語句中也僅有一條語句。這條最裡面的語句調用了fmt.Println函數並想要列印出變數i的值。

這個程式很簡單,三條語句逐條嵌套。我的具體問題是:這個命令源碼文件被執行後會列印出什麼內容?

這道題的典型回答是:不會有任何內容被列印出來。

問題解析

問題解析與一個進程總會有一個主執行緒類似,每一個獨立的 Go 程式在運行時也總會有一個主 goroutine。這個主 goroutine 會在 Go 程式的運行準備工作完成後被自動地啟用,並不需要我們做任何手動的操作。

想必你已經知道,每條go語句一般都會攜帶一個函數調用,這個被調用的函數常常被稱為go函數。而主 goroutine 的go函數就是那個作為程式入口的main函數。

一定要注意,go函數真正被執行的時間,總會與其所屬的go語句被執行的時間不同。當程式執行到一條go語句的時候,Go 語言的運行時系統,會先試圖從某個存放空閑的 G 的隊列中獲取一個 G(也就是 goroutine),它只有在找不到空閑 G 的情況下才會去創建一個新的 G。

這也是為什麼我總會說「啟用」一個 goroutine,而不說「創建」一個 goroutine 的原因。已存在的 goroutine 總是會被優先復用。

然而,創建 G 的成本也是非常低的。創建一個 G 並不會像新建一個進程或者一個系統級執行緒那樣,必須通過作業系統的系統調用來完成,在 Go 語言的運行時系統內部就可以完全做到了,更何況一個 G 僅相當於為需要並發執行程式碼片段服務的上下文環境而已。

在拿到了一個空閑的 G 之後,Go 語言運行時系統會用這個 G 去包裝當前的那個go函數(或者說該函數中的那些程式碼),然後再把這個 G 追加到某個存放可運行的 G 的隊列中。

這類隊列中的 G 總是會按照先入先出的順序,很快地由運行時系統內部的調度器安排運行。雖然這會很快,但是由於上面所說的那些準備工作還是不可避免的,所以耗時還是存在的。

因此,go函數的執行時間總是會明顯滯後於它所屬的go語句的執行時間。當然了,這裡所說的「明顯滯後」是對於電腦的 CPU 時鐘和 Go 程式來說的。我們在大多數時候都不會有明顯的感覺。

在說明了原理之後,我們再來看這種原理下的表象。請記住,只要go語句本身執行完畢,Go 程式完全不會等待go函數的執行,它會立刻去執行後邊的語句。這就是所謂的非同步並發地執行。

這裡「後邊的語句」指的一般是for語句中的下一個迭代。然而,當最後一個迭代運行的時候,這個「後邊的語句」是不存在的。

在 demo38.go 中的那條for語句會以很快的速度執行完畢。當它執行完畢時,那 10 個包裝了go函數的 goroutine 往往還沒有獲得運行的機會。

請注意,go函數中的那個對fmt.Println函數的調用是以for語句中的變數i作為參數的。你可以想像一下,如果當for語句執行完畢的時候,這些go函數都還沒有執行,那麼它們引用的變數i的值將會是什麼?

它們都會是10,對嗎?那麼這道題的答案會是「列印出 10 個10」,是這樣嗎?

在確定最終的答案之前,你還需要知道一個與主 goroutine 有關的重要特性,即:一旦主 goroutine 中的程式碼(也就是main函數中的那些程式碼)執行完畢,當前的 Go 程式就會結束運行。

如此一來,如果在 Go 程式結束的那一刻,還有 goroutine 未得到運行機會,那麼它們就真的沒有運行機會了,它們中的程式碼也就不會被執行了。

我們剛才談論過,當for語句的最後一個迭代運行的時候,其中的那條go語句即是最後一條語句。所以,在執行完這條go語句之後,主 goroutine 中的程式碼也就執行完了,Go 程式會立即結束運行。那麼,如果這樣的話,還會有任何內容被列印出來嗎?

嚴謹地講,Go 語言並不會去保證這些 goroutine 會以怎樣的順序運行。由於主 goroutine 會與我們手動啟用的其他 goroutine 一起接受調度,又因為調度器很可能會在 goroutine 中的程式碼只執行了一部分的時候暫停,以期所有的 goroutine 有更公平的運行機會。

所以哪個 goroutine 先執行完、哪個 goroutine 後執行完往往是不可預知的,除非我們使用了某種 Go 語言提供的方式進行了人為干預。然而,在這段程式碼中,我們並沒有進行任何人為干預。

那答案到底是什麼呢?就 demo38.go 中如此簡單的程式碼而言,絕大多數情況都會是「不會有任何內容被列印出來」。

但是為了嚴謹起見,無論應聘者的回答是「列印出 10 個10」還是「不會有任何內容被列印出來」,又或是「列印出亂序的0到9」,我都會緊接著去追問「為什麼?」因為只有你知道了這背後的原理,你做出的回答才會被認為是正確的。

這個原理是如此的重要,以至於如果你不知道它,那麼就幾乎無法編寫出正確的可並發執行的程式。如果你不知道此原理,那麼即使你寫的並發程式看起來可以正確地運行,那也肯定是運氣好而已。

總結

今天,我描述了 goroutine 在作業系統的並發編程體系,以及在 Go 語言並發編程模型中的地位和作用。

我還提到了 Go 語言內部的運行時系統和調度器,以及它們圍繞著 goroutine 做的那些統籌調配和維護工作。這些內容中的每句話應該都會對你正確理解 goroutine 起到實質性的作用。你可以用這些知識去解釋主問題中的那個程式在運行後為什麼會產出那樣的結果。

下一篇內容,我們還會繼續圍繞 go 語句以及執行規則談一些擴展知識,今天留給你的思考題就是:用什麼手段可以對 goroutine 的啟用數量加以限制?

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: //www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可發布。