channel in Go's runtime

原文鏈接:http://studygolang.com/articles/1806?from=timeline&isappinstalled=0

那張圖是我看明白channel 實現的關鍵

下面是我之前困惑的一段代碼,

func main() {
     message := make(chan string) // no buffer
     count := 3

     go func() {
          for i := 1; i <= count; i++ {
               fmt.Println("send message")
               message <- fmt.Sprintf("message %d", i)
          }
     }()

     time.Sleep(time.Second * 3)

     for i := 1; i <= count; i++ {
          fmt.Println(<-message)
     }
}

Go語言有一個非常大的亮點就是支持語言級別的併發。語言級別提供併發編程,究竟有多重要,可能需要你親自去體會多線程、事件+callback等常見的併發併發編程模型後才能準確的感受到。爲了配合語言級別的併發支持,channel組件就是Go語言必不可少的了。官方提倡的一個編程信條——“使用通信去共享內存,而不是共享內存去通信”,這裏說的”通信去共享內存”的手段就是channel。

channel的實現位於runtime/chan.c文件中。

channel底層結構模型

每個channel都是由一個Hchan結構定義的,這個結構中有兩個非常關鍵的字段就是recvq和sendq。recvq和sendq是兩個等待隊列,這個兩個隊列裏分別保存的是等待在channel上進行讀操作的goroutine和等待在channel上進行寫操作的goroutine。

當我們使用make()創建一個channel後,這個channel的大概內存模型就如上圖,有一個Hchan結構頭部,頭部後面的所有內存將被劃分爲一個一個的slot,每個slot將存儲一個元素。slot的個數當然就是make channel時指定的緩衝大小。如果make的channel是無緩衝的,那麼這裏就沒有slot了,就只有Hchan這個頭部結構。channel的這個底層實現就是分配的一段連續內存(數組),不是採用的鏈表或者其他的什麼高級數據結構,事實上做這件事情也不需要高級的數據結構了。

這裏的所有slot形成的數組本身在不移動內存的情況下,是無法做到FIFO的,事實上,Hchan中還有兩個關鍵字段recvx和sendx,在它們的配合下就將slot數組構成了一個循環數組,就這樣利用數組實現了一個循環隊列。

這裏得吐槽一小段代碼,這段代碼就是在make一個channel的函數中。

#define	MAXALIGN	7

Hcan *c;

// calculate rounded size of Hchan
n = sizeof(*c);
while(n & MAXALIGN)
	n++;

這裏的while循環就是要將Hchan結構的大小向上補齊到8的倍數,這樣後面的內存空間就是按8字節對齊了。爲了完成這個向上的補齊操作,最壞情況要執行7次循環,而事實上是可以一步到位的補齊到8的倍數,完全沒必要一次一次的加1進行嘗試。這個細節其實在很多代碼裏都有,Nginx就做得很優雅。我是想說Go的部分代碼還是挺奔放的,我個人很不喜歡runtime裏面的一些函數/變量的命名。

寫channel

