Go 語言 Goroutines和線程

2023-03-14 16:58 更新

原文鏈接:https://gopl-zh.github.io/ch9/ch9-08.html


9.8. Goroutines和線程

在上一章中我們說goroutine和操作系統(tǒng)的線程區(qū)別可以先忽略。盡管兩者的區(qū)別實(shí)際上只是一個(gè)量的區(qū)別,但量變會(huì)引起質(zhì)變的道理同樣適用于goroutine和線程?,F(xiàn)在正是我們來區(qū)分開兩者的最佳時(shí)機(jī)。

9.8.1. 動(dòng)態(tài)棧

每一個(gè)OS線程都有一個(gè)固定大小的內(nèi)存塊(一般會(huì)是2MB)來做棧,這個(gè)棧會(huì)用來存儲(chǔ)當(dāng)前正在被調(diào)用或掛起(指在調(diào)用其它函數(shù)時(shí))的函數(shù)的內(nèi)部變量。這個(gè)固定大小的棧同時(shí)很大又很小。因?yàn)?MB的棧對于一個(gè)小小的goroutine來說是很大的內(nèi)存浪費(fèi),比如對于我們用到的,一個(gè)只是用來WaitGroup之后關(guān)閉channel的goroutine來說。而對于go程序來說,同時(shí)創(chuàng)建成百上千個(gè)goroutine是非常普遍的,如果每一個(gè)goroutine都需要這么大的棧的話,那這么多的goroutine就不太可能了。除去大小的問題之外,固定大小的棧對于更復(fù)雜或者更深層次的遞歸函數(shù)調(diào)用來說顯然是不夠的。修改固定的大小可以提升空間的利用率,允許創(chuàng)建更多的線程,并且可以允許更深的遞歸調(diào)用,不過這兩者是沒法同時(shí)兼?zhèn)涞摹?/p>

相反,一個(gè)goroutine會(huì)以一個(gè)很小的棧開始其生命周期,一般只需要2KB。一個(gè)goroutine的棧,和操作系統(tǒng)線程一樣,會(huì)保存其活躍或掛起的函數(shù)調(diào)用的本地變量,但是和OS線程不太一樣的是,一個(gè)goroutine的棧大小并不是固定的;棧的大小會(huì)根據(jù)需要?jiǎng)討B(tài)地伸縮。而goroutine的棧的最大值有1GB,比傳統(tǒng)的固定大小的線程棧要大得多,盡管一般情況下,大多goroutine都不需要這么大的棧。

練習(xí) 9.4: 創(chuàng)建一個(gè)流水線程序,支持用channel連接任意數(shù)量的goroutine,在跑爆內(nèi)存之前,可以創(chuàng)建多少流水線階段?一個(gè)變量通過整個(gè)流水線需要用多久?(這個(gè)練習(xí)題翻譯不是很確定)

9.8.2. Goroutine調(diào)度

OS線程會(huì)被操作系統(tǒng)內(nèi)核調(diào)度。每幾毫秒,一個(gè)硬件計(jì)時(shí)器會(huì)中斷處理器,這會(huì)調(diào)用一個(gè)叫作scheduler的內(nèi)核函數(shù)。這個(gè)函數(shù)會(huì)掛起當(dāng)前執(zhí)行的線程并將它的寄存器內(nèi)容保存到內(nèi)存中,檢查線程列表并決定下一次哪個(gè)線程可以被運(yùn)行,并從內(nèi)存中恢復(fù)該線程的寄存器信息,然后恢復(fù)執(zhí)行該線程的現(xiàn)場并開始執(zhí)行線程。因?yàn)椴僮飨到y(tǒng)線程是被內(nèi)核所調(diào)度,所以從一個(gè)線程向另一個(gè)“移動(dòng)”需要完整的上下文切換,也就是說,保存一個(gè)用戶線程的狀態(tài)到內(nèi)存,恢復(fù)另一個(gè)線程的到寄存器,然后更新調(diào)度器的數(shù)據(jù)結(jié)構(gòu)。這幾步操作很慢,因?yàn)槠渚植啃院懿钚枰獛状蝺?nèi)存訪問,并且會(huì)增加運(yùn)行的cpu周期。

Go的運(yùn)行時(shí)包含了其自己的調(diào)度器,這個(gè)調(diào)度器使用了一些技術(shù)手段,比如m:n調(diào)度,因?yàn)槠鋾?huì)在n個(gè)操作系統(tǒng)線程上多工(調(diào)度)m個(gè)goroutine。Go調(diào)度器的工作和內(nèi)核的調(diào)度是相似的,但是這個(gè)調(diào)度器只關(guān)注單獨(dú)的Go程序中的goroutine(譯注:按程序獨(dú)立)。

和操作系統(tǒng)的線程調(diào)度不同的是,Go調(diào)度器并不是用一個(gè)硬件定時(shí)器,而是被Go語言“建筑”本身進(jìn)行調(diào)度的。例如當(dāng)一個(gè)goroutine調(diào)用了time.Sleep,或者被channel調(diào)用或者mutex操作阻塞時(shí),調(diào)度器會(huì)使其進(jìn)入休眠并開始執(zhí)行另一個(gè)goroutine,直到時(shí)機(jī)到了再去喚醒第一個(gè)goroutine。因?yàn)檫@種調(diào)度方式不需要進(jìn)入內(nèi)核的上下文,所以重新調(diào)度一個(gè)goroutine比調(diào)度一個(gè)線程代價(jià)要低得多。

練習(xí) 9.5:  寫一個(gè)有兩個(gè)goroutine的程序,兩個(gè)goroutine會(huì)向兩個(gè)無buffer channel反復(fù)地發(fā)送ping-pong消息。這樣的程序每秒可以支持多少次通信?

9.8.3. GOMAXPROCS

Go的調(diào)度器使用了一個(gè)叫做GOMAXPROCS的變量來決定會(huì)有多少個(gè)操作系統(tǒng)的線程同時(shí)執(zhí)行Go的代碼。其默認(rèn)的值是運(yùn)行機(jī)器上的CPU的核心數(shù),所以在一個(gè)有8個(gè)核心的機(jī)器上時(shí),調(diào)度器一次會(huì)在8個(gè)OS線程上去調(diào)度GO代碼。(GOMAXPROCS是前面說的m:n調(diào)度中的n)。在休眠中的或者在通信中被阻塞的goroutine是不需要一個(gè)對應(yīng)的線程來做調(diào)度的。在I/O中或系統(tǒng)調(diào)用中或調(diào)用非Go語言函數(shù)時(shí),是需要一個(gè)對應(yīng)的操作系統(tǒng)線程的,但是GOMAXPROCS并不需要將這幾種情況計(jì)算在內(nèi)。

你可以用GOMAXPROCS的環(huán)境變量來顯式地控制這個(gè)參數(shù),或者也可以在運(yùn)行時(shí)用runtime.GOMAXPROCS函數(shù)來修改它。我們在下面的小程序中會(huì)看到GOMAXPROCS的效果,這個(gè)程序會(huì)無限打印0和1。

for {
    go fmt.Print(0)
    fmt.Print(1)
}

$ GOMAXPROCS=1 go run hacker-cliche?.go
111111111111111111110000000000000000000011111...

$ GOMAXPROCS=2 go run hacker-cliche?.go
010101010101010101011001100101011010010100110...

在第一次執(zhí)行時(shí),最多同時(shí)只能有一個(gè)goroutine被執(zhí)行。初始情況下只有main goroutine被執(zhí)行,所以會(huì)打印很多1。過了一段時(shí)間后,GO調(diào)度器會(huì)將其置為休眠,并喚醒另一個(gè)goroutine,這時(shí)候就開始打印很多0了,在打印的時(shí)候,goroutine是被調(diào)度到操作系統(tǒng)線程上的。在第二次執(zhí)行時(shí),我們使用了兩個(gè)操作系統(tǒng)線程,所以兩個(gè)goroutine可以一起被執(zhí)行,以同樣的頻率交替打印0和1。我們必須強(qiáng)調(diào)的是goroutine的調(diào)度是受很多因子影響的,而runtime也是在不斷地發(fā)展演進(jìn)的,所以這里的你實(shí)際得到的結(jié)果可能會(huì)因?yàn)榘姹镜牟煌c我們運(yùn)行的結(jié)果有所不同。

練習(xí)9.6: 測試一下計(jì)算密集型的并發(fā)程序(練習(xí)8.5那樣的)會(huì)被GOMAXPROCS怎樣影響到。在你的電腦上最佳的值是多少?你的電腦CPU有多少個(gè)核心?

9.8.4. Goroutine沒有ID號(hào)

在大多數(shù)支持多線程的操作系統(tǒng)和程序語言中,當(dāng)前的線程都有一個(gè)獨(dú)特的身份(id),并且這個(gè)身份信息可以以一個(gè)普通值的形式被很容易地獲取到,典型的可以是一個(gè)integer或者指針值。這種情況下我們做一個(gè)抽象化的thread-local storage(線程本地存儲(chǔ),多線程編程中不希望其它線程訪問的內(nèi)容)就很容易,只需要以線程的id作為key的一個(gè)map就可以解決問題,每一個(gè)線程以其id就能從中獲取到值,且和其它線程互不沖突。

goroutine沒有可以被程序員獲取到的身份(id)的概念。這一點(diǎn)是設(shè)計(jì)上故意而為之,由于thread-local storage總是會(huì)被濫用。比如說,一個(gè)web server是用一種支持tls的語言實(shí)現(xiàn)的,而非常普遍的是很多函數(shù)會(huì)去尋找HTTP請求的信息,這代表它們就是去其存儲(chǔ)層(這個(gè)存儲(chǔ)層有可能是tls)查找的。這就像是那些過分依賴全局變量的程序一樣,會(huì)導(dǎo)致一種非健康的“距離外行為”,在這種行為下,一個(gè)函數(shù)的行為可能并不僅由自己的參數(shù)所決定,而是由其所運(yùn)行在的線程所決定。因此,如果線程本身的身份會(huì)改變——比如一些worker線程之類的——那么函數(shù)的行為就會(huì)變得神秘莫測。

Go鼓勵(lì)更為簡單的模式,這種模式下參數(shù)(譯注:外部顯式參數(shù)和內(nèi)部顯式參數(shù)。tls 中的內(nèi)容算是"外部"隱式參數(shù))對函數(shù)的影響都是顯式的。這樣不僅使程序變得更易讀,而且會(huì)讓我們自由地向一些給定的函數(shù)分配子任務(wù)時(shí)不用擔(dān)心其身份信息影響行為。

你現(xiàn)在應(yīng)該已經(jīng)明白了寫一個(gè)Go程序所需要的所有語言特性信息。在后面兩章節(jié)中,我們會(huì)回顧一些之前的實(shí)例和工具,支持我們寫出更大規(guī)模的程序:如何將一個(gè)工程組織成一系列的包,如何獲取,構(gòu)建,測試,性能測試,剖析,寫文檔,并且將這些包分享出去。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)