golang goroutine and thread

我們的程序是如何被運(yùn)行的?

學(xué)習(xí)過操作系統(tǒng)的人,應(yīng)該對(duì)進(jìn)程和線程的模型都是有所了解的。按照我的理解:「進(jìn)程」是操作系統(tǒng)資源分配的基本單位,它給程序提供了一個(gè)良好的運(yùn)行環(huán)境?!妇€程」則是一個(gè)輕量級(jí)的進(jìn)程,一個(gè)「進(jìn)程」中可以有很多線程,但是最終在一個(gè) CPU 的核上只能有一個(gè)「進(jìn)程」的其中一個(gè)「線程」被執(zhí)行。所以,我們的一個(gè)程序的執(zhí)行過程可以粗略的理解為:

  1. 程序的可執(zhí)行文件被 Load 到內(nèi)存中
  2. 創(chuàng)建進(jìn)程&創(chuàng)建主線程
  3. 主線程被 OS 調(diào)度到合適的 CPU 執(zhí)行

goroutine 是什么?

看了很多文章對(duì)于 goroutine 的描述,其中出現(xiàn)最多的一句話就是「The goroutine is a lightweight thread.」。在結(jié)合了對(duì)操作系統(tǒng)的線程模型的理解之后,我覺得 goroutine 就是一個(gè)在用戶空間(usernamespace)下實(shí)現(xiàn)的「線程」,它由 golang 的 runtime 進(jìn)行管理。goroutine 和 go runtime 的關(guān)系可以直接的類比于線程和操作系統(tǒng)內(nèi)核的關(guān)系。至于它是不是輕量級(jí),這需要和操作系統(tǒng)的線程進(jìn)行對(duì)比之后才能夠知道。在此我們先避免「人云亦云」。

goroutine 和 thread 有什么不同?

目前看起來 goroutine 和 thread 在實(shí)現(xiàn)的思路上是比較相似的。但是為什么說 goroutine 比 thread 要輕量呢?從字面的意思上來理解,「輕量」肯定意味著消耗的系統(tǒng)資源變少了。

內(nèi)存消耗

FC462CDC-C7E8-40FE-B889-8B2F73043ABD.jpg

OS

從 OS 的層面來說,內(nèi)存大致可以分為三個(gè)部分:一部分為棧(Stack)另外一部分為堆(Heap),最后一部分為程序代碼的存儲(chǔ)空間(Programe Text)。既然在邏輯上 OS 已經(jīng)對(duì)內(nèi)存的布局做了劃分,如果棧和堆之前如果沒有遵守「分界線」而發(fā)生了 overwrite,那么結(jié)果將是災(zāi)難性的。為了防止發(fā)生這種情況,OS 在 Stack 和 Heap 之間設(shè)置了一段不可被 overwrite 的區(qū)域:Guard Page

thread

通過對(duì) OS 中線程模型的了解,我們可以知道:同一個(gè)進(jìn)程的多個(gè)線程共享進(jìn)程的地址空間。所以,每一個(gè) thread 都會(huì)有自己的 Stack 空間以及一份 Guard Page用于線程間的隔離。在程序運(yùn)行的過程中,線程越多,消耗的內(nèi)存也就越多。當(dāng)一個(gè)線程被創(chuàng)建的時(shí)候,通常會(huì)消耗大概1MB的空間(預(yù)分配的 Stack 空間+ Guard Page)。

goroutine

對(duì)于一個(gè) goroutine 來說,當(dāng)它被創(chuàng)建的時(shí)候,有一個(gè)初始的內(nèi)存使用量。這個(gè)使用量在 Go 1.2~1.4 版本的時(shí)候發(fā)生過幾次改變,最終確定為2KB。當(dāng)一個(gè) goroutine 在運(yùn)行的過程中如果需要使用更多的內(nèi)存,那么它將會(huì)在 Heap 上申請(qǐng)。
對(duì)于 Guard Page 的問題,goroutine 采取了一種「用前檢查」的方式來解決:每當(dāng)一個(gè)函數(shù)調(diào)用的時(shí)候,go runtime 都會(huì)去檢查當(dāng)前 goroutine 的 stack 空間是否夠用,如果不夠就在 Heap 上分配一塊新的空間,該塊空間使用完還會(huì)被回收。
這種「用前檢查」的動(dòng)態(tài)分配內(nèi)存的方式使得 goroutine 在內(nèi)存的消耗上相較于 thread 來說具有明顯的優(yōu)勢(shì)。所以在寫 golang 程序的時(shí)候,我們幾乎可以對(duì)收到的每一個(gè) Request 都開一個(gè) goroutine 處理。但是如果使用 thread這么做的話,你就等著 OOM 吧:)。上面的描述并不代表對(duì)于 goroutine 你就可以隨意分配使用而不及時(shí)回收,如果 goroutine 數(shù)量太多它一樣會(huì) OOM,只不過 goroutine 相較于 thread 的內(nèi)存增長(zhǎng)率要低很多罷了。在同等的量級(jí)下,thread 會(huì)引起程序 OOM 但是 goroutine 不會(huì)。

