Golang通脈之併發初探

發表於2021-10-28

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

併發與並行

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

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

程式、執行緒、協程

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

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

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

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

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

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

Go語言對於併發的實現是靠協程goroutinegoroutine類似於執行緒,屬於使用者態的執行緒,可以根據需要建立成千上萬個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個重要結構,分別是MGPSched, 前三個定義在runtime.h中,Sched定義在proc.c中。

  • Sched結構就是排程器,它維護有儲存MG的佇列以及排程器的一些狀態資訊等。
  • 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程式碼。

分別用三角形,矩形和圓形表示MachineProcessorGoroutine

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

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

執行緒阻塞

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

runqueue執行完成

當其中一個Processorrunqueue為空,沒有goroutine可以排程。它會從另外一個上下文偷取一半的goroutine

!! 圖中的GPM都是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. 如果某一個GM執行時間過長,有沒有辦法做搶佔式排程,讓該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排程方面的效能。

原始碼分析

相關文章