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

MingsonZheng發表於2021-10-30

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 國際許可協議進行許可。

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

相關文章