github上看到的一篇關于golang高并發性的文章,覺得寫的非常好
github 地址 https://github.com/rubyhan1314/Golang-100-Days
一、并發性Concurrency
1.1 多任務
怎么來理解多任務呢?其實就是指我們的作業系統可以同時執行多個任務,舉個例子,你一邊聽音樂,一邊刷微博,一邊聊QQ,一邊用Markdown寫作業,這就是多任務,至少同時有4個任務正在運行,還有很多任務悄悄地在后臺同時運行著,只是界面上沒有顯示而已,
1.2 什么是并發
Go是并發語言,而不是并行語言,在討論如何在Go中進行并發處理之前,我們首先必須了解什么是并發,以及它與并行性有什么不同,(Go is a concurrent language and not a parallel one. )
并發性Concurrency是同時處理許多事情的能力,
舉個例子,假設一個人在晨跑,在晨跑時,他的鞋帶松了,現在這個人停止跑步,系鞋帶,然后又開始跑步,這是一個典型的并發性示例,這個人能夠同時處理跑步和系鞋帶,這是一個人能夠同時處理很多事情,
什么是并行性parallelism,它與并發concurrency有什么不同? 并行就是同時做很多事情,這聽起來可能與并發類似,但實際上是不同的,
讓我們用同樣的慢跑例子更好地理解它,在這種情況下,我們假設這個人正在慢跑,并且使用它的手機聽音樂,在這種情況下,一個人一邊慢跑一邊聽音樂,那就是他同時在做很多事情,這就是所謂的并行性(parallelism),
并發性和并行性——一種技術上的觀點, 假設我們正在撰寫一個web瀏覽器,web瀏覽器有各種組件,其中兩個是web頁面呈現區域和下載檔案從internet下載的下載器,假設我們以這樣的方式構建了瀏覽器的代碼,這樣每個組件都可以獨立地執行,當這個瀏覽器運行在單個核處理器中時,處理器將在瀏覽器的兩個組件之間進行背景關系切換,它可能會下載一個檔案一段時間,然后它可能會切換到呈現用戶請求的網頁的html,這就是所謂的并發性,并發行程從不同的時間點開始,它們的執行周期重疊,在這種情況下,下載和呈現從不同的時間點開始,它們的執行重疊,
假設同一瀏覽器運行在多核處理器上,在這種情況下,檔案下載組件和HTML呈現組件可能同時在不同的內核中運行,這就是所謂的并行性,

并行性Parallelism不會總是導致更快的執行時間,這是因為并行運行的組件可能需要相互通信,例如,在我們的瀏覽器中,當檔案下載完成時,應該將其傳遞給用戶,比如使用彈出視窗,這種通信發生在負責下載的組件和負責呈現用戶界面的組件之間,這種通信開銷在并發concurrent 系統中很低,當組件在多個內核中并行concurrent 運行時,這種通信開銷很高,因此,并行程式并不總是導致更快的執行時間!

