9.8. Goroutines和線程
在上一章中我們說goroutine和操作系統的線程區別可以先忽略。儘管兩者的區別實際上只是一個量的區別,但量變會引起質變的道理同樣適用於goroutine和線程。現在正是我們來區分開兩者的最佳時機。
9.8.1. 動態棧
每一個OS線程都有一個固定大小的內存塊(一般會是2MB)來做棧,這個棧會用來存儲當前正在被調用或掛起(指在調用其它函數時)的函數的內部變量。這個固定大小的棧同時很大又很小。因為2MB的棧對於一個小小的goroutine來說是很大的內存浪費,比如對於我們用到的,一個只是用來WaitGroup之後關閉channel的goroutine來說。而對於go程序來說,同時創建成百上千個goroutine是非常普遍的,如果每一個goroutine都需要這麼大的棧的話,那這麼多的goroutine就不太可能了。除去大小的問題之外,固定大小的棧對於更復雜或者更深層次的遞歸函數調用來說顯然是不夠的。修改固定的大小可以提升空間的利用率,允許創建更多的線程,並且可以允許更深的遞歸調用,不過這兩者是沒法同時兼備的。
相反,一個goroutine會以一個很小的棧開始其生命週期,一般只需要2KB。一個goroutine的棧,和操作系統線程一樣,會保存其活躍或掛起的函數調用的本地變量,但是和OS線程不太一樣的是,一個goroutine的棧大小並不是固定的;棧的大小會根據需要動態地伸縮。而goroutine的棧的最大值有1GB,比傳統的固定大小的線程棧要大得多,儘管一般情況下,大多goroutine都不需要這麼大的棧。
練習 9.4: 創建一個流水線程序,支持用channel連接任意數量的goroutine,在跑爆內存之前,可以創建多少流水線階段?一個變量通過整個流水線需要用多久?(這個練習題翻譯不是很確定)
9.8.2. Goroutine調度
OS線程會被操作系統內核調度。每幾毫秒,一個硬件計時器會中斷處理器,這會調用一個叫作scheduler的內核函數。這個函數會掛起當前執行的線程並將它的寄存器內容保存到內存中,檢查線程列表並決定下一次哪個線程可以被運行,並從內存中恢復該線程的寄存器信息,然後恢復執行該線程的現場並開始執行線程。因為操作系統線程是被內核所調度,所以從一個線程向另一個“移動”需要完整的上下文切換,也就是說,保存一個用戶線程的狀態到內存,恢復另一個線程的到寄存器,然後更新調度器的數據結構。這幾步操作很慢,因為其局部性很差需要幾次內存訪問,並且會增加運行的cpu週期。
Go的運行時包含了其自己的調度器,這個調度器使用了一些技術手段,比如m:n調度,因為其會在n個操作系統線程上多工(調度)m個goroutine。Go調度器的工作和內核的調度是相似的,但是這個調度器只關注單獨的Go程序中的goroutine(譯註:按程序獨立)。
和操作系統的線程調度不同的是,Go調度器並不是用一個硬件定時器,而是被Go語言“建築”本身進行調度的。例如當一個goroutine調用了time.Sleep,或者被channel調用或者mutex操作阻塞時,調度器會使其進入休眠並開始執行另一個goroutine,直到時機到了再去喚醒第一個goroutine。因為這種調度方式不需要進入內核的上下文,所以重新調度一個goroutine比調度一個線程代價要低得多。
練習 9.5: 寫一個有兩個goroutine的程序,兩個goroutine會向兩個無buffer channel反覆地發送ping-pong消息。這樣的程序每秒可以支持多少次通信?
9.8.3. GOMAXPROCS
Go的調度器使用了一個叫做GOMAXPROCS的變量來決定會有多少個操作系統的線程同時執行Go的代碼。其默認的值是運行機器上的CPU的核心數,所以在一個有8個核心的機器上時,調度器一次會在8個OS線程上去調度GO代碼。(GOMAXPROCS是前面說的m:n調度中的n)。在休眠中的或者在通信中被阻塞的goroutine是不需要一個對應的線程來做調度的。在I/O中或系統調用中或調用非Go語言函數時,是需要一個對應的操作系統線程的,但是GOMAXPROCS並不需要將這幾種情況計算在內。
你可以用GOMAXPROCS的環境變量來顯式地控制這個參數,或者也可以在運行時用runtime.GOMAXPROCS函數來修改它。我們在下面的小程序中會看到GOMAXPROCS的效果,這個程序會無限打印0和1。
for {
go fmt.Print(0)
fmt.Print(1)
}
$ GOMAXPROCS=1 go run hacker-cliché.go
111111111111111111110000000000000000000011111...
$ GOMAXPROCS=2 go run hacker-cliché.go
010101010101010101011001100101011010010100110...
在第一次執行時,最多同時只能有一個goroutine被執行。初始情況下只有main goroutine被執行,所以會打印很多1。過了一段時間後,GO調度器會將其置為休眠,並喚醒另一個goroutine,這時候就開始打印很多0了,在打印的時候,goroutine是被調度到操作系統線程上的。在第二次執行時,我們使用了兩個操作系統線程,所以兩個goroutine可以一起被執行,以同樣的頻率交替打印0和1。我們必須強調的是goroutine的調度是受很多因子影響的,而runtime也是在不斷地發展演進的,所以這裡的你實際得到的結果可能會因為版本的不同而與我們運行的結果有所不同。
練習9.6: 測試一下計算密集型的併發程序(練習8.5那樣的)會被GOMAXPROCS怎樣影響到。在你的電腦上最佳的值是多少?你的電腦CPU有多少個核心?
9.8.4. Goroutine沒有ID號
在大多數支持多線程的操作系統和程序語言中,當前的線程都有一個獨特的身份(id),並且這個身份信息可以以一個普通值的形式被很容易地獲取到,典型的可以是一個integer或者指針值。這種情況下我們做一個抽象化的thread-local storage(線程本地存儲,多線程編程中不希望其它線程訪問的內容)就很容易,只需要以線程的id作為key的一個map就可以解決問題,每一個線程以其id就能從中獲取到值,且和其它線程互不衝突。
goroutine沒有可以被程序員獲取到的身份(id)的概念。這一點是設計上故意而為之,由於thread-local storage總是會被濫用。比如說,一個web server是用一種支持tls的語言實現的,而非常普遍的是很多函數會去尋找HTTP請求的信息,這代表它們就是去其存儲層(這個存儲層有可能是tls)查找的。這就像是那些過分依賴全局變量的程序一樣,會導致一種非健康的“距離外行為”,在這種行為下,一個函數的行為可能並不僅由自己的參數所決定,而是由其所運行在的線程所決定。因此,如果線程本身的身份會改變——比如一些worker線程之類的——那麼函數的行為就會變得神祕莫測。
Go鼓勵更為簡單的模式,這種模式下參數(譯註:外部顯式參數和內部顯式參數。tls 中的內容算是"外部"隱式參數)對函數的影響都是顯式的。這樣不僅使程序變得更易讀,而且會讓我們自由地向一些給定的函數分配子任務時不用擔心其身份信息影響行為。
你現在應該已經明白了寫一個Go程序所需要的所有語言特性信息。在後面兩章節中,我們會回顧一些之前的實例和工具,支持我們寫出更大規模的程序:如何將一個工程組織成一系列的包,如何獲取,構建,測試,性能測試,剖析,寫文檔,並且將這些包分享出去。