天天看點

Go 并行計算的核心-Goroutine

‘ ’

這一篇主要分享的是 Go 中比較核心的概念:協程(Coroutine),在 Go 中被改寫之後稱之為:Goroutine,它是并發模型的基本執行單元。事實上每一個Go程式至少有一個Goroutine:主Goroutine。當程式啟動時,它會自動建立。

為了更好了解 Goroutine,先看一下線程的概念。

線程(Thread):有時被稱為輕量級程序(Lightweight Process,LWP),是程式執行流的最小單元。一個标準的線程由線程 ID,目前指令指針(PC),寄存器集合和堆棧組成。另外,線程是程序中的一個實體,是被系統獨立排程和分派的基本機關,線程自己不擁有系統資源,隻擁有一點兒在運作中必不可少的資源,但它可與同屬一個程序的其它線程共享程序所擁有的全部資源。

線程擁有自己獨立的棧和共享的堆,線程的切換一般也由作業系統排程。

Go 并行計算的核心-Goroutine

上圖我們能看到,微信這個程序下啟動了不同的線程在獨立執行不同的任務。

線程不能獨立存在,它必須依附于某個程序。那麼這個程序中的各個線程是如何被排程的呢,排程又是由誰負責?

首先作業系統并行執行的能力有限,多數時候是并發執行任務。大部分作業系統并發執行任務采用的方式是時間片輪轉排程。這種排程會由作業系統核心來進行處理。通過硬體的計數器中斷處理器,讓該線程強制暫停并将該線程的寄存器放入記憶體中,通過檢視線程清單決定接下來執行哪一個線程,并從記憶體中恢複該線程的寄存器,最後恢複該線程的執行,進而去執行下一個任務。

是以對于線程來說,這裡多出來一個上下文切換的過程。排程器要實時去切換時間片以確定目前所有的線程都能照顧到,别飽了大兒子餓了小兒子。

這裡衍生出一個很重要的概念:搶占式 任務系統,時間片輪換必然涉及到一個任務可能正執行一半,另一個任務的時間片到了,它此刻不得不懸挂起來。

再說說使用者态和核心的的問題。所有使用者程式都是運作在使用者态的,但是有時候程式确實需要做一些核心态的事情, 例如從硬碟讀取資料 或者從鍵盤擷取輸入等。而唯一可以做這些事情的就是作業系統, 是以此時程式就需要先作業系統請求以程式的名義來執行這些操作這時需要一個這樣的機制:使用者态程式切換到核心态, 但是不能控制在核心态中執行的指令,這種機制叫系統調用, 在CPU中的實作稱之為陷阱指令(Trap Instruction)。

雖然線程比較輕量,但是在排程時也有比較大的額外開銷。每個線程會都占用 1M 以上的記憶體空間,在切換線程時不止會消耗較多的記憶體,恢複寄存器中的内容還需要向作業系統申請或者銷毀資源,每一次線程上下文的切換都需要消耗 ~1us 左右的時間,但是 Go 排程器對 Goroutine 的上下文切換約為 ~0.2us,減少了 80% 的額外開銷。

在進階語言中的 “函數調用” 的概念,在彙編裡主要展現為兩個寄存器。寄存器是 CPU 内部臨時儲存資料的區域,相當于進階語言裡的變量。但是有一個寄存器是特殊的,它存放了 CPU 目前正在執行的指令的記憶體位址(Instruction Register)。一個進階語言中的函數一般會被編譯成指令存放在一段連續的記憶體空間中(data segment)。那麼所謂函數執行到了第幾行這樣的資訊其實就是儲存在這個 Instruction Register 中的。另外一個很特殊的寄存器是 Stack Register,它存放的記憶體位址指向的記憶體區域用于函數之間傳遞參數和傳回值,以及存放一個函數内的局部變量。如果不考慮現代計算機 CPU 中各種各樣其他存放中間結果的寄存器,理論上儲存了 Instruction Register(執行到哪兒了)和 Stack Register(堆棧上的變量)就儲存了一個函數的目前執行狀态,分别是函數目前執行到了哪,以及這個函數局部變量所代表的目前 state。

