願我所遇之人,所歷之事,哪怕因為我有一點點變好,我就心滿意足了。
本文轉載自: blog.lab99.org/post/golang…
一、視訊資訊
1、視訊觀看地址
2、PPT下載地址
download.csdn.net/download/xu…
3、博文
about.sourcegraph.com/go/understa…
二、Go 的併發特性
- goroutines: 獨立執行每個任務,並可能並行執行
- channels: 用於 goroutines 之間的通訊、同步
1、一個簡單的事務處理的例子
對於下面這樣的非併發的程式:
func main() {
tasks := getTasks()
// 處理每個任務
for _, task := range tasks {
process(task)
}
}
複製程式碼
將其轉換為 Go 的併發模式很容易,使用典型的 Task Queue 的模式:
func main() {
// 建立帶緩衝的 channel
ch := make(chan Task, 3)
// 執行固定數量的 workers
for i := 0; i < numWorkers; i++ {
go worker(ch)
}
// 傳送任務到 workers
hellaTasks := getTasks()
for _, task := range hellaTasks {
ch <- task
}
...
}
func worker(ch chan Task) {
for {
// 接收任務
task := <-ch
process(task)
}
}
複製程式碼
2、channels 的特性
- goroutine-safe,多個 goroutine 可以同時訪問一個 channel。
- 可以用於在 goroutine 之間儲存和傳遞值
- 其語義是先入先出(FIFO)
- 可以導致 goroutine 的 block 和 unblock
三、解析
1、構造 channel
// 帶緩衝的 channel
ch := make(chan Task, 3)
// 無緩衝的 channel
ch := make(chan Task)
複製程式碼
回顧前面提到的 channel 的特性,特別是前兩個。如果忽略內建的 channel,讓你設計一個具有 goroutines-safe 並且可以用來儲存、傳遞值的東西,你會怎麼做?很多人可能覺得或許可以用一個帶鎖的佇列來做。沒錯,事實上,channel 內部就是一個帶鎖的佇列。 golang.org/src/runtime…
type hchan struct {
...
buf unsafe.Pointer // 指向一個環形佇列
...
sendx uint // 傳送 index
recvx uint // 接收 index
...
lock mutex // 互斥量
}
複製程式碼
buf 的具體實現很簡單,就是一個環形佇列的實現。sendx 和 recvx 分別用來記錄傳送、接收的位置。然後用一個 lock 互斥鎖來確保無競爭冒險。
對於每一個 ch := make(chan Task, 3) 這類操作,都會在堆中,分配一個空間,建立並初始化一個 hchan 結構變數,而 ch 則是指向這個 hchan 結構的指標。
因為 ch 本身就是個指標,所以我們才可以在 goroutine 函式呼叫的時候直接將 ch 傳遞過去,而不用再 &ch 取指標了,所以所有使用同一個 ch 的 goroutine 都指向了同一個實際的記憶體空間。
2、傳送、接收
為了方便描述,我們用 G1 表示 main() 函式的 goroutine,而 G2 表示 worker 的 goroutine。
// G1
func main() {
...
for _, task := range tasks {
ch <- task
}
...
}
// G2
func worker(ch chan Task) {
for {
task :=<-ch
process(task)
}
}
複製程式碼
2.1 簡單的傳送、接收
那麼 G1 中的 ch <- task0 具體是怎麼做的呢?
- 獲取鎖
- enqueue(task0)(這裡是記憶體複製 task0)
- 釋放鎖
這一步很簡單,接下來看 G2 的 t := <- ch 是如何讀取資料的。
- 獲取鎖
- t = dequeue()(同樣,這裡也是記憶體複製)
- 釋放鎖
這一步也非常簡單。但是我們從這個操作中可以看到,所有 goroutine 中共享的部分只有這個 hchan 的結構體,而所有通訊的資料都是記憶體複製。這遵循了 Go 併發設計中很核心的一個理念:
Do not communicate by sharing memory;instead, share memory by communicating
記憶體複製指的是:
// typedmemmove copies a value of type t to dst from src.
// Must be nosplit, see #16026.
//go:nosplit
func typedmemmove(typ *_type, dst, src unsafe.Pointer) {
if typ.kind&kindNoPointers == 0 {
bulkBarrierPreWrite(uintptr(dst), uintptr(src), typ.size)
}
// There's a race here: if some other goroutine can write to
// src, it may change some pointer in src after we've
// performed the write barrier but before we perform the
// memory copy. This safe because the write performed by that
// other goroutine must also be accompanied by a write
// barrier, so at worst we've unnecessarily greyed the old
// pointer that was in src.
memmove(dst, src, typ.size)
if writeBarrier.cgo {
cgoCheckMemmove(typ, dst, src, 0, typ.size)
}
}
複製程式碼
3、阻塞和恢復
3.1 傳送方被阻塞
假設 G2 需要很長時間的處理,在此期間,G1 不斷的傳送任務:
ch <- task1
ch <- task2
ch <- task3
複製程式碼
但是當再一次 ch <- task4 的時候,由於 ch 的緩衝只有 3 個,所以沒有地方放了,於是 G1 被 block 了,當有人從佇列中取走一個 Task 的時候,G1 才會被恢復。這是我們都知道的,不過我們今天關心的不是發生了什麼,而是如何做到的?
3.2 goroutine 的執行時排程
首先,goroutine 不是作業系統執行緒,而是 使用者空間執行緒。因此 goroutine 是由 Go runtime 來建立並管理的,而不是 OS,所以要比作業系統執行緒輕量級。
當然,goroutine 最終還是要執行於某個執行緒中的,控制 goroutine 如何執行於執行緒中的是 Go runtime 中的 scheduler (排程器)。
Go 的執行時排程器是 M:N 排程模型,既 N 個 goroutine,會執行於 M 個 OS 執行緒中。換句話說,一個 OS 執行緒中,可能會執行多個 goroutine。
Go 的 M:N 排程中使用了3個結構:
- M: OS 執行緒
- G: goroutine
- P: 排程上下文
- P 擁有一個執行佇列,裡面是所有可以執行的 goroutine 及其上下文
3.3 goroutine 被阻塞的具體過程
那麼當 ch <- task4 執行的時候,channel 中已經滿了,需要 pause G1。這個時候:
- G1 會呼叫執行時的 gopark
- 然後 Go 的執行時排程器就會接管
- 將 G1 的狀態設定為 waiting
- 斷開 G1 和 M 之間的關係(switch out),因此 G1 脫離 M,換句話說,M 空閒了,可以安排別的任務了。
- 從 P 的執行佇列中,取得一個可執行的 goroutine G
- 建立新的 G 和 M 的關係(Switch in),因此 G 就準備好執行了。
- 當排程器返回的時候,新的 G 就開始執行了,而 G1 則不會執行,也就是 block 了。
從上面的流程中可以看到,對於 goroutine 來說,G1 被阻塞了,新的 G 開始執行了;而對於作業系統執行緒 M 來說,則根本沒有被阻塞。
我們知道 OS 執行緒要比 goroutine 要沉重的多,因此這裡儘量避免 OS 執行緒阻塞,可以提高效能。
3.4 goroutine 恢復執行的具體過程
前面理解了阻塞,那麼接下來理解一下如何恢復執行。不過,在繼續瞭解如何恢復之前,我們需要先進一步理解 hchan 這個結構。因為,當 channel 不在滿的時候,排程器是如何知道該讓哪個 goroutine 繼續執行呢?而且 goroutine 又是如何知道該從哪取資料呢?
在 hchan 中,除了之前提到的內容外,還定義有 sendq 和 recvq 兩個佇列,分別表示等待傳送、接收的 goroutine,及其相關資訊。
type hchan struct {
...
buf unsafe.Pointer // 指向一個環形佇列
...
sendq waitq // 等待傳送的佇列
recvq waitq // 等待接收的佇列
...
lock mutex // 互斥量
}
複製程式碼
其中 waitq 是一個連結串列結構的佇列,每個元素是一個 sudog 的結構,其定義大致為:
type sudog struct {
g *g // 正在等候的 goroutine
elem unsafe.Pointer // 指向需要接收、傳送的元素
...
}
複製程式碼
所以在之前的阻塞 G1 的過程中,實際上:
- G1 會給自己建立一個 sudog 的變數
- 然後追加到 sendq 的等候佇列中,方便將來的receiver 來使用這些資訊恢復 G1。
這些都是發生在呼叫排程器之前。
那麼現在開始看一下如何恢復。
當 G2 呼叫 t := <- ch 的時候,channel 的狀態是,緩衝是滿的,而且還有一個 G1 在等候傳送佇列裡,然後 G2 執行下面的操作:
- G2 先執行 dequeue() 從緩衝佇列中取得 task1 給 t
- G2 從 sendq 中彈出一個等候傳送的 sudog
- 將彈出的 sudog 中的 elem 的值 enqueue() 到 buf 中
- 將彈出的 sudog 中的 goroutine,也就是 G1,狀態從 waiting 改為 runnable
- 然後,G2 需要通知排程器 G1 已經可以進行排程了,因此呼叫 goready(G1)。
- 排程器將 G1 的狀態改為 runnable
- 排程器將 G1 壓入 P 的執行佇列,因此在將來的某個時刻排程的時候,G1 就會開始恢復執行。
- 返回到 G2
注意,這裡是由 G2 來負責將 G1 的 elem 壓入 buf 的,這是一個優化。這樣將來 G1 恢復執行後,就不必再次獲取鎖、enqueue()、釋放鎖了。這樣就避免了多次鎖的開銷。
3.5 如果接收方先阻塞呢?
更酷的地方是接收方先阻塞的流程。
如果 G2 先執行了 t := <- ch,此時 buf 是空的,因此 G2 會被阻塞,他的流程是這樣:
- G2 給自己建立一個 sudog 結構變數。其中 g 是自己,也就是 G2,而 elem 則指向 t
- 將這個 sudog 變數壓入 recvq 等候接收佇列
- G2 需要告訴 goroutine,自己需要 pause 了,於是呼叫 gopark(G2)
- 和之前一樣,排程器將其 G2 的狀態改為 waiting
- 斷開 G2 和 M 的關係
- 從 P 的執行佇列中取出一個 goroutine
- 建立新的 goroutine 和 M 的關係
- 返回,開始繼續執行新的 goroutine
這些應該已經不陌生了,那麼當 G1 開始傳送資料的時候,流程是什麼樣子的呢?
G1 可以將 enqueue(task),然後呼叫 goready(G2)。不過,我們可以更聰明一些。
我們根據 hchan 結構的狀態,已經知道 task 進入 buf 後,G2 恢復執行後,會讀取其值,複製到 t 中。那麼 G1 可以根本不走 buf,G1 可以直接把資料給 G2。
Goroutine 通常都有自己的棧,互相之間不會訪問對方的棧內資料,除了 channel。這裡,由於我們已經知道了 t 的地址(通過 elem指標),而且由於 G2 不在執行,所以我們可以很安全的直接賦值。當 G2 恢復執行的時候,既不需要再次獲取鎖,也不需要對 buf 進行操作。從而節約了記憶體複製、以及鎖操作的開銷。
4、總結
- goroutine-safe
- hchan 中的 lock mutex
- 儲存、傳遞值,FIFO
- 通過 hchan 中的環形緩衝區來實現
- 導致 goroutine 的阻塞和恢復
- hchan 中的 sendq和recvq,也就是 sudog 結構的連結串列佇列
- 呼叫執行時排程器 (gopark(), goready())
四、其它 channel 的操作
1、無緩衝 channel
無緩衝的 channel 行為就和前面說的直接傳送的例子一樣:
- 接收方阻塞 → 傳送方直接寫入接收方的棧
- 傳送方阻塞 → 接受法直接從傳送方的 sudog 中讀取
2、select
- 先把所有需要操作的 channel 上鎖
- 給自己建立一個 sudog,然後新增到所有 channel 的 sendq或recvq(取決於是傳送還是接收)
- 把所有的 channel 解鎖,然後 pause 當前呼叫 select 的 goroutine(gopark())
- 然後當有任意一個 channel 可用時,select 的這個 goroutine 就會被排程執行。
- resuming mirrors the pause sequence
五、為什麼 Go 會這樣設計?
1、Simplicity 更傾向於帶鎖的佇列,而不是無鎖的實現。
效能提升不是憑空而來的,是隨著複雜度增加而增加的。
dvyokov 後者雖然效能可能會更好,但是這個優勢,並不一定能夠戰勝隨之而來的實現程式碼的複雜度所帶來的劣勢。
2、Performance
- 呼叫 Go 執行時排程器,這樣可以保持 OS 執行緒不被阻塞跨 goroutine 的棧讀、寫。
- 可以讓 goroutine 醒來後不必獲取鎖。
- 可以避免一些記憶體複製。
當然,任何優勢都會有其代價。這裡的代價是實現的複雜度,所以這裡有更復雜的記憶體管理機制、垃圾回收以及棧收縮機制。
在這裡效能的提高優勢,要比複雜度的提高帶來的劣勢要大。
所以在 channel 實現的各種程式碼中,我們都可以見到這種simplicity vs performance 的權衡後的結果。
六、博主資訊
個人微信公眾號: