Golang通脈之並發初探

並發是編程裡面一個非常重要的概念,Go語言在語言層面天生支援並發。

並發與並行

並發:同一時間段內執行多個任務。

並行:同一時刻執行多個任務,有時間上的重疊。

進程、執行緒、協程

進程(Process),執行緒(Thread),協程(Coroutine,也叫輕量級執行緒)

進程:是一個程式在一個數據集中的一次動態執行過程,可以簡單理解為「正在執行的程式」,它是CPU資源分配和調度的獨立單位。 進程一般由程式、數據集、進程式控制制塊三部分組成。我們編寫的程式用來描述進程要完成哪些功能以及如何完成;數據集則是程式在執行過程中所需要使用的資源;進程式控制制塊用來記錄進程的外部特徵,描述進程的執行變化過程,系統可以利用它來控制和管理進程,它是系統感知進程存在的唯一標誌。 進程的局限是創建、撤銷和切換的開銷比較大。

執行緒:是在進程之後發展出來的概念。 執行緒也叫輕量級進程,它是一個基本的CPU執行單元,也是程式執行過程中的最小單元,由執行緒ID、程式計數器、暫存器集合和堆棧共同組成。一個進程可以包含多個執行緒。 執行緒的優點是減小了程式並發執行時的開銷,提高了作業系統的並發性能,缺點是執行緒沒有自己的系統資源,只擁有在運行時必不可少的資源,但同一進程的各執行緒可以共享進程所擁有的系統資源,如果把進程比作一個車間,那麼執行緒就好比是車間裡面的工人。不過對於某些獨佔性資源存在鎖機制,處理不當可能會產生「死鎖」。

協程:是一種用戶態的輕量級執行緒,又稱微執行緒,英文名Coroutine,協程的調度完全由用戶控制。通常將協程和子程式(函數)比較著理解。 子程式調用總是一個入口,一次返回,一旦退出即完成了子程式的執行。

與傳統的系統級執行緒和進程相比,協程的最大優勢在於其"輕量級",可以輕鬆創建上百萬個而不會導致系統資源衰竭,而執行緒和進程通常最多也不能超過1萬的。這也是協程也叫輕量級執行緒的原因。

!! 協程與多執行緒相比,其優勢體現在:協程的執行效率極高。因為子程式切換不是執行緒切換,而是由程式自身控制,因此,沒有執行緒切換的開銷,和多執行緒比,執行緒數量越多,協程的性能優勢就越明顯。

Go語言對於並發的實現是靠協程goroutine。goroutine類似於執行緒,屬於用戶態的執行緒,可以根據需要創建成千上萬個goroutine並發工作。goroutine是由Go語言的運行時(runtime)調度完成,而執行緒是由作業系統調度完成。

執行緒模型

現代作業系統中,執行緒是處理器調度和分配的基本單位,進程則作為資源擁有的基本單位。每個進程是由私有的虛擬地址空間、程式碼、數據和其它各種系統資源組成。執行緒是進程內部的一個執行單元。 每一個進程至少有一個主執行執行緒,它無需由用戶去主動創建,是由系統自動創建的。 用戶根據需要在應用程式中創建其它執行緒,多個執行緒並發地運行於同一個進程中。

先從執行緒講起,無論語言層面何種並發模型,到了作業系統層面,一定是以執行緒的形態存在的。而作業系統根據資源訪問許可權的不同,體系架構可分為用戶空間和內核空間;內核空間主要操作訪問CPU資源、I/O資源、記憶體資源等硬體資源,為上層應用程式提供最基本的基礎資源,用戶空間呢就是上層應用程式的固定活動空間,用戶空間不可以直接訪問資源,必須通過「系統調用」、「庫函數」或「Shell腳本」來調用內核空間提供的資源。

現在的電腦語言,可以狹義的認為是一種「軟體」,它們中所謂的「執行緒」,往往是用戶態的執行緒,和作業系統本身內核態的執行緒(簡稱KSE),還是有區別的。

Go並發編程模型在底層是由作業系統所提供的執行緒庫支撐的,因此還是得從執行緒實現模型說起。