以上構成了協程應用的基石,協程就是根據函數的目前執行狀态來切換異步執行狀态。

協程:協作式任務系統。協作式多任務不需要搶占式多任務的時間片配置設定,時鐘滴答,搶占式上下文切換等一系列開銷,根據實作方式的不同,任務切換時甚至可能不需要儲存現場。另外協作式多任務中的每項任務都不需要擔心被搶占,因而具有更低的通信和同步開銷。

我們可以了解協程就是:

​ 協程 = 線程 - 搶占

線程由于共享程序空間,是以就大大減少了程序切換的開銷,但依然是一個系統調用,需要從使用者空間切換到系統空間然後再切換回使用者空間,同時有可能導緻 CPU 将時間片切給其它程序。

而協程則是由程式設計語言所支援的在使用者空間内的多任務,協程存在于使用者态,大量的協程實際上隻占用了核心态的一個線程。簡單的說就是同一個線程中,系統維護一個程式清單,某個程式阻塞了如果有其它程式等待執行,則不切線程而直接恢複那個程式的執行上下文。

而協作式多任務的弊端,上文中也提到,是任務必須知道協作的存在,并主動配合協作,而搶占式多任務對任務是透明的。

雖然搶占式多任務的開銷更大,但作業系統面對的是一系列未知的應用程式,作業系統不能要求應用程式配合協作,是以在作業系統級别,協作式多任務是不現實的。

但是在應用程式級别,所有的任務都是開發者預先規劃好的,是以協作式多任務是可能的。而相比搶占式多任務的透明性,協作式多任務反而提供了更精細和可控的排程。

Go 實作了兩種并發形式。第一種是大家普遍認知的:多線程共享記憶體。其實就是 Java 或者 C++ 等語言中的多線程開發。另外一種是 Go 語言特有的,也是 Go 語言推薦的:CSP(communicating sequential processes)并發模型。

CSP 并發模型是在 1970 年左右提出的概念,屬于比較新的概念,不同于傳統的多線程通過共享記憶體來通信,CSP 講究的是 “以通信的方式來共享記憶體”。

請記住下面這句話: DO NOT COMMUNICATE BY SHARING MEMORY; INSTEAD, SHARE MEMORY BY COMMUNICATING. “不要以共享記憶體的方式來通信,相反,要通過通信來共享記憶體。”

普通的線程并發模型,就是像 Java、C++、或者 Python,它們的線程間通信都是通過共享記憶體的方式來進行的。非常典型的方式就是,在通路共享資料(例如數組、Map、或者某個結構體或對象)的時候,通過鎖來通路,是以在很多時候衍生出一種友善操作的資料結構,叫做 “線程安全的資料結構”。例如 Java 提供的 java.util.concurrent 包中的資料結構,Go 中也實作了傳統的線程并發模型。

Go 其實隻用到了 CSP 的很小一部分,即理論中的 Process/Channel(對應到 Go 中的 goroutine/channel):這兩個并發原語之間互相獨立,Process 通過對 Channel 進行讀寫,形成一套有序阻塞和可預測的并發模型。

在 Go 中任何代碼都是運作在 goroutine 裡,即便沒有顯式的 <code>go func()</code> ,預設的 main 函數也是一個 goroutine。Goroutine 開銷很小,它有自己的棧,不同于核心線程固定大小的記憶體塊(2MB),goroutine 的棧初始值是 2KB,可以動态增長到 1G(64位1G,32位256M),GC 還會周期性回收不再使用的記憶體,收縮棧空間)。

channel 是 Go 語言中各個并發結構體(goroutine) 之前的通信機制。 通俗的講就是各個 goroutine 之間通信的 ”管道“,有點類似于 Linux 中的管道。

生成一個 goroutine 的方式非常的簡單:Go 一下就生成:

通信機制 channel 也很友善,傳資料用 channel &lt;- data,取資料用 &lt;-channel。

