理解 Go Channels[精品長文]

衣舞晨風發表於2019-08-01

願我所遇之人,所歷之事,哪怕因為我有一點點變好,我就心滿意足了。

本文轉載自: blog.lab99.org/post/golang…

一、視訊資訊

1、視訊觀看地址

www.youtube.com/watch?v=KBZ…

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。這個時候:

  1. G1 會呼叫執行時的 gopark
  2. 然後 Go 的執行時排程器就會接管
  3. 將 G1 的狀態設定為 waiting
  4. 斷開 G1 和 M 之間的關係(switch out),因此 G1 脫離 M,換句話說,M 空閒了,可以安排別的任務了。
  5. 從 P 的執行佇列中,取得一個可執行的 goroutine G
  6. 建立新的 G 和 M 的關係(Switch in),因此 G 就準備好執行了。
  7. 當排程器返回的時候,新的 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 // 指向需要接收、傳送的元素
  ...
}
複製程式碼

golang.org/src/runtime…

所以在之前的阻塞 G1 的過程中,實際上:

  1. G1 會給自己建立一個 sudog 的變數
  2. 然後追加到 sendq 的等候佇列中,方便將來的receiver 來使用這些資訊恢復 G1。

這些都是發生在呼叫排程器之前

那麼現在開始看一下如何恢復。

當 G2 呼叫 t := <- ch 的時候,channel 的狀態是,緩衝是滿的,而且還有一個 G1 在等候傳送佇列裡,然後 G2 執行下面的操作:

  1. G2 先執行 dequeue() 從緩衝佇列中取得 task1 給 t
  2. G2 從 sendq 中彈出一個等候傳送的 sudog
  3. 將彈出的 sudog 中的 elem 的值 enqueue() 到 buf 中
  4. 將彈出的 sudog 中的 goroutine,也就是 G1,狀態從 waiting 改為 runnable
    1. 然後,G2 需要通知排程器 G1 已經可以進行排程了,因此呼叫 goready(G1)。
    2. 排程器將 G1 的狀態改為 runnable
    3. 排程器將 G1 壓入 P 的執行佇列,因此在將來的某個時刻排程的時候,G1 就會開始恢復執行。
    4. 返回到 G2

注意,這裡是由 G2 來負責將 G1 的 elem 壓入 buf 的,這是一個優化。這樣將來 G1 恢復執行後,就不必再次獲取鎖、enqueue()、釋放鎖了。這樣就避免了多次鎖的開銷。

3.5 如果接收方先阻塞呢?

更酷的地方是接收方先阻塞的流程。

如果 G2 先執行了 t := <- ch,此時 buf 是空的,因此 G2 會被阻塞,他的流程是這樣:

  1. G2 給自己建立一個 sudog 結構變數。其中 g 是自己,也就是 G2,而 elem 則指向 t
  2. 將這個 sudog 變數壓入 recvq 等候接收佇列
  3. G2 需要告訴 goroutine,自己需要 pause 了,於是呼叫 gopark(G2)
    1. 和之前一樣,排程器將其 G2 的狀態改為 waiting
    2. 斷開 G2 和 M 的關係
    3. 從 P 的執行佇列中取出一個 goroutine
    4. 建立新的 goroutine 和 M 的關係
    5. 返回,開始繼續執行新的 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

golang.org/src/runtime…

  1. 先把所有需要操作的 channel 上鎖
  2. 給自己建立一個 sudog,然後新增到所有 channel 的 sendq或recvq(取決於是傳送還是接收)
  3. 把所有的 channel 解鎖,然後 pause 當前呼叫 select 的 goroutine(gopark())
  4. 然後當有任意一個 channel 可用時,select 的這個 goroutine 就會被排程執行。
  5. resuming mirrors the pause sequence

五、為什麼 Go 會這樣設計?

1、Simplicity 更傾向於帶鎖的佇列,而不是無鎖的實現。

效能提升不是憑空而來的,是隨著複雜度增加而增加的。

dvyokov 後者雖然效能可能會更好,但是這個優勢,並不一定能夠戰勝隨之而來的實現程式碼的複雜度所帶來的劣勢。

2、Performance

  • 呼叫 Go 執行時排程器,這樣可以保持 OS 執行緒不被阻塞跨 goroutine 的棧讀、寫。
  • 可以讓 goroutine 醒來後不必獲取鎖。
  • 可以避免一些記憶體複製。

當然,任何優勢都會有其代價。這裡的代價是實現的複雜度,所以這裡有更復雜的記憶體管理機制、垃圾回收以及棧收縮機制。

在這裡效能的提高優勢,要比複雜度的提高帶來的劣勢要大。

所以在 channel 實現的各種程式碼中,我們都可以見到這種simplicity vs performance 的權衡後的結果。

六、博主資訊

個人微信公眾號:

理解 Go Channels[精品長文]

個人部落格

個人github

個人掘金部落格

個人CSDN部落格

相關文章