為什麼我認為goroutine和channel是把別的平臺上類庫的功能內建在語言裡

老趙發表於2013-04-08

這幾天看了《Go語言程式設計》這本書,感覺一般,具體可見這篇書評。書評裡面我提到“Go語言的goroutine和channel其實是把別的語言/平臺上類庫的功能內建到語言裡”,這句話當然單單這麼說出來是沒什麼價值的,於是我也就趁熱把它說得再詳細一些。我的看法簡而言之是:由goroutine和channel所帶來的主要程式設計正規化、設計思路等等,其實基本都可以在其他一些平臺中配合特定的類庫來實現。

我們知道,作業系統的最小排程單元是“執行緒”,要執行任何一段程式碼,都必須落實到“執行緒”上。可惜執行緒太重,資源佔用太高,頻繁建立銷燬會帶來比較嚴重的效能問題,於是又誕生出執行緒池之類的常見使用模式。也是類似的原因,“阻塞”一個執行緒往往不是一個好主意,因為執行緒雖然暫停了,但是它所佔用的資源還在。執行緒的暫停和繼續對於排程器都會帶來壓力,而且執行緒越多,排程時的開銷便越大,這其中的平衡很難把握。

正因為如此,也有人提出並實現了fibercoroutine這樣的東西,所謂fiber便是一個比執行緒更小的程式碼執行單位,假如說“執行緒”是用來計算的“物理”資源,那麼fiber就可以認為是計算的“邏輯”資源了。從理念上說,goroutine和WebWorker都是類似fiber或coroutine這樣的概念(所以叫做goroutine):它們都是執行邏輯的計算單元,我們可以建立大量此類單元而不用擔心佔用過多資源,自有排程器來使用一個或多個執行緒來執行它們的邏輯。

Go語言使用go關鍵字來將任意一條語句放到一個coroutine上去執行。假如只是簡單地執行一段邏輯,那麼這和丟一段程式碼去執行緒池裡執行可以說沒有任何區別。但關鍵就在於,由於一個coroutine幾乎就是個普通的物件,因此我們往往可以放心地阻塞它的邏輯,一旦阻塞排程器可以讓當前執行緒立即去執行其他fiber上的程式碼。這裡的阻塞往往就是通過Go語言中的channel帶來的,一般來說會發生在“讀”和“寫”的時候:

func DoSomething(ch chan int) {
    ch <- 1
    var i = <-ch
}

上面程式碼中的ch就是一個用來儲存int型別資料的channel。第一行程式碼是向其寫入資料,可能在channel寫滿的時候阻塞。第二行則是從中獲取資料,在channel為空的時候阻塞。可以看出,所謂channel其實就是一個再簡單不過的容器而已。假如要類比.NET類庫,則可以認為它是一個實現了ITargetBlockISourceBlock的物件(例如一個BufferBlock):

static async void DoSomething<T>(T block)
    where T : ISourceBlock<int>, ITargetBlock<int> {

    await block.SendAsync(1);
    var i = await block.ReceiveAsync();
}

類似Go語言中的超時等特性自然也一應俱全。當然,這裡還並不能完全說是“類庫”,畢竟還用到了C# 5裡的async/await特性。我相信假如您對async/await有所瞭解的話,肯定也會聽到一些它跟coroutine相關或類比的聲音。它們在概念和效果上的確十分相似,當然背後的實現是有很大不同的。假如你一定要用coroutine,那還是免不了由語言或執行時提供支援。不過基於goroutine和channel的程式設計模式幾乎完全可以由類庫來實現。

在Go語言中,基於goroutine和channel的程式設計模式往往是這樣的:

func (ch chan int) {
    for { // 死迴圈
        var msg = <-ch

        Process(msg)
    }
}

這樣的“程式碼編寫模式”是基於阻塞的,這需要coroutine支援。不過假如我們把需求分析到最基礎的部分,它其實僅僅是:

  1. 可以建立大量佇列,每個佇列可以儲存大量任務。
  2. 單個佇列中的任務嚴格序列。
  3. 儘可能高效地(自然可以並行)處理系統中所有佇列裡的任務。

這就完全是類庫能實現的功能了,各個平臺上的此類成熟類庫並不少見:

  1. iOS上的GCD,或者說libdispatch。
  2. Java平臺上與GCD理念相同的HawtDispatch類庫。
  3. 與Scala語言關係更為密切的Akka類庫。
  4. .NET中的TPL Dataflow(之前提到的BufferBlock的出處)。

這些類庫與Go語言中基於goroutine和channel的開發方式有著相似的基礎,也完全有能力使用同樣的方式來架構系統。基於這些類庫,我們只需要提交大量的任務,至於這些任務什麼時候被執行則是內部實現所關心的問題,類庫自身將會把這些任務排程到物理執行緒上執行,用一種最高效,代價最低的方式。當然,我們也可以對這些佇列進行一些配置,這甚至比Go或Erlang中直接由語言執行時來提供的排程支援有更細緻的控制粒度。

我在工作中用過HawtDispatch和TPL Dataflow,也深刻體會到它們的價值。尤其是後者,我用TPL Dataflow實現的業務更為複雜,簡直可以說大大改善了我的工作品質,拿它來模仿之前的程式設計模式則可以是這樣的:

var block = new ActionBlock<int>(Process);

往這個block物件裡塞入的任何物件都會使用Process方法進行處理。當然TPL Dataflow的功能不止如此,它有著大量的高階功能,例如TransformBlock可以在保證順序的情況下進行一對多的資料轉換,十分好用。具體內容可以參考這篇說明

當然,像Go與Erlang這種對coroutine和併發直接提供支援的語言還可以有其他一些做法,例如Go可以做到先從channel A中獲取資料,然後在一個邏輯分支中再從channel B中獲取資料。這對於只提供任務佇列的類庫來說做起來就麻煩一些了(對於C#和Scala這類語言來說依然不成問題),不過在我的經驗裡這個限制似乎並不會成為嚴重的阻礙,我們依然可以實現相同訊息架構。

說起Erlang,其實在我看來它比Go的channel要好用不少。原因在於Erlang是動態型別語言,它的receive操作可以用來匹配當前佇列(在Erlang裡叫做mailbox)中不同模式的元組,篩選出符合特定模式的訊息。與此相反,Go是靜態型別語言,它總是從一個channel中依次獲取型別相同的元素,這就完全類似於Java或C#中的泛型集合了。當然,這也不會是個影響系統設計的大問題。

說實話,我覺得這篇文章描述過多,但缺乏案例。其實我本來想通過改寫《Go語言程式設計》中的範例來說明問題,但後來發現書中關於channel和goroutine的例子實在太簡單了,沒法體現出一個這個特性所帶來“架構設計”。所以,示例什麼的找機會再說吧。

相關文章