讓我們快速進入問題,不浪費時間。試著執(zhí)行下面的golang代碼片段。
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
var x int
threads := runtime.GOMAXPROCS(0)
println(threads)
for i := 0; i < threads; i++ {
go func() {
for {
x++
}
}()
}
time.Sleep(time.Second)
fmt.Println("x =", x)
}
運行代碼
$ GOMAXPROCS=8 go run x.go
(旁注:熟悉Golang的同道想必知道GOMAXPROCS其實對應的CPU核心數,也就是線程數,這里應該是原作者運行示例時的計算機的CPU核數為8,因為根據文檔定義,如果runtime.GOMAXPROCS(0)傳入參數小于1,如果特殊指定,GOMAXPROCS就等于CPU核心數)
你觀察到程序從未終止嗎?這就是我說的golang陷阱。如果你用C/C++寫同樣的程序,你就不會發(fā)現這樣的問題?,F在讓我們修改程序,修改以下一行:
threads := runtime.GOMAXPROCS(0)-1
所以,我們只是減少了1個go協程的數量。如果你在這個改變后重新運行程序,你會發(fā)現程序正確地終止,并打印出結果。這非常令人驚訝,不是嗎?要了解這個問題背后的原因,我們需要了解一下golang運行時和調度器的實現。
揭開調度器的神秘面紗
Golang提供了用于并發(fā)的goroutine。它們類似于線程,但它們是輕量級的,開銷非常小。擁有數萬個goroutine的程序并不罕見,而擁有一萬個pthreads代價就非常高了。golang在用戶態(tài)中實現了goroutine。golang運行時為go程序創(chuàng)建的操作系統(tǒng)線程(pthreads)等于GOMAXPROCS的數量。Go協程被golang運行時安排在這些有限的OS線程上。
操作系統(tǒng)調度器
讓我們回顧一下操作系統(tǒng)是如何調度進程的。通常情況下,操作系統(tǒng)調度器會保存一份操作系統(tǒng)進程的列表,它們處于正在運行、可運行或不可運行的狀態(tài)。如果一個進程的運行時間超過了調度器的時間片,它就會搶占該進程,并安排在同一CPU上執(zhí)行另一個可運行的進程。搶占是通過定時器中斷來實現的。定時器中斷的頻率為調度器時間片的間隔。在一段代碼中正在執(zhí)行的進程會停止執(zhí)行,保存進程執(zhí)行上下文并執(zhí)行中斷處理程序。中斷處理程序會將執(zhí)行切換到調度器中?,F在,調度器可以決定在這個CPU上執(zhí)行哪個可運行的進程。調度器會選擇一個進程并切換到它的執(zhí)行上下文。
Golang的調度器
Golang實現了一個可協作的搶占式調度器。它沒有實現基于定時器中斷的搶占。但是,這個調度器應該方便在一個OS線程上同時運行多個goroutine。Golang在運行時提供的構造體、庫和系統(tǒng)調用(?此處翻譯的不好,構造體這個說法聽著怪怪的)中加入鉤子,可以與調度器進行協作。由于它避開了調用進入調度器的計時器,所以將運行時提供的函數作為進入調度器的入口。如果我們設法寫一個不使用任何運行時提供的封裝函數的goroutine,會發(fā)生什么?這正是這里發(fā)生的事情。那個goroutine不會調用到調度器,并導致goroutine的搶占。
在上面的程序中,我們執(zhí)行的goroutine等于GOMAXPROCS(操作系統(tǒng)線程)。主協程是一個額外的goroutine。每個go協程都運行一個無限循環(huán),并帶有一個整數增量操作,這為協程提供了沒有調用到調度器的范圍。因此,所有六個線程(GOMAXPROCS)都在運行無限循環(huán),它們永遠不會搶占。處于可運行狀態(tài)的主協程無法執(zhí)行,因為這六個線程中的任何一個線程都忙于執(zhí)行無限循環(huán),所以調度器永遠不會被執(zhí)行。當我們減少1個線程時,現在有一個OS線程變得空閑,能夠執(zhí)行主程序。
(旁注:假設系統(tǒng)是8個CPU,我們GOMAXPROCS減1以后運行程序,就會有一個核是空閑的,此時正好可以進入主線程中執(zhí)行,雖然原作者這里寫的是6,不過我覺得處于無限循環(huán)的線程應該等同于threads,當threads等于系統(tǒng)CPU核心數時,由于無限循環(huán),主協程沒有機會被調度到,所以就程序沒法退出,當將threads頭1時,主協程才有機會能夠執(zhí)行,GOMAXPROCS限制的是goroutine的最大并發(fā)能力,這個也是由golang自己的調度器實現的,那主協程能運行是由于golang調度所致嗎?此處先埋下伏筆。
我分別在不同的go版本下運行了示例程序:1.13、1.14,得到了不同的結果,1.13符合預期,但是1.14下程序卻有不同表現,主線程總能得到執(zhí)行,我想這應該是因為1.14版本的go調度器有較大變化所致,此處先埋點,后開坑)
在現實世界的程序中,這種情況是不太可能發(fā)生的,因為我們可能會使用運行時提供的功能,如channels、systemcalls、fmt.Sprint、Mutex、time.Sleep至少一次。你可以在無限循環(huán)中添加一個無害的time.Sleep(0),然后觀察程序不再掛起。
結論
雖然出現這個問題的幾率非常小,但還是有可能發(fā)生。解決這個問題的方法是在這種情況下,從程序中強行調用進入調度器。runtime.Gosched()的調用有利于強制進入調度器。
這篇博文的靈感來自于我的同事,他在玩golang的時候就遇到了這個問題。
(旁注:此文非常有意思,一個簡單的示例,卻可以發(fā)散讀者對于調度器的理解,有你當然,這篇文章還沒有講解goroutine為什么輕量,這是另一個有意思的話題,歡迎一起討論)