執行緒可以視為進程中的控制流。一個進程至少會包含一個執行緒,因為其中至少會有一個控制流持續運行。因而,一個進程的第一個執行緒會隨著這個進程的啟動而創建,這個執行緒稱為該進程的主執行緒。當然,一個進程也可以包含多個執行緒。這些執行緒都是由當前進程中已存在的執行緒創建出來的,創建的方法就是調用系統調用,更確切地說是調用 pthread create函數。擁有多個執行緒的進程可以並發執行多個任務,並且即使某個或某些任務被阻塞,也不會影響其他任務正常執行,這可以大大改善程式的響應時間和吞吐量。另一方面,執行緒不可能獨立於進程存在。它的生命周期不可能逾越其所屬進程的生命周期。

執行緒的實現模型主要有3個,分別是:用戶級執行緒模型、內核級執行緒模型和兩級執行緒模型。它們之間最大的差異就在於執行緒與內核調度實體( Kernel Scheduling Entity,簡稱KSE)之間的對應關係上。顧名思義,內核調度實體就是可以被內核的調度器調度的對象。在很多文獻和書中,它也稱為內核級執行緒,是作業系統內核的最小調度單元。

內核級執行緒模型

用戶執行緒與KSE是1對1關係(1:1)。大部分程式語言的執行緒庫(如linux的pthread,Java的java.lang.Thread,C++11的std::thread等等)都是對作業系統的執行緒(內核級執行緒)的一層封裝,創建出來的每個執行緒與一個不同的KSE靜態關聯,因此其調度完全由OS調度器來做。這種方式實現簡單,直接藉助OS提供的執行緒能力,並且不同用戶執行緒之間一般也不會相互影響。但其創建,銷毀以及多個執行緒之間的上下文切換等操作都是直接由OS層面親自來做,在需要使用大量執行緒的場景下對OS的性能影響會很大。

每個執行緒由內核調度器獨立的調度,所以如果一個執行緒阻塞則不影響其他的執行緒。

優點:在多核處理器的硬體的支援下,內核空間執行緒模型支援了真正的並行,當一個執行緒被阻塞後,允許另一個執行緒繼續執行,所以並發能力較強。

缺點:每創建一個用戶級執行緒都需要創建一個內核級執行緒與其對應,這樣創建執行緒的開銷比較大,會影響到應用程式的性能。

用戶級執行緒模型

用戶執行緒與KSE是多對1關係(M:1),這種執行緒的創建,銷毀以及多個執行緒之間的協調等操作都是由用戶自己實現的執行緒庫來負責,對OS內核透明,一個進程中所有創建的執行緒都與同一個KSE在運行時動態關聯。現在有許多語言實現的 協程 基本上都屬於這種方式。這種實現方式相比內核級執行緒可以做的很輕量級,對系統資源的消耗會小很多,因此可以創建的數量與上下文切換所花費的代價也會小得多。但該模型有個致命的缺點,如果我們在某個用戶執行緒上調用阻塞式系統調用(如用阻塞方式read網路IO),那麼一旦KSE因阻塞被內核調度出CPU的話,剩下的所有對應的用戶執行緒全都會變為阻塞狀態(整個進程掛起)。 所以這些語言的協程庫會把自己一些阻塞的操作重新封裝為完全的非阻塞形式,然後在以前要阻塞的點上,主動讓出自己,並通過某種方式通知或喚醒其他待執行的用戶執行緒在該KSE上運行,從而避免了內核調度器由於KSE阻塞而做上下文切換,這樣整個進程也不會被阻塞了。

優點: 這種模型的好處是執行緒上下文切換都發生在用戶空間,避免的模態切換(mode switch),從而對於性能有積極的影響。

缺點:所有的執行緒基於一個內核調度實體即內核執行緒,這意味著只有一個處理器可以被利用,在多處理器環境下這是不能夠被接受的,本質上,用戶執行緒只解決了並發問題,但是沒有解決並行問題。如果執行緒因為 I/O 操作陷入了內核態,內核態執行緒阻塞等待 I/O 數據,則所有的執行緒都將會被阻塞,用戶空間也可以使用非阻塞而 I/O,但是不能避免性能及複雜度問題。

兩級執行緒模型

