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 有數以十萬計,也仍然可以如此。
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/ ),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。