在通信過程中,傳資料 channel &lt;- data 和 取資料 &lt;-channel 必然會成對出現,因為這邊傳那邊取,兩個 goroutine 之間才會實作通信。

而且不管傳還是取,必阻塞,直到另外的 goroutine 傳或者取為止。示例如下:

注意 <code>main()</code>本身也是運作了一個 goroutine。

<code>c := make(chan int)</code>這樣就聲明了一個阻塞式的無緩沖的通道。chan 是關鍵字,代表我要建立一個通道。

線程的實作模型主要有 3 種:核心級線程模型、使用者級線程模型和兩級線程模型(也稱混合型線程模型),它們之間最大的差異就在于 使用者線程與核心排程實體(KSE,Kernel Scheduling Entity)之間的對應關系上。

使用者線程:核心線程 = 1:1,每個使用者線程綁定一個核心線程,排程由「核心」完成。

優點:實作簡單(直接調用「系統調用」),可以并行(利用多核)

缺點:占用資源多,上下文切換慢

使用者線程:核心線程 = N:1,多個使用者線程(從屬于單個程序)綁定一個核心線程,排程由「使用者」完成;也就是說,作業系統隻知道使用者程序而對其中的線程是無感覺的,核心的所有排程都是基于使用者程序;許多語言的「協程庫」都屬于這種方式。

優點:占用資源少,上下文切換快,性能優于「核心級線程模型」

缺點:不支援多核,原生不支援并發(單個使用者線程有阻塞調用,則所有線程都阻塞;因為此模式下作業系統的排程最小機關為程序)

可以通過封裝阻塞調用為非阻塞調用,來避免所有線程都阻塞。

使用者線程:核心線程 = M:N,一個使用者程序(包含多個線程)可以「動态綁定」多個核心線程;排程由「核心、使用者」完成(使用者排程器實作使用者線程到核心線程的排程,核心排程器實作核心線程到CPU上的排程);比如:某個核心線程由于其綁定的使用者線程阻塞了,這個核心線程關聯的其他使用者線程可以重新綁定其他核心線程。

優點:支援并發,占用資源少

缺點:排程複雜

Go 采用的是此線程模型。下面來看看 Go 如何實作兩級混用線程模型。

Go 内部有三個對象:

P (processor) :代表上下文(或者可以認為是 CPU)

M (work thread):代表工作線程

G:goroutine

正常情況下一個 CPU 對象啟一個工作線程對象,線程去檢查并執行 goroutine 對象。碰到 goroutine 對象阻塞的時候,會啟動一個新的工作線程,以充分利用 CPU 資源。 所有有時候線程對象會比處理器對象多很多.

我們用如下圖分别表示 P、M、G:

Go 并行計算的核心-Goroutine

G(Goroutine) :表示 Goroutine,每個 Goroutine 對應一個 G 結構體,G 存儲 Goroutine 的運作堆棧、狀态以及任務函數,可重用。G 并非執行體,每個 G 需要綁定到 P 才能被排程執行。

M(Machine) :對 Os 核心級線程的封裝,數量對應真實的 CPU 數(真正幹活的對象)。在綁定有效的 P 後,進入 schedule 循環;而 schedule 循環的機制大緻是從 Global 隊列、P 的 Local 隊列以及 wait 隊列中擷取 G,切換到 G 的執行棧上并執行 G 的函數,調用 goexit 做清理工作并回到 M,如此反複。

M 并不保留 G 狀态,這是 G 可以跨 M 排程的基礎,M 的數量是不定的,由 Go Runtime 調整,為了防止建立過多 OS 線程導緻系統排程不過來,目前預設最大限制為 10000 個。

P(Processor) :邏輯處理器,對 G 來說,P 相當于 CPU 核,G 隻有綁定到 P (在 P 的 local runqueue 中)才能被排程。對 M 來說,P 提供了相關的執行環境 (Context),如記憶體配置設定狀态(mcache),任務隊列 (G) 等,P 的數量決定了系統内最大可并行的 G 的數量(前提:實體 CPU 核數 &gt;= P 的數量),P 的數量由使用者設定的 GOMAXPROCS 決定,但是不論 GOMAXPROCS 設定為多大,P 的數量最大為 256。

