《Go 語言程式設計》讀書筆記 (七) Goroutine 與系統執行緒的區別

KevinYan發表於2020-01-05

goroutine和執行緒的區別

動態棧

每一個OS執行緒都有一個固定大小的記憶體塊(一般會是2MB)來做棧,這個棧會用來儲存當前正在被呼叫或掛起(指在呼叫其它函式時)的函式的內部變數。這個固定大小的棧同時很大又很小。因為2MB的棧對於一個小小的goroutine來說是很大的記憶體浪費,比如對於我們用到的,一個只是用來WaitGroup之後關閉channel的goroutine來說。而對於go程式來說,同時建立成百上千個gorutine是非常普遍的,如果每一個goroutine都需要這麼大的棧的話,那這麼多的goroutine就不太可能了。除去大小的問題之外,固定大小的棧對於更復雜或者更深層次的遞迴函式呼叫來說顯然是不夠的。修改固定的大小可以提升空間的利用率允許建立更多的執行緒,並且可以允許更深的遞迴呼叫,不過這兩者是沒法同時兼備的。

相反,一個goroutine會以一個很小的棧開始其生命週期,一般只需要2KB。一個goroutine的棧,和作業系統執行緒一樣,會儲存其活躍或掛起的函式呼叫的本地變數,但是和OS執行緒不太一樣的是一個goroutine的棧大小並不是固定的;棧的大小會根據需要動態地伸縮。而goroutine的棧的最大值有1GB,比傳統的固定大小的執行緒棧要大得多,儘管一般情況下,大多goroutine都不需要這麼大的棧。

goroutine 排程

OS執行緒會被作業系統核心排程。每幾毫秒,一個硬體計時器會中斷處理器,這會呼叫一個叫做scheduler的核心函式。這個函式會掛起當前執行的執行緒並儲存記憶體中它的暫存器內容,檢查執行緒列表並決定下一次哪個執行緒可以被執行,並從記憶體中恢復該執行緒的暫存器資訊,然後恢復執行該執行緒的現場並開始執行執行緒。因為作業系統執行緒是被核心所排程,所以從一個執行緒向另一個“移動”需要完整的上下文切換,也就是說,儲存一個使用者執行緒的狀態到記憶體,恢復另一個執行緒的到暫存器,然後更新排程器的資料結構。這幾步操作很慢,因為其區域性性很差需要幾次記憶體訪問,並且會增加執行的cpu週期。

Go的執行時包含了其自己的排程器,這個排程器使用了一些技術手段,比如m:n排程,因為其會在n個作業系統執行緒上多工(排程)m個goroutine。Go排程器的工作和核心的排程是相似的,但是這個排程器只關注單獨的Go程式中的goroutine。

和作業系統的執行緒排程不同的是,Go排程器並不是用一個硬體定時器而是被Go語言"建築"本身進行排程的。例如當一個goroutine呼叫了time.Sleep或者被channel呼叫或者mutex操作阻塞時,排程器會使其進入休眠並開始執行另一個goroutine直到時機到了再去喚醒第一個goroutine。因為因為這種排程方式不需要進入核心的上下文,所以重新排程一個goroutine比排程一個執行緒代價要低得多。

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也是在不斷地發展演進的,所以這裡的你實際得到的結果可能會因為版本的不同而與我們執行的結果有所不同。

Goroutine沒有ID號

在大多數支援多執行緒的作業系統和程式語言中,當前的執行緒都有一個獨特的身份(id),並且這個身份資訊可以以一個普通值的形式被被很容易地獲取到,典型的可以是一個integer或者指標值。這種情況下我們做一個抽象化的thread-local storage(執行緒本地儲存,多執行緒程式設計中不希望其它執行緒訪問的內容)就很容易,只需要以執行緒的id作為key的一個map就可以解決問題,每一個執行緒以其id就能從中獲取到值,且和其它執行緒互不衝突。

goroutine沒有可以被程式設計師獲取到的身份(id)的概念。這一點是設計上故意而為之,由於thread-local storage總是會被濫用。Go鼓勵更為簡單的模式,這種模式下引數對函式的影響都是顯式的。這樣不僅使程式變得更易讀,而且會讓我們自由地向一些給定的函式分配子任務時不用擔心其身份資訊影響行為。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

公眾號:網管叨bi叨 | Golang、PHP、Laravel、Docker等學習經驗分享

相關文章