用戶執行緒與KSE是多對多關係(M:N),這種實現綜合了前兩種模型的優點,為一個進程中創建多個KSE,並且執行緒可以與不同的KSE在運行時進行動態關聯,當某個KSE由於其上工作的執行緒的阻塞操作被內核調度出CPU時,當前與其關聯的其餘用戶執行緒可以重新與其他KSE建立關聯關係。當然這種動態關聯機制的實現很複雜,也需要用戶自己去實現,這算是它的一個缺點吧。Go語言中的並發就是使用的這種實現方式,Go為了實現該模型自己實現了一個運行時調度器來負責Go中的"執行緒"與KSE的動態關聯。此模型有時也被稱為 混合型執行緒模型,即用戶調度器實現用戶執行緒到KSE的「調度」,內核調度器實現KSE到CPU上的調度。

Go 並發調度:GMP模型

在作業系統提供的內核執行緒之上,Go搭建了一個特有的兩級執行緒模型。goroutine機制實現了M : N的執行緒模型,goroutine機制是協程(coroutine)的一種實現,golang內置的調度器,可以讓多核CPU中每個CPU執行一個協程。

調度器是如何工作的

在Go語言中新建一個「執行緒」(Go語言中稱為Goroutine):

// 用go關鍵字加上一個函數(這裡用了匿名函數)
// 調用就做到了在一個新的「執行緒」並發執行任務
go func() { 
    // do something in one new goroutine
}()

功能上等價於Java8的程式碼:

new java.lang.Thread(() -> { 
    // do something in one new thread
}).start();

理解goroutine機制的原理,關鍵是理解Go語言scheduler的實現。

Go語言中支撐整個scheduler實現的主要有4個重要結構,分別是M、G、P、Sched, 前三個定義在runtime.h中,Sched定義在proc.c中。

  • Sched結構就是調度器,它維護有存儲M和G的隊列以及調度器的一些狀態資訊等。
  • M結構是Machine,系統執行緒,它由作業系統管理的,goroutine就是跑在M之上的;M是一個很大的結構,裡面維護小對象記憶體cache(mcache)、當前執行的goroutine、隨機數發生器等等非常多的資訊。
  • P結構是Processor,處理器,它的主要用途就是用來執行goroutine的,它維護了一個goroutine隊列,即runqueue。Processor是讓我們從N:1調度到M:N調度的重要部分。
  • G是goroutine實現的核心結構,它包含了棧,指令指針,以及其他對調度goroutine很重要的資訊,例如其阻塞的channel。

!! Processor的數量是在啟動時被設置為環境變數GOMAXPROCS的值,或者通過運行時調用函數GOMAXPROCS()進行設置。Processor數量固定意味著任意時刻只有GOMAXPROCS個執行緒在運行go程式碼。

分別用三角形,矩形和圓形表示Machine、Processor和Goroutine。

在單核處理器的場景下,所有goroutine運行在同一個M系統執行緒中,每一個M系統執行緒維護一個P,任何時刻,一個P中只有一個G,其他G在runqueue中等待。一個G運行完自己的時間片後,讓出上下文,回到runqueue中。 多核處理器的場景下,為了運行goroutines,每個M系統執行緒會持有一個P。

在正常情況下,scheduler會按照上面的流程進行調度,但是執行緒會發生阻塞等情況,看一下goroutine對執行緒阻塞等的處理。

執行緒阻塞

當正在運行的goroutine阻塞的時候,例如進行系統調用,會再創建一個系統執行緒(M1),當前的M執行緒放棄了它的P,P轉到新的執行緒中去運行。

runqueue執行完成

當其中一個Processor的runqueue為空,沒有goroutine可以調度。它會從另外一個上下文偷取一半的goroutine。

!! 圖中的G,P和M都是Go語言運行時系統(其中包括記憶體分配器,並發調度器,垃圾收集器等組件,可以想像為Java中的JVM)抽象出來的概念和數據結構對象: G:Goroutine的簡稱,用go關鍵字加函數調用的程式碼就是創建了一個G對象,是對一個要並發執行的任務的封裝,也可以稱作用戶態執行緒。屬於用戶級資源,對OS透明,具備輕量級,可以大量創建,上下文切換成本低等特點。 M:Machine的簡稱,在linux平台上是用clone系統調用創建的,其與用linux pthread庫創建出來的執行緒本質上是一樣的,都是利用系統調用創建出來的OS執行緒實體。M的作用就是執行G中包裝的並發任務。Go運行時系統中的調度器的主要職責就是將G公平合理的安排到多個M上去執行。其屬於OS資源,可創建的數量上也受限了OS,通常情況下G的數量都多於活躍的M的。 P:Processor的簡稱,邏輯處理器,主要作用是管理G對象(每個P都有一個G隊列),並為G在M上的運行提供本地化資源。