在單核情況下,所有 Goroutine 運作在同一個線程(M0)中,每一個線程維護一個上下文(P),任何時刻,一個上下文中隻有一個 Goroutine,其他 Goroutine 在 runqueue 中等待。

一個 Goroutine 運作完自己的時間片後讓出上下文,自己回到 runqueue 中(如下圖所示)。當正在運作的 G0 阻塞的時候(可以需要 IO),會再建立一個線程(M1),P 轉到新的線程中去運作。

Go 并行計算的核心-Goroutine

當 M0 傳回時,它會嘗試從其他線程中 “偷” 一個上下文過來,如果沒有偷到,會把 Goroutine 放到 Global runqueue 中去,然後把自己放入線程緩存中。 上下文會定時檢查 Global runqueue。

從兩級線程模型來看,似乎并不需要 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 上的執行與排程。

Channel 是 Go 語言中一個非常重要的類型。通過 channel,Go 實作了通過通信來實作記憶體共享。Channel 是在多個 goroutine 之間傳遞資料和同步的重要手段。

使用原子函數、讀寫鎖可以保證資源的共享通路安全,但使用 channel 更優雅

channel 的聲明:var 變量名 chan 類型(var c chan int ),使用 make 初始化,close 關閉。

無緩存區通道

有緩存的通道

channel 是一個引用類型,是以在它被初始化之前,它的值是 nil,channel 使用 make 函數進行初始化。可以向它傳遞一個 int 值,代表 channel 緩沖區的大小(容量),構造出來的是一個緩沖型的 channel;不傳或傳 0 的,構造的就是一個非緩沖型的 channel。

Go 并行計算的核心-Goroutine

無緩沖通道發送内容時,如果接受者沒有準備好,則發送者會阻塞,反之亦然。有緩沖通道不會阻塞,接受者如果讀取成功,則會繼續讀取,傳回值 ok,如果為讀取成功,管道未被關閉,則阻塞等待,如果未讀取到,并且已經關閉,則傳回 !ok。

select 語句提供了一種處理多通道的方法。跟 switch 語句很像,但是每個分支都是一個通道:

所有通道都會被監聽

select 會阻塞直到某個通道讀取到内容

如果多個通道都可以處理,則會以僞随機的方式處理

如果有預設分支,并且沒有通道就緒,則會立即執行

與switch語句相比, select有比較多的限制,其中最大的一條限制就是每個case語句裡必須是一個IO操作,大緻的結構如下:

在一個select語句中,Go語言會按順序從頭至尾評估每一個發送和接收的語句。

如果其中的任意一語句可以繼續執行(即沒有被阻塞),那麼就從那些可以執行的語句中任意選擇一條來使用。

如果沒有任意一條語句可以執行(即所有的通道都被阻塞),那麼有兩種可能的情況:

如果給出了default語句,那麼就會執行default語句,同時程式的執行會從select語句後的語句中恢複。

如果沒有default語句,那麼select語句将被阻塞,直到至少有一個通信可以進行下去。

大家來看這個例子:

這個例子會輸出 <code>0 2 4 6 8</code>,<code>i= 1 3 5 7 9</code>的時候兩個 case 都是滿足的,這時候 select 就會随機選擇一個。

有時候會出現goroutine阻塞的情況,那麼我們如何避免整個程式進入阻塞的情況呢?我們可以利用select來設定逾時,通過如下的方式實作:

本篇我們開始涉及 Go 核心的多線程通信部分。在 Java 中多個線程之前的資料傳輸方式是通過共享同一個變量,而 Go 通過 channel 這一中介溝通起了線程之前通信的橋梁。這在程式設計習慣上是一個很大的改變,大家要熟悉這一使用方式。

講完線程間通信,我們接下來就進入到并發程式設計的世界,看看在 Go 中是如何處理并發操作的。

Go 并行計算的核心-Goroutine