有了channel的底層結構模型,基本上也能想象一個元素是如何在channel進行”入隊/出隊”了。完成寫channel操作的函數是runtime·chansend,這個函數同時實現了同步/異步寫channel,也就是帶/不帶緩衝的channel的寫操作都是在這個函數裏實現的。同步寫,還是異步寫,其實就是判斷是否有slot。這裏敘述一下寫channel的過程,不再展示代碼了。

  1. 加鎖,鎖住整個channel結構(就是上面的貼圖模型)。加鎖是可以理解,只是這個鎖也夠大的。所以,是否一定總是通過“通信來共享內存”是需要慎重考慮的。這把鎖可以看出,channel很多時候不一定有直接對共享變量加鎖效率高。
  2. 現在已經鎖住了整個channel了,可以開始幹活了。判斷是否有slot(是否帶緩衝),如果有就做異步寫,沒有就做同步寫。
  3. 假設第2步判斷的是同步寫,那麼就試着從recvq等待隊列裏取出一個等待的goroutine,然後將要寫入的元素直接交給(拷貝)這個goroutine,然後再將這個拿到元素的goroutine給設置爲ready狀態,就可以開始運行了。到這裏並沒有完,如果recvq裏,並沒有一個等待的goroutine,那麼就將待寫入的元素保存在當前執行寫的goroutine的結構裏,然後將當前goroutine入隊到sendq中並被掛起,等待有人來讀取元素後纔會被喚醒。這個時候,同步寫的過程就真的完成了。
  4. 假設第2步判斷的是異步寫,異步寫相對同步寫來說,依賴的對象不再是是否有goroutine在等待讀,而是緩衝區是否被寫滿(是否還有slot)。因此,異步寫的過程和同步寫大體上也是一樣的。首先是判斷是否還有slot可用,如果沒有slot可用了,就將當前goroutine入隊到sendq中並被掛起等待。如果有slot可用,就將元素追加到一個slot中,再從recvq中試着取出一個等待的goroutine開始進行讀操作(如果recvq中有等待讀的goroutine的話)。到這裏,異步寫也就完成了。

異步寫和同步寫在邏輯過程上基本是相同的,只是依賴的對象不一樣而已。同步寫依賴是否有等待讀的goroutine,異步寫依賴是否有可用的緩衝區。

讀channel

我們知道了寫過程的邏輯,試着推測一下讀過程其實一點也不難了。有了寫,本質上就有了讀了。完成讀channel操作的函數是runtime·chanrecv, 下面簡單的敘述一下讀過程。

  1. 同樣首先加鎖,鎖住整個channel好乾活。
  2. a通過是否帶緩衝來判斷做同步讀還是異步讀, 類似寫過程。
  3. 假設是同步讀,就試着從sendq隊列取出一個等待寫的goroutine,並把需要寫入的元素拿過來(拷貝),再將取出的goroutine給ready起來。如果sendq中沒有等待寫的goroutine,就只能把當前讀的goroutine給入隊到recvq並被掛起了。
  4. 假設是異步讀,這個時候就是判斷緩衝區中是否有一個元素,沒的話,就是將當前讀goroutine給入隊到recvq並被掛起等待。如果有元素的話,當然就是取出最前面的元素,同時試着從sendq中取出一個等待寫的goroutine喚醒它。

通過讀寫過程可以看出,讀和寫是心心相惜的,裏面有一個非常重要的細節——讀需要去”喚醒”寫的goroutine,寫的時候需要去“喚醒”讀的goroutine。所以這裏的讀寫過程其實是成對出現,配合完成工作的,缺少一個都不行。(我好像在說廢話)

無限大channel的實現

有同事提到如何實現一個不限制緩衝區大小的channel,同時還支持select操作。select的實現,放下一次討論了。不管用什麼語言,要實現一個無限制大小的channel,應該都不難。在目前channel的基礎如何實現一個無限制大小的channel,在這裏我大概說一下我的想法,拋磚引玉。

現在的channel其實就一個數組而已,爲了避免內存拷貝,可以在目前的基礎上加一層鏈表結構。這樣一來,只要緩衝區用完後,就可以分配一個新的slot數組,並且和老的數組給鏈起來構成一個更大的緩衝區。這裏代碼上最複雜的應該是元素被讀走後,需要將空的數組給釋放掉。加入鏈表來構造無限制的channel實現看上去是一種比較簡單有效的方案。

如果channel是無限制緩衝大小的,那麼寫入的goroutine就永遠不會被掛起等待了,也就不要sendq隊列了。當然,沒消費者或者消費者掛掉的話,這個channel最終也會導致內存爆掉。所以,無限制大小的channel是否真的有必要???

瞭解了channel的底層實現,應該可以更好選擇“通信去共享內存,還是共享內存去通信”,沒有什麼是銀彈。

注:本文是基於go1.1.2版本代碼。


發佈了144 篇原創文章 · 獲贊 40 · 訪問量 102萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章