從兩級執行緒模型來看,似乎並不需要P的參與,有G和M就可以了,那為什麼要加入P呢? 其實Go語言運行時系統早期(Go1.0)的實現中並沒有P的概念,Go中的調度器直接將G分配到合適的M上運行。但這樣帶來了很多問題,例如,不同的G在不同的M上並發運行時可能都需向系統申請資源(如堆記憶體),由於資源是全局的,將會由於資源競爭造成很多系統性能損耗,為了解決類似的問題,後面的Go(Go1.1)運行時系統加入了P,讓P去管理G對象,M要想運行G必須先與一個P綁定,然後才能運行該P管理的G。這樣帶來的好處是,可以在P對象中預先申請一些系統資源(本地資源),G需要的時候先向自己的本地P申請(無需鎖保護),如果不夠用或沒有再向全局申請,而且從全局拿的時候會多拿一部分,以供後面高效的使用。就像現在我們去政府辦事情一樣,先去本地政府看能否搞定,如果搞不定再去中央,從而提供辦事效率。 而且由於P解耦了G和M對象,這樣即使M由於被其上正在運行的G阻塞住,其餘與該M關聯的G也可以隨著P一起遷移到別的活躍的M上繼續運行,從而讓G總能及時找到M並運行自己,從而提高系統的並發能力。 Go運行時系統通過構造G-P-M對象模型實現了一套用戶態的並發調度系統,可以自己管理和調度自己的並發任務,所以可以說Go語言原生支援並發。自己實現的調度器負責將並發任務分配到不同的內核執行緒上運行,然後內核調度器接管內核執行緒在CPU上的執行與調度。

可以看到Go的並發用起來非常簡單,用了一個語法糖將內部複雜的實現結結實實的包裝了起來。其內部可以用下面這張圖來概述:

寫在最後,Go運行時完整的調度系統是很複雜,很難用一篇文章描述的清楚,這裡只能從宏觀上介紹一下,先有個整體的認識。

// Goroutine1
func task1() {
    go task2()
    go task3()
}

假如有一個G(Goroutine1)已經通過P被安排到了一個M上正在執行,在Goroutine1執行的過程中又創建兩個G,這兩個G會被馬上放入與Goroutine1相同的P的本地G任務隊列中,排隊等待與該P綁定的M的執行,這是最基本的結構,很好理解。 關鍵問題是:

1.如何在一個多核心系統上盡量合理分配G到多個M上運行,充分利用多核,提高並發能力呢?

如果在一個Goroutine中通過go關鍵字創建了大量G,這些G雖然暫時會被放在同一個隊列, 但如果這時還有空閑P(系統內P的數量默認等於系統cpu核心數GOMAXPROCS),Go運行時系統始終能保證至少有一個(通常也只有一個)活躍的M與空閑P綁定去各種G隊列去尋找可運行的G任務,該種M稱為自旋的M。一般尋找順序為:自己綁定的P的隊列 --> 全局隊列 --> 其他P隊列。如果自己P隊列找到就拿出來開始運行,否則去全局隊列看看,由於全局隊列需要鎖保護,如果裡面有很多任務,會轉移一批到本地P隊列中,避免每次都去競爭鎖。如果全局隊列還是沒有,直接從其他P隊列偷任務了(偷一半任務回來)。這樣就保證了在還有可運行的G任務的情況下,總有與CPU核心數相等的M + P組合 在執行G任務或在執行G的路上(尋找G任務)。

2. 如果某個M在執行G的過程中被G中的系統調用阻塞了,怎麼辦?

在這種情況下,這個M將會被內核調度器調度出CPU並處於阻塞狀態,與該M關聯的其他G就沒有辦法繼續執行了,但Go運行時系統的一個監控執行緒(sysmon執行緒)能探測到這樣的M,並把與該M綁定的P剝離,尋找其他空閑或新建M接管該P,然後繼續運行其中的G,然後等到該M從阻塞狀態恢復,需要重新找一個空閑P來繼續執行原來的G,如果這時系統正好沒有空閑的P,就把原來的G放到全局隊列當中,等待其他M+P組合發掘並執行。