1.3 行程、執行緒、協程
行程(Process),執行緒(Thread),協程(Coroutine,也叫輕量級執行緒)
行程
行程是一個程式在一個資料集中的一次動態執行程序,可以簡單理解為“正在執行的程式”,它是CPU資源分配和調度的獨立單位,
行程一般由程式、資料集、行程控制塊三部分組成,我們撰寫的程式用來描述行程要完成哪些功能以及如何完成;資料集則是程式在執行程序中所需要使用的資源;行程控制塊用來記錄行程的外部特征,描述行程的執行變化程序,系統可以利用它來控制和管理行程,它是系統感知行程存在的唯一標志, 行程的局限是創建、撤銷和切換的開銷比較大,
執行緒
執行緒是在行程之后發展出來的概念, 執行緒也叫輕量級行程,它是一個基本的CPU執行單元,也是程式執行程序中的最小單元,由執行緒ID、程式計數器、暫存器集合和堆疊共同組成,一個行程可以包含多個執行緒,
執行緒的優點是減小了程式并發執行時的開銷,提高了作業系統的并發性能,缺點是執行緒沒有自己的系統資源,只擁有在運行時必不可少的資源,但同一行程的各執行緒可以共享行程所擁有的系統資源,如果把行程比作一個車間,那么執行緒就好比是車間里面的工人,不過對于某些獨占性資源存在鎖機制,處理不當可能會產生“死鎖”,
協程
協程是一種用戶態的輕量級執行緒,又稱微執行緒,英文名Coroutine,協程的調度完全由用戶控制,人們通常將協程和子程式(函式)比較著理解,
子程式呼叫總是一個入口,一次回傳,一旦退出即完成了子程式的執行,
與傳統的系統級執行緒和行程相比,協程的最大優勢在于其"輕量級",可以輕松創建上百萬個而不會導致系統資源衰竭,而執行緒和行程通常最多也不能超過1萬的,這也是協程也叫輕量級執行緒的原因,
協程與多執行緒相比,其優勢體現在:協程的執行效率極高,因為子程式切換不是執行緒切換,而是由程式自身控制,因此,沒有執行緒切換的開銷,和多執行緒比,執行緒數量越多,協程的性能優勢就越明顯,
Go語言對于并發的實作是靠協程,Goroutine
二、Go語言的并發模型
Go 語言相比Java等一個很大的優勢就是可以方便地撰寫并發程式,Go 語言內置了 goroutine 機制,使用goroutine可以快速地開發并發程式, 更好的利用多核處理器資源,接下來我們來了解一下Go語言的并發原理,
2.1 執行緒模型
在現代作業系統中,執行緒是處理器調度和分配的基本單位,行程則作為資源擁有的基本單位,每個行程是由私有的虛擬地址空間、代碼、資料和其它各種系統資源組成,執行緒是行程內部的一個執行單元, 每一個行程至少有一個主執行執行緒,它無需由用戶去主動創建,是由系統自動創建的, 用戶根據需要在應用程式中創建其它執行緒,多個執行緒并發地運行于同一個行程中,
我們先從執行緒講起,無論語言層面何種并發模型,到了作業系統層面,一定是以執行緒的形態存在的,而作業系統根據資源訪問權限的不同,體系架構可分為用戶空間和內核空間;內核空間主要操作訪問CPU資源、I/O資源、記憶體資源等硬體資源,為上層應用程式提供最基本的基礎資源,用戶空間呢就是上層應用程式的固定活動空間,用戶空間不可以直接訪問資源,必須通過“系統呼叫”、“庫函式”或“Shell腳本”來呼叫內核空間提供的資源,
我們現在的計算機語言,可以狹義的認為是一種“軟體”,它們中所謂的“執行緒”,往往是用戶態的執行緒,和作業系統本身內核態的執行緒(簡稱KSE),還是有區別的,
Go并發編程模型在底層是由作業系統所提供的執行緒庫支撐的,因此還是得從執行緒實作模型說起,
執行緒可以視為行程中的控制流,一個行程至少會包含一個執行緒,因為其中至少會有一個控制流持續運行,因而,一個行程的第一個執行緒會隨著這個行程的啟動而創建,這個執行緒稱為該行程的主執行緒,當然,一個行程也可以包含多個執行緒,這些執行緒都是由當前行程中已存在的執行緒創建出來的,創建的方法就是呼叫系統呼叫,更確切地說是呼叫
pthread create函式,擁有多個執行緒的行程可以并發執行多個任務,并且即使某個或某些任務被阻塞,也不會影響其他任務正常執行,這可以大大改善程式的回應時間和吞吐量,另一方面,執行緒不可能獨立于行程存在,它的生命周期不可能逾越其所屬行程的生命周期,
執行緒的實作模型主要有3個,分別是:用戶級執行緒模型、內核級執行緒模型和兩級執行緒模型,它們之間最大的差異就在于執行緒與內核調度物體( Kernel Scheduling Entity,簡稱KSE)之間的對應關系上,顧名思義,內核調度物體就是可以被內核的調度器調度的物件,在很多文獻和書中,它也稱為內核級執行緒,是作業系統內核的最小調度單元,
2.1.1 內核級執行緒模型
用戶執行緒與KSE是1對1關系(1:1),大部分編程語言的執行緒庫(如linux的pthread,Java的java.lang.Thread,C++11的std::thread等等)都是對作業系統的執行緒(內核級執行緒)的一層封裝,創建出來的每個執行緒與一個不同的KSE靜態關聯,因此其調度完全由OS調度器來做,這種方式實作簡單,直接借助OS提供的執行緒能力,并且不同用戶執行緒之間一般也不會相互影響,但其創建,銷毀以及多個執行緒之間的背景關系切換等操作都是直接由OS層面親自來做,在需要使用大量執行緒的場景下對OS的性能影響會很大,

每個執行緒由內核調度器獨立的調度,所以如果一個執行緒阻塞則不影響其他的執行緒,
優點:在多核處理器的硬體的支持下,內核空間執行緒模型支持了真正的并行,當一個執行緒被阻塞后,允許另一個執行緒繼續執行,所以并發能力較強,
缺點:每創建一個用戶級執行緒都需要創建一個內核級執行緒與其對應,這樣創建執行緒的開銷比較大,會影響到應用程式的性能,
2.1.2 用戶級執行緒模型
用戶執行緒與KSE是多對1關系(M:1),這種執行緒的創建,銷毀以及多個執行緒之間的協調等操作都是由用戶自己實作的執行緒庫來負責,對OS內核透明,一個行程中所有創建的執行緒都與同一個KSE在運行時動態關聯,現在有許多語言實作的 協程 基本上都屬于這種方式,這種實作方式相比內核級執行緒可以做的很輕量級,對系統資源的消耗會小很多,因此可以創建的數量與背景關系切換所花費的代價也會小得多,但該模型有個致命的缺點,如果我們在某個用戶執行緒上呼叫阻塞式系統呼叫(如用阻塞方式read網路IO),那么一旦KSE因阻塞被內核調度出CPU的話,剩下的所有對應的用戶執行緒全都會變為阻塞狀態(整個行程掛起),
所以這些語言的協程庫會把自己一些阻塞的操作重新封裝為完全的非阻塞形式,然后在以前要阻塞的點上,主動讓出自己,并通過某種方式通知或喚醒其他待執行的用戶執行緒在該KSE上運行,從而避免了內核調度器由于KSE阻塞而做背景關系切換,這樣整個行程也不會被阻塞了,

優點: 這種模型的好處是執行緒背景關系切換都發生在用戶空間,避免的模態切換(mode switch),從而對于性能有積極的影響,
缺點:所有的執行緒基于一個內核調度物體即內核執行緒,這意味著只有一個處理器可以被利用,在多處理器環境下這是不能夠被接受的,本質上,用戶執行緒只解決了并發問題,但是沒有解決并行問題,如果執行緒因為 I/O 操作陷入了內核態,內核態執行緒阻塞等待 I/O 資料,則所有的執行緒都將會被阻塞,用戶空間也可以使用非阻塞而 I/O,但是不能避免性能及復雜度問題,
2.1.3 兩級執行緒模型
用戶執行緒與KSE是多對多關系(M:N),這種實作綜合了前兩種模型的優點,為一個行程中創建多個KSE,并且執行緒可以與不同的KSE在運行時進行動態關聯,當某個KSE由于其上作業的執行緒的阻塞操作被內核調度出CPU時,當前與其關聯的其余用戶執行緒可以重新與其他KSE建立關聯關系,當然這種動態關聯機制的實作很復雜,也需要用戶自己去實作,這算是它的一個缺點吧,Go語言中的并發就是使用的這種實作方式,Go為了實作該模型自己實作了一個運行時調度器來負責Go中的"執行緒"與KSE的動態關聯,此模型有時也被稱為 混合型執行緒模型,即用戶調度器實作用戶執行緒到KSE的“調度”,內核調度器實作KSE到CPU上的調度,

2.2 Go并發調度: G-P-M模型
在作業系統提供的內核執行緒之上,Go搭建了一個特有的兩級執行緒模型,goroutine機制實作了M : N的執行緒模型,goroutine機制是協程(coroutine)的一種實作,golang內置的調度器,可以讓多核CPU中每個CPU執行一個協程,
2.2.1 調度器是如何作業的
有了上面的認識,我們可以開始真正的介紹Go的并發機制了,先用一段代碼展示一下在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系統執行緒維護一個Processor,任何時刻,一個Processor中只有一個goroutine,其他goroutine在runqueue中等待,一個goroutine運行完自己的時間片后,讓出背景關系,回到runqueue中, 多核處理器的場景下,為了運行goroutines,每個M系統執行緒會持有一個Processor,

在正常情況下,scheduler會按照上面的流程進行調度,但是執行緒會發生阻塞等情況,看一下goroutine對執行緒阻塞等的處理,
2.2.2 執行緒阻塞
當正在運行的goroutine阻塞的時候,例如進行系統呼叫,會再創建一個系統執行緒(M1),當前的M執行緒放棄了它的Processor,P轉到新的執行緒中去運行,

2.2.3 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的執行,這是最基本的結構,很好理解, 關鍵問題是:
a.如何在一個多核心系統上盡量合理分配G到多個M上運行,充分利用多核,提高并發能力呢?
如果我們在一個Goroutine中通過go關鍵字創建了大量G,這些G雖然暫時會被放在同一個佇列, 但如果這時還有空閑P(系統內P的數量默認等于系統cpu核心數),Go運行時系統始終能保證至少有一個(通常也只有一個)活躍的M與空閑P系結去各種G佇列去尋找可運行的G任務,該種M稱為自旋的M,一般尋找順序為:自己系結的P的佇列,全域佇列,然后其他P佇列,如果自己P佇列找到就拿出來開始運行,否則去全域佇列看看,由于全域佇列需要鎖保護,如果里面有很多任務,會轉移一批到本地P佇列中,避免每次都去競爭鎖,如果全域佇列還是沒有,就要開始玩狠的了,直接從其他P佇列偷任務了(偷一半任務回來),這樣就保證了在還有可運行的G任務的情況下,總有與CPU核心數相等的M+P組合 在執行G任務或在執行G的路上(尋找G任務),
b. 如果某個M在執行G的程序中被G中的系統呼叫阻塞了,怎么辦?
在這種情況下,這個M將會被內核調度器調度出CPU并處于阻塞狀態,與該M關聯的其他G就沒有辦法繼續執行了,但Go運行時系統的一個監控執行緒(sysmon執行緒)能探測到這樣的M,并把與該M系結的P剝離,尋找其他空閑或新建M接管該P,然后繼續運行其中的G,大致程序如下圖所示,然后等到該M從阻塞狀態恢復,需要重新找一個空閑P來繼續執行原來的G,如果這時系統正好沒有空閑的P,就把原來的G放到全域佇列當中,等待其他M+P組合發掘并執行,
c. 如果某一個G在M運行時間過長,有沒有辦法做搶占式調度,讓該M上的其他G獲得一定的運行時間,以保證調度系統的公平性?
我們知道linux的內核調度器主要是基于時間片和優先級做調度的,對于相同優先級的執行緒,內核調度器會盡量保證每個執行緒都能獲得一定的執行時間,為了防止有些執行緒"餓死"的情況,內核調度器會發起搶占式調度將長期運行的執行緒中斷并讓出CPU資源,讓其他執行緒獲得執行機會,當然在Go的運行時調度器中也有類似的搶占機制,但并不能保證搶占能成功,因為Go運行時系統并沒有內核調度器的中斷能力,它只能通過向運行時間過長的G中設定搶占flag的方法溫柔的讓運行的G自己主動讓出M的執行權,
說到這里就不得不提一下Goroutine在運行程序中可以動態擴展自己執行緒堆疊的能力,可以從初始的2KB大小擴展到最大1G(64bit系統上),因此在每次呼叫函式之前需要先計算該函式呼叫需要的堆疊空間大小,然后按需擴展(超過最大值將導致運行時例外),Go搶占式調度的機制就是利用在判斷要不要擴堆疊的時候順便查看以下自己的搶占flag,決定是否繼續執行,還是讓出自己,
運行時系統的監控執行緒會計時并設定搶占flag到運行時間過長的G,然后G在有函式呼叫的時候會檢查該搶占flag,如果已設定就將自己放入全域佇列,這樣該M上關聯的其他G就有機會執行了,但如果正在執行的G是個很耗時的操作且沒有任何函式呼叫(如只是for回圈中的計算操作),即使搶占flag已經被設定,該G還是將一直霸占著當前M直到執行完自己的任務,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/33523.html
標籤:Go