創(chuàng)建和銷毀的性能

thread

線程的創(chuàng)建和銷毀都需要通過系統(tǒng)調(diào)用來實(shí)現(xiàn),也就是說,這些動(dòng)作都必須要和 OS 的內(nèi)核進(jìn)行交互。

goroutine

goroutine 的創(chuàng)建和銷毀操作都是由 go runtime 來完成的,在用戶空間下直接進(jìn)行處理。
對(duì)于創(chuàng)建和銷毀的性能問題,這里不做過多介紹。本質(zhì)上來說,goroutine 和 thread 就相當(dāng)于「用戶級(jí)線程」和「內(nèi)核級(jí)線程」。感興趣的可以去找下相關(guān)資料深入了解下兩者的區(qū)別。否則,可以簡(jiǎn)單的理解為「goroutine 的創(chuàng)建和銷毀是程序自己做的,但是 thread 得麻煩 OS 的內(nèi)核,兩者的性能當(dāng)然不一樣」

上下文切換的消耗

thread

當(dāng)不同的線程發(fā)生切換的時(shí)候,如上面提到的創(chuàng)建和銷毀操作一樣,都需要和 OS 的內(nèi)核進(jìn)行交互。調(diào)度器將會(huì)保存/恢復(fù)當(dāng)時(shí)所有寄存器當(dāng)中的內(nèi)容:PC (Program Counter), SP (Stack Pointer) 等等一系列的上下文數(shù)據(jù)。這些操作都是非?!赴嘿F」的

goroutine

多個(gè) goroutine 在發(fā)生切換的時(shí)候,由于是在同一個(gè) thread 下面,只會(huì)保存/恢復(fù)三個(gè)寄存器當(dāng)中的內(nèi)容:Program Counter, Stack Pointer and DX。另外,如果你對(duì) golang scheduler 的調(diào)度模型比較熟悉的話,那么你應(yīng)該知道,同一時(shí)刻同一個(gè) thread 只會(huì)執(zhí)行一個(gè) goroutine,未被執(zhí)行但是已經(jīng)準(zhǔn)備好的 goroutine 都是放在一個(gè) queue 中的,他們是被串行處理的。所以,即使一個(gè)程序創(chuàng)建了成千上萬的 goroutine 也不會(huì)對(duì)上下文的切換造成什么影響。最重要的是,golang scheduler 在切換不同 goroutine 的操作上基本上達(dá)到了 O(1) 的時(shí)間復(fù)雜度。這就使得上下文切換的時(shí)間已經(jīng)和 goroutine 的規(guī)模完全不相關(guān)了。

goroutine 是如何工作的?

通常來講,一個(gè) goroutine 運(yùn)行起來通常需要三個(gè)「組件」參與:

1. golang runtime
2. runable goroutine
3. thread

golang runtime將會(huì)創(chuàng)建一些 thread 以便提供 goroutine 的運(yùn)行環(huán)境。一個(gè)可運(yùn)行的 goroutine 將會(huì)被調(diào)度到 thread 上執(zhí)行。當(dāng)該 goroutine 被 block 住(沒有 block 住對(duì)應(yīng)的 thread,如系統(tǒng)中斷等)的時(shí)候,會(huì)從「runable goroutines」中獲取一個(gè) goroutine 進(jìn)行上下文切換以至于這個(gè)新的 goroutine 能夠被執(zhí)行

golang 的 scheduler 是如何工作的?

golang 的調(diào)度模型

對(duì)于 thread 和 os 內(nèi)核來說,如果他們彼此的數(shù)量關(guān)系是1:1,在機(jī)器是多核的情況下,其并行計(jì)算能力將會(huì)被發(fā)揮到極致。但是,線程上下文切換的消耗將會(huì)對(duì)整個(gè) OS 的性能有所影響。如果他們彼此的數(shù)量關(guān)系是 N:1, 雖然上下文切換的消耗降低了,但是 CPU 的利用率卻會(huì)下降。

在 golang 的調(diào)度模型中,對(duì)于 goroutine 和 thread 的數(shù)量關(guān)系,采取了 M:N 的形式:它可以調(diào)度任意數(shù)量的 goroutine 到任意數(shù)量的 thread 上。在盡可能提高 CPU 利用率的同時(shí),也保證了 goroutine 的上下文切換操作是較為「便宜」的(都是在 usernamespace 下進(jìn)行,不需要 OS 內(nèi)核參與)。 golang 對(duì)于 goroutine 的調(diào)度,設(shè)計(jì)了三個(gè)基本的對(duì)象:

     1. machine(M)
     2. goroutine(G)
     3. processor(P)

其中 M 代表的是一個(gè)可供使用的 thread,它被 OS 內(nèi)核管理。G 則代表一個(gè) goroutine,它是輕量級(jí)的 thread。P 代表一種「資源」,只有它和 M 一起配合才能夠運(yùn)行一段 golang 的代碼,所以姑且可以把 P 理解為 goroutine 執(zhí)行過程中的上下文環(huán)境。P 是一個(gè)橋梁,他把1:1和1:N 這兩種模型結(jié)合了起來,最終產(chǎn)出了 M:N 的調(diào)度模型。

D358094D-65B7-43C0-A264-C7ED3922A493.jpg

根據(jù)上面的模型圖可以看出, golang 程序的并發(fā)能力除了受到 M(thread)的限制之外,還受到了 P(processor)數(shù)量的限制。 Thread 可以通過系統(tǒng)調(diào)用進(jìn)行創(chuàng)建,那么 P 的數(shù)量可以通過什么來進(jìn)行設(shè)置呢?在名為 runtime的 package 中有一個(gè)GOMAXPROCS 方法,我們可以通過它設(shè)置最大可使用的 P 的數(shù)量。
runtime. GOMAXPROCS這個(gè)函數(shù)本質(zhì)上是用來設(shè)置最大可用的 CPU 核心數(shù)量:

GOMAXPROCS sets the maximum number of CPUs that can be executing simultaneously and returns the previous setting. If n < 1, it does not change the current setting. The number of logical CPUs on the local machine can be queried with NumCPU. This call will go away when the scheduler improves.

由于 CPU 核心數(shù)和 P 是1:1的關(guān)系(M 的數(shù)量可以多于 P),我們認(rèn)為設(shè)置最大可用的 CPU 核心數(shù)就是設(shè)置最大可用的 P 的數(shù)量(對(duì)于一個(gè) go 程序來說)

調(diào)度過程

我們將分四種情況來了解 golang scheduler 調(diào)度 goroutine 的過程。假設(shè)現(xiàn)在有兩個(gè) M,兩個(gè) P,若干個(gè) G

steady

D358094D-65B7-43C0-A264-C7ED3922A493.jpg

圖中灰色的 G 表示了可被執(zhí)行但是還未被調(diào)度的 goroutine。P 每次從「可被執(zhí)行的 goroutine 隊(duì)列」中選取一個(gè) goroutine 調(diào)度到 M 執(zhí)行。當(dāng)前的 goroutine 被執(zhí)行完成之后,將從隊(duì)列中彈出。P 會(huì)不斷的重復(fù)上述的過程處理 goroutine。

值得注意的是,在 golang 1.2之前的版本當(dāng)中,「可被執(zhí)行的 goroutine 隊(duì)列」和 P 并不是 1:1 的關(guān)系。整個(gè) go runtime 中只有一個(gè)全局的「可被執(zhí)行的 goroutine 隊(duì)列」。它通過一個(gè)全局的鎖來防止并發(fā)讀寫時(shí)的「競(jìng)爭(zhēng)」問題。這種設(shè)計(jì)無疑是低效的,尤其是在 CPU 核心數(shù)較多的機(jī)器上。

理論上來說,只要 P 所控制的「可被執(zhí)行的 goroutine 隊(duì)列」不為空,那么這個(gè)調(diào)度過程就是穩(wěn)定的。

busy

D358094D-65B7-43C0-A264-C7ED3922A493.jpg

Busy 的情況就是指所有的 M 上都有正在運(yùn)行的 G,沒有空閑的 P,也沒有空閑的 M。此時(shí)整個(gè)調(diào)度過程如上面 steady 情況所描述的一樣。

idle

通過對(duì)上面兩種情況下調(diào)度過程的了解,我們?cè)俅位仡櫼幌?,一個(gè)「最小的調(diào)度單位」都包括哪些元素:

    1. Gs(runable goroutine queue)
    2. M
    3. P

Idle 狀態(tài)即是:部分 P 中掛載的 runable goroutine queue已經(jīng)沒有剩余的 goroutine 可供調(diào)度。如下圖所示,兩個(gè)「最小調(diào)度單位」中,已經(jīng)有一個(gè)的 runable goroutine queue 為空了。此時(shí),為了能夠讓所有的 M 的利用率達(dá)到最大,golang runtime 會(huì)采取以下兩種機(jī)制來處理 idle 狀態(tài):

     1. 從 global runable goroutine queue 中選取 goroutine
     2. 若 global runable goroutine queue 中也沒有 goroutine,隨機(jī)選取選取一個(gè) P,從其掛載的 runable goroutine queue 中 steal 走一半的 goroutine

一個(gè)更加通用的調(diào)度過程的描述如下:

     runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     try to steal from other Ps.
    //     if not, check the global runnable queue.
}

在描述 steady 狀態(tài)的調(diào)度過程的時(shí)候,我們提到過老版本的 Go 中沒有和 P 綁定的 runable goroutine queue, 而只有一個(gè)全局的 gloabl runable goroutine queue。雖然在之后的版本中不再使用鎖+全局隊(duì)列的機(jī)制來實(shí)現(xiàn)調(diào)度器,但它仍然被保存了下來,并且在查找可被調(diào)度的 goroutine 時(shí)還會(huì)被訪問到。至于原因,我們會(huì)在后面說到。

syscall

當(dāng)我們的 goroutine 邏輯中有使用「系統(tǒng)調(diào)用」的代碼時(shí),其對(duì)應(yīng)的 M 會(huì)被阻塞。此時(shí) P 中掛載的 runable goroutine queue 中的 goroutine 在短時(shí)間內(nèi)將不會(huì)被這個(gè) M 調(diào)度執(zhí)行?,F(xiàn)在看起來這些「剩余」的 goroutine 進(jìn)入了一個(gè)比較尷尬的狀態(tài),它們似乎只能等待其對(duì)應(yīng)的 M 從阻塞狀態(tài)中釋放出來才能夠被重新調(diào)度執(zhí)行。

在了解 go scheduler 如何處理這種情況之前,我們可以根據(jù)之前的了解先自己思考一下:

  1. 將剩余的 goroutine 全部放入 global runable goroutine queue 中等待被調(diào)度執(zhí)行

  2. 進(jìn)行 context switch 操作,將 P 連同剩下的 runable goroutine queue 切換到一個(gè)較為 idle 的 M 上等待被調(diào)度執(zhí)行

第一種辦法最簡(jiǎn)單暴力,但是缺點(diǎn)也很明顯,全局隊(duì)列是需要有鎖參與的,效率肯定不高。第二種辦法的思路來源于對(duì) idle 狀態(tài)的討論。既然不同的 P 之前都可以 steal 彼此的
goroutine,那么為什么不能直接一次性把 P 和整個(gè) runable goroutine queue 都拿過來呢?

EA9C45C7-430A-44F6-9E01-24409CAF1D43.jpg

實(shí)際上,golang scheduler 的做法和我們想的第二種比較相似。不同的 P 之間會(huì)進(jìn)行上下文切換。將已經(jīng)被 block 住的 M 上掛載的 P 連同 runable goroutine queue 全部切換到到一個(gè)空閑的 M 上等待被調(diào)度執(zhí)行。而之前那個(gè)被 block 住的 M 將會(huì)帶著一個(gè) G 等待被 unblock。 Unblock 之后,對(duì)于一個(gè)「最小調(diào)度單位」而言,舊的 M 和 G 顯然是缺少了一個(gè) P 的。所以它按照「之前別人對(duì)付它的方式」看是否有機(jī)會(huì)能夠從其他的 M 上 steal 到一個(gè) P 和其掛載的 runable goroutine queue。如果這個(gè) steal 的行為失敗,那么它將會(huì)把帶著的 G 丟到 global runable queue 中。至于這個(gè) M 如何被進(jìn)一步處理,那又是一個(gè)新的問題了,我們?cè)谑S嗟钠袑?huì)提到。

至此,對(duì)于 golang scheduler 調(diào)度 goroutine 的執(zhí)行過程我們就大致的講完了。對(duì)于在討論 idle 情況的時(shí)候,我們留下的那個(gè)「為什么 global runable queue 會(huì)被保留」的問題,相信在討論 syscall 情況的時(shí)候已經(jīng)給出了答案。

為什么會(huì)有空閑的 thread 在等待被使用呢?(Spinning Thread)

在討論調(diào)度過程中關(guān)于 syscall 情況的時(shí)候,你會(huì)發(fā)現(xiàn),有以下幾個(gè)比較奇怪的地方:

  1. Golang scheduler 不是會(huì)最大限度的提高 thread 的利用率么?為什么還有一個(gè)空閑的 M 在那呢?
  2. 那個(gè)空閑的 M 為什么沒有 P 呢?

理論上來說,一個(gè)thread 如果完成了它所要做的事情就應(yīng)該被 OS 銷毀,接下來其他進(jìn)程中的 thread 就可能被 CPU 調(diào)度執(zhí)行。這也就是我們常說的操作系統(tǒng)中線程的「搶占式調(diào)度」。考慮上面 syscall 中的情況,如果一個(gè)程序現(xiàn)在有兩個(gè) M,其中一個(gè)因?yàn)槭虑樽鐾甓讳N毀,另外一個(gè)因?yàn)?syscall 的原因被 block。此時(shí),被block 的 M 上掛載的 runable goroutines 就必須要等到下一次這個(gè) M 被 OS 調(diào)度執(zhí)行的時(shí)候才會(huì)機(jī)會(huì)繼續(xù)被處理。頻繁的線程間的搶占操作不但會(huì)使得 OS 的負(fù)載升高,對(duì)一些對(duì)性能要求較高的程序來講幾乎是不可接受的。

golang scheduler 的設(shè)計(jì)者在考慮了「 OS 的資源利用率」以及「頻繁的 thread 搶占給 OS 帶來的負(fù)載」之后,最終提出了「Spinning Thread」的概念。自旋線程在沒有找到可供其調(diào)度執(zhí)行的 goroutine 之后,并不會(huì)銷毀,而是采取「自旋」的操作保存了下來。雖然看起來這是浪費(fèi)了一些資源,但是考慮一下 syscall 的情景就可以知道,比起「自旋」,線程間頻繁的搶占以及頻繁的創(chuàng)建和銷毀操作可能帶來的危害會(huì)更大

對(duì)于一個(gè) go 的程序來說,可存在的「Spining Thread」的數(shù)量是可以通過runtime. GOMAXPROCS函數(shù)設(shè)置的。runtime. GOMAXPROCS函數(shù)本意是設(shè)置最大可用的 CPU 核心數(shù),但是仔細(xì)想想就可以明白「Spining Thread」出現(xiàn)的目的就是在其他 M 出現(xiàn)問題的時(shí)候,可以直接接管 P 繼續(xù)處理 G。而 P 的概念在 golang 的調(diào)度模型中又相當(dāng)于是 CPU 的一個(gè)核。所以 「Spining Thread」的數(shù)量最合適的就是和最大可用的 CPU 核心數(shù)保持一致。

舉例來說,在具有1個(gè) M和1個(gè) P的一個(gè)程序中,如果正在執(zhí)行的 M 已經(jīng)被 syscall block 住,那么仍然需要和 P 數(shù)量相同的「Spining Thread」才能夠讓等待的 runable goroutine 繼續(xù)執(zhí)行。所以,在此期間, M 的數(shù)量是要多余 P 的數(shù)量的(一個(gè) Spinning Thread+一個(gè)被 block 住的 thread)。這也就是為什么,當(dāng)runtime. GOMAXPROCS函數(shù)設(shè)置的值為1的時(shí)候,程序仍然是處于多線程運(yùn)行的狀態(tài)的。

根據(jù)上面的描述,「Spining Thread」是一個(gè)特殊的 M,當(dāng)一個(gè) M 具有以下幾個(gè)特點(diǎn)中的一個(gè)的時(shí)候,它就可以被稱作是一個(gè)「Spining Thread」:

     1. An M with a P assignment is looking for a runnable goroutine.
2. An M without a P assignment is looking for available Ps.
3. Scheduler also unparks an additional thread and spins it when it is readying a goroutine if there is an idle P and there are no other spinning threads.

針對(duì)一開始我們談到的兩個(gè)問題現(xiàn)在也不難給出答案:

  1. 充分提高 thread 利用率是在 runable goroutine 數(shù)量足夠多的情況下,盡可能的將它們調(diào)度到 M 執(zhí)行。但是當(dāng) runable goroutine 數(shù)量不會(huì)讓所有的 M 都處于工作狀態(tài)的時(shí)候,golang scheduler 也并不會(huì)直接把它們銷毀,而是至多留出runtime. GOMAXPROCS個(gè)處于 Spinning 狀態(tài)的 M,等待被阻塞的 M 下掛載的 runable goroutine。這是為了避免線程間頻繁的搶占操作給 OS 帶來的壓力,同時(shí)也盡可能的保證了 runable goroutine 能夠快速的被處理
  2. 空閑的 M 但是沒有掛載 P 也是「Spining Thread」 中的一類

注意:本文參考其他文章

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容