3. 如果某一個G在M運行時間過長,有沒有辦法做搶佔式調度,讓該M上的其他G獲得一定的運行時間,以保證調度系統的公平性?

linux的內核調度器主要是基於時間片和優先順序做調度的。對於相同優先順序的執行緒,內核調度器會盡量保證每個執行緒都能獲得一定的執行時間。為了防止有些執行緒"餓死"的情況,內核調度器會發起搶佔式調度將長期運行的執行緒中斷並讓出CPU資源,讓其他執行緒獲得執行機會。當然在Go的運行時調度器中也有類似的搶佔機制,但並不能保證搶佔能成功,因為Go運行時系統並沒有內核調度器的中斷能力,它只能通過向運行時間過長的G中設置搶佔flag的方法優雅的讓運行的G自己主動讓出M的執行權。 說到這裡就不得不提一下Goroutine在運行過程中可以動態擴展自己執行緒棧的能力【可增長的棧:OS執行緒一般都有固定的棧記憶體(通常為2MB),一個goroutine的棧在其生命周期開始時只有很小的棧(典型情況下2KB),goroutine的棧不是固定的,他可以按需增大和縮小,goroutine的棧大小限制可以達到1GB,雖然極少會用到這麼大。所以在Go語言中一次創建十萬左右的goroutine也是可以的】,可以從初始的2KB大小擴展到最大1G(64bit系統上),因此在每次調用函數之前需要先計算該函數調用需要的棧空間大小,然後按需擴展(超過最大值將導致運行時異常)。Go搶佔式調度的機制就是利用在判斷要不要擴棧的時候順便查看以下自己的搶佔flag,決定是否繼續執行,還是讓出自己。 運行時系統的監控執行緒會計時並設置搶佔flag到運行時間過長的G,然後G在有函數調用的時候會檢查該搶佔flag,如果已設置就將自己放入全局隊列,這樣該M上關聯的其他G就有機會執行了。但如果正在執行的G是個很耗時的操作且沒有任何函數調用(如只是for循環中的計算操作),即使搶佔flag已經被設置,該G還是將一直霸佔著當前M直到執行完自己的任務。

簡單的總結一下GMP模型:

GPM是Go語言運行時(runtime)層面的實現,是go語言自己實現的一套調度系統。區別於作業系統調度OS執行緒。

  • G很好理解,就是個goroutine的,裡面除了存放本goroutine資訊外 還有與所在P的綁定等資訊。
  • P管理著一組goroutine隊列,P裡面會存儲當前goroutine運行的上下文環境(函數指針,堆棧地址及地址邊界),P會對自己管理的goroutine隊列做一些調度(比如把佔用CPU時間較長的goroutine暫停、運行後續的goroutine等等)當自己的隊列消費完了就去全局隊列里取,如果全局隊列里也消費完了會去其他P的隊列里搶任務。
  • M(machine)是Go運行時(runtime)對作業系統內核執行緒的虛擬, M與內核執行緒一般是一一映射的關係, 一個groutine最終是要放到M上執行的;

P與M一般是一一對應的。他們關係是: P管理著一組G掛載在M上運行。當一個G長久阻塞在一個M上時,runtime會新建一個M,阻塞G所在的P會把其他的G 掛載在新建的M上。當舊的G阻塞完成或者認為其已經死掉時 回收舊的M。

P的個數是通過runtime.GOMAXPROCS設定(最大256),Go1.5版本之後默認為物理執行緒數。 在並發量大的時候會增加一些P和M,但不會太多,切換太頻繁的話得不償失。

單從執行緒調度講,Go語言相比起其他語言的優勢在於OS執行緒是由OS內核來調度的,goroutine則是由Go運行時(runtime)自己的調度器調度的,這個調度器使用一個稱為m:n調度的技術(復用/調度m個goroutine到n個OS執行緒)。 其一大特點是goroutine的調度是在用戶態下完成的, 不涉及內核態與用戶態之間的頻繁切換,包括記憶體的分配與釋放,都是在用戶態維護著一塊大的記憶體池, 不直接調用系統的malloc函數(除非記憶體池需要改變),成本比調度OS執行緒低很多。 另一方面充分利用了多核的硬體資源,近似的把若干goroutine均分在物理執行緒上, 再加上本身goroutine的超輕量,以上種種保證了go調度方面的性能。

源碼分析

Tags: