Go语言的并发模型
Go語言的并發(fā)模型
Go 語言相比Java等一個很大的優(yōu)勢就是可以方便地編寫并發(fā)程序。Go 語言內(nèi)置了 goroutine 機(jī)制,使用goroutine可以快速地開發(fā)并發(fā)程序, 更好的利用多核處理器資源。接下來我們來了解一下Go語言的并發(fā)原理。
一、線程模型
在現(xiàn)代操作系統(tǒng)中,線程是處理器調(diào)度和分配的基本單位,進(jìn)程則作為資源擁有的基本單位。每個進(jìn)程是由私有的虛擬地址空間、代碼、數(shù)據(jù)和其它各種系統(tǒng)資源組成。線程是進(jìn)程內(nèi)部的一個執(zhí)行單元。 每一個進(jìn)程至少有一個主執(zhí)行線程,它無需由用戶去主動創(chuàng)建,是由系統(tǒng)自動創(chuàng)建的。 用戶根據(jù)需要在應(yīng)用程序中創(chuàng)建其它線程,多個線程并發(fā)地運行于同一個進(jìn)程中。
我們先從線程講起,無論語言層面何種并發(fā)模型,到了操作系統(tǒng)層面,一定是以線程的形態(tài)存在的。而操作系統(tǒng)根據(jù)資源訪問權(quán)限的不同,體系架構(gòu)可分為用戶空間和內(nèi)核空間;內(nèi)核空間主要操作訪問CPU資源、I/O資源、內(nèi)存資源等硬件資源,為上層應(yīng)用程序提供最基本的基礎(chǔ)資源,用戶空間呢就是上層應(yīng)用程序的固定活動空間,用戶空間不可以直接訪問資源,必須通過“系統(tǒng)調(diào)用”、“庫函數(shù)”或“Shell腳本”來調(diào)用內(nèi)核空間提供的資源。
我們現(xiàn)在的計算機(jī)語言,可以狹義的認(rèn)為是一種“軟件”,它們中所謂的“線程”,往往是用戶態(tài)的線程,和操作系統(tǒng)本身內(nèi)核態(tài)的線程(簡稱KSE),還是有區(qū)別的。
Go并發(fā)編程模型在底層是由操作系統(tǒng)所提供的線程庫支撐的,因此還是得從線程實現(xiàn)模型說起。
線程可以視為進(jìn)程中的控制流。一個進(jìn)程至少會包含一個線程,因為其中至少會有一個控制流持續(xù)運行。因而,一個進(jìn)程的第一個線程會隨著這個進(jìn)程的啟動而創(chuàng)建,這個線程稱為該進(jìn)程的主線程。當(dāng)然,一個進(jìn)程也可以包含多個線程。這些線程都是由當(dāng)前進(jìn)程中已存在的線程創(chuàng)建出來的,創(chuàng)建的方法就是調(diào)用系統(tǒng)調(diào)用,更確切地說是調(diào)用
pthread create函數(shù)。擁有多個線程的進(jìn)程可以并發(fā)執(zhí)行多個任務(wù),并且即使某個或某些任務(wù)被阻塞,也不會影響其他任務(wù)正常執(zhí)行,這可以大大改善程序的響應(yīng)時間和吞吐量。另一方面,線程不可能獨立于進(jìn)程存在。它的生命周期不可能逾越其所屬進(jìn)程的生命周期。
線程的實現(xiàn)模型主要有3個,分別是:用戶級線程模型、內(nèi)核級線程模型和兩級線程模型。它們之間最大的差異就在于線程與內(nèi)核調(diào)度實體( Kernel Scheduling Entity,簡稱KSE)之間的對應(yīng)關(guān)系上。顧名思義,內(nèi)核調(diào)度實體就是可以被內(nèi)核的調(diào)度器調(diào)度的對象。在很多文獻(xiàn)和書中,它也稱為內(nèi)核級線程,是操作系統(tǒng)內(nèi)核的最小調(diào)度單元。
1.1 內(nèi)核級線程模型
用戶線程與KSE是1對1關(guān)系(1:1)。大部分編程語言的線程庫(如linux的pthread,Java的java.lang.Thread,C++11的std::thread等等)都是對操作系統(tǒng)的線程(內(nèi)核級線程)的一層封裝,創(chuàng)建出來的每個線程與一個不同的KSE靜態(tài)關(guān)聯(lián),因此其調(diào)度完全由OS調(diào)度器來做。這種方式實現(xiàn)簡單,直接借助OS提供的線程能力,并且不同用戶線程之間一般也不會相互影響。但其創(chuàng)建,銷毀以及多個線程之間的上下文切換等操作都是直接由OS層面親自來做,在需要使用大量線程的場景下對OS的性能影響會很大。
每個線程由內(nèi)核調(diào)度器獨立的調(diào)度,所以如果一個線程阻塞則不影響其他的線程。
優(yōu)點:在多核處理器的硬件的支持下,內(nèi)核空間線程模型支持了真正的并行,當(dāng)一個線程被阻塞后,允許另一個線程繼續(xù)執(zhí)行,所以并發(fā)能力較強(qiáng)。
缺點:每創(chuàng)建一個用戶級線程都需要創(chuàng)建一個內(nèi)核級線程與其對應(yīng),這樣創(chuàng)建線程的開銷比較大,會影響到應(yīng)用程序的性能。
1.2 用戶級線程模型
用戶線程與KSE是多對1關(guān)系(M:1),這種線程的創(chuàng)建,銷毀以及多個線程之間的協(xié)調(diào)等操作都是由用戶自己實現(xiàn)的線程庫來負(fù)責(zé),對OS內(nèi)核透明,一個進(jìn)程中所有創(chuàng)建的線程都與同一個KSE在運行時動態(tài)關(guān)聯(lián)。現(xiàn)在有許多語言實現(xiàn)的 協(xié)程 基本上都屬于這種方式。這種實現(xiàn)方式相比內(nèi)核級線程可以做的很輕量級,對系統(tǒng)資源的消耗會小很多,因此可以創(chuàng)建的數(shù)量與上下文切換所花費的代價也會小得多。但該模型有個致命的缺點,如果我們在某個用戶線程上調(diào)用阻塞式系統(tǒng)調(diào)用(如用阻塞方式read網(wǎng)絡(luò)IO),那么一旦KSE因阻塞被內(nèi)核調(diào)度出CPU的話,剩下的所有對應(yīng)的用戶線程全都會變?yōu)樽枞麪顟B(tài)(整個進(jìn)程掛起)。
所以這些語言的協(xié)程庫會把自己一些阻塞的操作重新封裝為完全的非阻塞形式,然后在以前要阻塞的點上,主動讓出自己,并通過某種方式通知或喚醒其他待執(zhí)行的用戶線程在該KSE上運行,從而避免了內(nèi)核調(diào)度器由于KSE阻塞而做上下文切換,這樣整個進(jìn)程也不會被阻塞了。
優(yōu)點: 這種模型的好處是線程上下文切換都發(fā)生在用戶空間,避免的模態(tài)切換(mode switch),從而對于性能有積極的影響。
缺點:所有的線程基于一個內(nèi)核調(diào)度實體即內(nèi)核線程,這意味著只有一個處理器可以被利用,在多處理器環(huán)境下這是不能夠被接受的,本質(zhì)上,用戶線程只解決了并發(fā)問題,但是沒有解決并行問題。如果線程因為 I/O 操作陷入了內(nèi)核態(tài),內(nèi)核態(tài)線程阻塞等待 I/O 數(shù)據(jù),則所有的線程都將會被阻塞,用戶空間也可以使用非阻塞而 I/O,但是不能避免性能及復(fù)雜度問題。
1.3 兩級線程模型
用戶線程與KSE是多對多關(guān)系(M:N),這種實現(xiàn)綜合了前兩種模型的優(yōu)點,為一個進(jìn)程中創(chuàng)建多個KSE,并且線程可以與不同的KSE在運行時進(jìn)行動態(tài)關(guān)聯(lián),當(dāng)某個KSE由于其上工作的線程的阻塞操作被內(nèi)核調(diào)度出CPU時,當(dāng)前與其關(guān)聯(lián)的其余用戶線程可以重新與其他KSE建立關(guān)聯(lián)關(guān)系。當(dāng)然這種動態(tài)關(guān)聯(lián)機(jī)制的實現(xiàn)很復(fù)雜,也需要用戶自己去實現(xiàn),這算是它的一個缺點吧。Go語言中的并發(fā)就是使用的這種實現(xiàn)方式,Go為了實現(xiàn)該模型自己實現(xiàn)了一個運行時調(diào)度器來負(fù)責(zé)Go中的"線程"與KSE的動態(tài)關(guān)聯(lián)。此模型有時也被稱為 混合型線程模型,即用戶調(diào)度器實現(xiàn)用戶線程到KSE的“調(diào)度”,內(nèi)核調(diào)度器實現(xiàn)KSE到CPU上的調(diào)度。
二、Go并發(fā)調(diào)度: G-P-M模型
在操作系統(tǒng)提供的內(nèi)核線程之上,Go搭建了一個特有的兩級線程模型。goroutine機(jī)制實現(xiàn)了M : N的線程模型,goroutine機(jī)制是協(xié)程(coroutine)的一種實現(xiàn),golang內(nèi)置的調(diào)度器,可以讓多核CPU中每個CPU執(zhí)行一個協(xié)程。
2.1 調(diào)度器是如何工作的
有了上面的認(rèn)識,我們可以開始真正的介紹Go的并發(fā)機(jī)制了,先用一段代碼展示一下在Go語言中新建一個“線程”(Go語言中稱為Goroutine)的樣子:
// 用go關(guān)鍵字加上一個函數(shù)(這里用了匿名函數(shù)) // 調(diào)用就做到了在一個新的“線程”并發(fā)執(zhí)行任務(wù) go func() { // do something in one new goroutine }()功能上等價于Java8的代碼:
new java.lang.Thread(() -> { // do something in one new thread }).start();理解goroutine機(jī)制的原理,關(guān)鍵是理解Go語言scheduler的實現(xiàn)。
Go語言中支撐整個scheduler實現(xiàn)的主要有4個重要結(jié)構(gòu),分別是M、G、P、Sched, 前三個定義在runtime.h中,Sched定義在proc.c中。
- Sched結(jié)構(gòu)就是調(diào)度器,它維護(hù)有存儲M和G的隊列以及調(diào)度器的一些狀態(tài)信息等。
- M結(jié)構(gòu)是Machine,系統(tǒng)線程,它由操作系統(tǒng)管理的,goroutine就是跑在M之上的;M是一個很大的結(jié)構(gòu),里面維護(hù)小對象內(nèi)存cache(mcache)、當(dāng)前執(zhí)行的goroutine、隨機(jī)數(shù)發(fā)生器等等非常多的信息。
- P結(jié)構(gòu)是Processor,處理器,它的主要用途就是用來執(zhí)行g(shù)oroutine的,它維護(hù)了一個goroutine隊列,即runqueue。Processor是讓我們從N:1調(diào)度到M:N調(diào)度的重要部分。
- G是goroutine實現(xiàn)的核心結(jié)構(gòu),它包含了棧,指令指針,以及其他對調(diào)度goroutine很重要的信息,例如其阻塞的channel。
Processor的數(shù)量是在啟動時被設(shè)置為環(huán)境變量GOMAXPROCS的值,或者通過運行時調(diào)用函數(shù)GOMAXPROCS()進(jìn)行設(shè)置。Processor數(shù)量固定意味著任意時刻只有GOMAXPROCS個線程在運行g(shù)o代碼。
我們分別用三角形,矩形和圓形表示Machine Processor和Goroutine。
在單核處理器的場景下,所有g(shù)oroutine運行在同一個M系統(tǒng)線程中,每一個M系統(tǒng)線程維護(hù)一個Processor,任何時刻,一個Processor中只有一個goroutine,其他goroutine在runqueue中等待。一個goroutine運行完自己的時間片后,讓出上下文,回到runqueue中。 多核處理器的場景下,為了運行g(shù)oroutines,每個M系統(tǒng)線程會持有一個Processor。
在正常情況下,scheduler會按照上面的流程進(jìn)行調(diào)度,但是線程會發(fā)生阻塞等情況,看一下goroutine對線程阻塞等的處理。
2.2 線程阻塞
當(dāng)正在運行的goroutine阻塞的時候,例如進(jìn)行系統(tǒng)調(diào)用,會再創(chuàng)建一個系統(tǒng)線程(M1),當(dāng)前的M線程放棄了它的Processor,P轉(zhuǎn)到新的線程中去運行。
2.3 runqueue執(zhí)行完成
當(dāng)其中一個Processor的runqueue為空,沒有g(shù)oroutine可以調(diào)度。它會從另外一個上下文偷取一半的goroutine。
其圖中的G,P和M都是Go語言運行時系統(tǒng)(其中包括內(nèi)存分配器,并發(fā)調(diào)度器,垃圾收集器等組件,可以想象為Java中的JVM)抽象出來概念和數(shù)據(jù)結(jié)構(gòu)對象:
G:Goroutine的簡稱,上面用go關(guān)鍵字加函數(shù)調(diào)用的代碼就是創(chuàng)建了一個G對象,是對一個要并發(fā)執(zhí)行的任務(wù)的封裝,也可以稱作用戶態(tài)線程。屬于用戶級資源,對OS透明,具備輕量級,可以大量創(chuàng)建,上下文切換成本低等特點。
M:Machine的簡稱,在linux平臺上是用clone系統(tǒng)調(diào)用創(chuàng)建的,其與用linux pthread庫創(chuàng)建出來的線程本質(zhì)上是一樣的,都是利用系統(tǒng)調(diào)用創(chuàng)建出來的OS線程實體。M的作用就是執(zhí)行G中包裝的并發(fā)任務(wù)。Go運行時系統(tǒng)中的調(diào)度器的主要職責(zé)就是將G公平合理的安排到多個M上去執(zhí)行。其屬于OS資源,可創(chuàng)建的數(shù)量上也受限了OS,通常情況下G的數(shù)量都多于活躍的M的。
P:Processor的簡稱,邏輯處理器,主要作用是管理G對象(每個P都有一個G隊列),并為G在M上的運行提供本地化資源。
從兩級線程模型來看,似乎并不需要P的參與,有G和M就可以了,那為什么要加入P這個東東呢?
其實Go語言運行時系統(tǒng)早期(Go1.0)的實現(xiàn)中并沒有P的概念,Go中的調(diào)度器直接將G分配到合適的M上運行。但這樣帶來了很多問題,例如,不同的G在不同的M上并發(fā)運行時可能都需向系統(tǒng)申請資源(如堆內(nèi)存),由于資源是全局的,將會由于資源競爭造成很多系統(tǒng)性能損耗,為了解決類似的問題,后面的Go(Go1.1)運行時系統(tǒng)加入了P,讓P去管理G對象,M要想運行G必須先與一個P綁定,然后才能運行該P(yáng)管理的G。這樣帶來的好處是,我們可以在P對象中預(yù)先申請一些系統(tǒng)資源(本地資源),G需要的時候先向自己的本地P申請(無需鎖保護(hù)),如果不夠用或沒有再向全局申請,而且從全局拿的時候會多拿一部分,以供后面高效的使用。就像現(xiàn)在我們?nèi)フk事情一樣,先去本地政府看能搞定不,如果搞不定再去中央,從而提供辦事效率。
而且由于P解耦了G和M對象,這樣即使M由于被其上正在運行的G阻塞住,其余與該M關(guān)聯(lián)的G也可以隨著P一起遷移到別的活躍的M上繼續(xù)運行,從而讓G總能及時找到M并運行自己,從而提高系統(tǒng)的并發(fā)能力。
Go運行時系統(tǒng)通過構(gòu)造G-P-M對象模型實現(xiàn)了一套用戶態(tài)的并發(fā)調(diào)度系統(tǒng),可以自己管理和調(diào)度自己的并發(fā)任務(wù),所以可以說Go語言原生支持并發(fā)。自己實現(xiàn)的調(diào)度器負(fù)責(zé)將并發(fā)任務(wù)分配到不同的內(nèi)核線程上運行,然后內(nèi)核調(diào)度器接管內(nèi)核線程在CPU上的執(zhí)行與調(diào)度。
可以看到Go的并發(fā)用起來非常簡單,用了一個語法糖將內(nèi)部復(fù)雜的實現(xiàn)結(jié)結(jié)實實的包裝了起來。其內(nèi)部可以用下面這張圖來概述:
寫在最后,Go運行時完整的調(diào)度系統(tǒng)是很復(fù)雜,很難用一篇文章描述的清楚,這里只能從宏觀上介紹一下,讓大家有個整體的認(rèn)識。
// Goroutine1 func task1() {go task2()go task3() }假如我們有一個G(Goroutine1)已經(jīng)通過P被安排到了一個M上正在執(zhí)行,在Goroutine1執(zhí)行的過程中我們又創(chuàng)建兩個G,這兩個G會被馬上放入與Goroutine1相同的P的本地G任務(wù)隊列中,排隊等待與該P(yáng)綁定的M的執(zhí)行,這是最基本的結(jié)構(gòu),很好理解。 關(guān)鍵問題是:
a.如何在一個多核心系統(tǒng)上盡量合理分配G到多個M上運行,充分利用多核,提高并發(fā)能力呢?
如果我們在一個Goroutine中通過go關(guān)鍵字創(chuàng)建了大量G,這些G雖然暫時會被放在同一個隊列, 但如果這時還有空閑P(系統(tǒng)內(nèi)P的數(shù)量默認(rèn)等于系統(tǒng)cpu核心數(shù)),Go運行時系統(tǒng)始終能保證至少有一個(通常也只有一個)活躍的M與空閑P綁定去各種G隊列去尋找可運行的G任務(wù),該種M稱為自旋的M。一般尋找順序為:自己綁定的P的隊列,全局隊列,然后其他P隊列。如果自己P隊列找到就拿出來開始運行,否則去全局隊列看看,由于全局隊列需要鎖保護(hù),如果里面有很多任務(wù),會轉(zhuǎn)移一批到本地P隊列中,避免每次都去競爭鎖。如果全局隊列還是沒有,就要開始玩狠的了,直接從其他P隊列偷任務(wù)了(偷一半任務(wù)回來)。這樣就保證了在還有可運行的G任務(wù)的情況下,總有與CPU核心數(shù)相等的M+P組合 在執(zhí)行G任務(wù)或在執(zhí)行G的路上(尋找G任務(wù))。
b. 如果某個M在執(zhí)行G的過程中被G中的系統(tǒng)調(diào)用阻塞了,怎么辦?
在這種情況下,這個M將會被內(nèi)核調(diào)度器調(diào)度出CPU并處于阻塞狀態(tài),與該M關(guān)聯(lián)的其他G就沒有辦法繼續(xù)執(zhí)行了,但Go運行時系統(tǒng)的一個監(jiān)控線程(sysmon線程)能探測到這樣的M,并把與該M綁定的P剝離,尋找其他空閑或新建M接管該P(yáng),然后繼續(xù)運行其中的G,大致過程如下圖所示。然后等到該M從阻塞狀態(tài)恢復(fù),需要重新找一個空閑P來繼續(xù)執(zhí)行原來的G,如果這時系統(tǒng)正好沒有空閑的P,就把原來的G放到全局隊列當(dāng)中,等待其他M+P組合發(fā)掘并執(zhí)行。
c. 如果某一個G在M運行時間過長,有沒有辦法做搶占式調(diào)度,讓該M上的其他G獲得一定的運行時間,以保證調(diào)度系統(tǒng)的公平性?
我們知道linux的內(nèi)核調(diào)度器主要是基于時間片和優(yōu)先級做調(diào)度的。對于相同優(yōu)先級的線程,內(nèi)核調(diào)度器會盡量保證每個線程都能獲得一定的執(zhí)行時間。為了防止有些線程"餓死"的情況,內(nèi)核調(diào)度器會發(fā)起搶占式調(diào)度將長期運行的線程中斷并讓出CPU資源,讓其他線程獲得執(zhí)行機(jī)會。當(dāng)然在Go的運行時調(diào)度器中也有類似的搶占機(jī)制,但并不能保證搶占能成功,因為Go運行時系統(tǒng)并沒有內(nèi)核調(diào)度器的中斷能力,它只能通過向運行時間過長的G中設(shè)置搶占flag的方法溫柔的讓運行的G自己主動讓出M的執(zhí)行權(quán)。
說到這里就不得不提一下Goroutine在運行過程中可以動態(tài)擴(kuò)展自己線程棧的能力,可以從初始的2KB大小擴(kuò)展到最大1G(64bit系統(tǒng)上),因此在每次調(diào)用函數(shù)之前需要先計算該函數(shù)調(diào)用需要的棧空間大小,然后按需擴(kuò)展(超過最大值將導(dǎo)致運行時異常)。Go搶占式調(diào)度的機(jī)制就是利用在判斷要不要擴(kuò)棧的時候順便查看以下自己的搶占flag,決定是否繼續(xù)執(zhí)行,還是讓出自己。
運行時系統(tǒng)的監(jiān)控線程會計時并設(shè)置搶占flag到運行時間過長的G,然后G在有函數(shù)調(diào)用的時候會檢查該搶占flag,如果已設(shè)置就將自己放入全局隊列,這樣該M上關(guān)聯(lián)的其他G就有機(jī)會執(zhí)行了。但如果正在執(zhí)行的G是個很耗時的操作且沒有任何函數(shù)調(diào)用(如只是for循環(huán)中的計算操作),即使搶占flag已經(jīng)被設(shè)置,該G還是將一直霸占著當(dāng)前M直到執(zhí)行完自己的任務(wù)。
參考資料:
https://studygolang.com/articles/11322?fr=sidebar
https://www.cnblogs.com/williamjie/p/9456764.html
千鋒Go語言的學(xué)習(xí)群:784190273
對應(yīng)視頻:
https://www.bilibili.com/video/av56945376
總結(jié)
- 上一篇: ffmpeg函数调用失败--在编译自己的
- 下一篇: 矩形分割(洛谷P1324题题解,Java