設計Go API的管道使用原則

Codefor發表於2014-07-18

管道是併發安全的佇列,用於在Go的輕量級執行緒(Go協程)之間安全地傳遞訊息。總的來講,這些原語是Go語言中最為稱道的特色功能之一。這種訊息傳遞正規化使得開發者可以以易於理解的語義和控制流來協調管理多執行緒併發任務,而這勝過使用回撥函式或者共享記憶體。

即使管道如此強大,在公有的API中卻不常見。例如,我梳理過Go的標準庫,在145個包中有超過6000個公有的API。在這上千個API中,去重後,只有5個用到了管道。

在公有的API中使用管道時,如何折衷考慮和取捨,缺乏指導。“共有API”,我是指“任何實現者和使用者是不同的兩個人的程式設計介面”。這篇文章會深入講解,為如何在共有API中使用管道,提供一系列的原則和解釋。一些特例會在本章末尾討論。

原則 #1

API應該宣告管道的方向性。

例子

time.After

signal.Notify

儘管並不常用,Go允許指定一個管道的方向性。語言規範這麼寫:

可選的<-操作符指定了管道的方向,傳送或接收。如果沒有指定方向,那麼管道就是雙向的。

關鍵在於API簽名中的方向操作符會被編譯器強制檢查

除了能夠被編譯器強制檢查安全性,方向操作符還能幫助API使用者理解資料的流動方向——只需要看一下型別簽名即可。

原則 #2

向一個管道傳送無界資料流的API必須寫文件解釋清楚在消費者消費不及時時API的行為。

例子

time.NewTicker

signal.Notify

ssh.Conn.OpenChannel

當一個API向一個管道傳送無界資料流時,在實現API時面臨的問題是如果向管道傳送資料會阻塞怎麼辦。阻塞的原因可能是管道已經滿了或者管道是無緩衝的,沒有go協程準備好接收資料。針對不同的場景要選擇合適的行為,但是每個場景必須作出選擇。例如,ssh包選擇了阻塞,並且文件寫明如果你不接受資料,連線就會被卡住。signal.Notify 和 time.Tick選擇不阻塞,直接丟棄資料。

不足的是,Go本身並沒有從型別或函式簽名角度提供方法指定預設行為。作為API的設計者,你必須在文件中寫明行為,不然其行為就是不定的。然而,多數情況下我們都是API的使用者而不是設計者,所以我們可以反過來記這個原則,反過來就是一條警告資訊:

對於通過一個管道向一個慢速的消費者傳送無界資料的API,在沒有通讀API的文件或者實現原始碼之前,你不能確定API的行為。

原則 #3

向一個管道傳送有界資料,同時這個管道是作為引數傳遞進來的API,必須用文件寫明對於慢速消費者的行為。

不好的例子

rpc.Client.Go

這個原則和第二個原則類似,不同點在於這個原則用於傳送有界資料的API。不幸的是,在標準庫中沒有很好的例子。標準庫中唯一的API就是rpc.Client.Go,但它違背了我們的原則。文件上這麼寫:

Go非同步的呼叫這個函式。它會返回代表著呼叫的Call資料結構。在呼叫完成時,done管道會通過返回同一個Call物件來觸發。如果done是空的,Go會分配一個新的管道;如果不空,done必須是有緩衝的,不然Go就會崩潰。

Go傳送了有界資料(只有1,當遠端呼叫結束時)。但是注意到,由於管道是被當作引數傳遞到函式中的,所以它仍然存在慢速消費者問題。即使你必須傳一個帶緩衝的管道進來,如果管道已滿,向這個管道傳送資料仍然可能會阻塞。文件並沒有定義這種場景下的行為。需要我們來讀讀原始碼了:

src/pkg/net/rpc/client.go

噢!如果done管道沒有合適的緩衝,RPC的響應可能丟失了。

原則 #4

向一個管道傳送無界資料流的API應該接受管道作為引數,而不是返回一個新的管道。

例子

signal.Notify

ssh.NewClient

當我第一次看到signal.Notify這個API時,我很疑惑,“為什麼它接收一個管道作為輸入而不是直接返回一個管道給我用?”“使用這個API需要呼叫方分配一個管道,難道API就不能替我們做麼,像下面這樣?”

文件幫助我們理解為什麼這不是好的選擇:

signal包向c傳送資料時並不會阻塞:呼叫方必須保證c有足夠的緩衝空間來跟得上潛在的訊號速度

signal.Notify接收管道作為引數,因為它把緩衝空間的控制權交給了呼叫方。這使得呼叫方可以選擇,在處理一個訊號時,可以安全的忽略多少訊號,這需要和快取這些訊號的記憶體開銷作折衷考慮。

緩衝大小的控制在高吞吐系統中尤為重要。設想一個高吞吐的釋出訂閱系統的這樣一個介面:

往管道中傳送越多的訊息,管道同步稱為效能瓶頸的可能性越大。由於API允許呼叫方建立管道,呼叫方需要考慮緩衝,進而效能可以由呼叫方控制。這是一種更靈活的設計。

如果僅僅是控制緩衝的大小,我們可能會爭論如下的API就足夠了:

這樣設計,管道作為引數還是必須的,因為這樣允許呼叫方使用一個管道動態的處理不同型別的訊號。這樣設計為呼叫方提供了更多的程式結構和效能上的靈活性。作為一個假想實驗,讓我們用Subscribe API來構建需求。訂閱newcustomer管道,並對於每一條訊息,為消費者訂閱其主題。如果API允許我們傳遞接收管道,我們可以這樣寫:

但是,如果管道被返回了,呼叫方不得不為每一個訂閱啟動一個單獨的go協程。這在任何複用場景都會帶來額外的記憶體和同步開銷:

原則 #5

傳送有界資料的API可以通過返回一個合適大小緩衝的管道來達到目的。

例子:

http.CloseNotifier

time.After

當API向一個管道傳送有界資料時,可以返回一個擁有容納全部資料的緩衝空間的管道。這個要返回的管道的方向性標識保證了呼叫方必須遵守約定。CloseNotify 和After返回的管道 都利用了這一點。

同時,需要注意到,通過允許呼叫方傳遞一個管道來接收資料,這些呼叫可能會更靈活。但需要處理當管道滿了的時候(原則3)。例如,另外一個可選的,更靈活的CloseNotifier:

但是這種額外的靈活性帶來的開銷並不值得關注,因為單一的呼叫方很少會同時等待多個關閉通知。畢竟,關閉通知只有在某個連線上下文內才有效。不同的連線一般都是相互獨立的。

特例

一些API打破了我們的原則,需要仔細分析。

原則 #1 的特例

API需要宣告管的方向性。

例子

rpc.Client.Go

傳過來的done管道沒有方向性識別符號:

直觀上看,這樣做是因為done管道是作為Call結構體的一部分返回的。

這種靈活性是需要的,這樣允許在你傳nil時分配一個done管道出來。如果堅持原則1,就需要從Call結構中去除done並且宣告兩個函式:

原則 #4 的特例

向管道傳送無界資料流的API需要接收管道作為引數,而不是返回一個新的管道。

例子

go.crypto/ssh

time.Tick

go.crypto/ssh包幾乎在所有的地方都返回了無界的資料流管道。ssh.NewClientConn只是其中的一個。給呼叫者更多控制權和靈活性的API應該是這樣:

time.Tick也違反了這個原則,但是易於理解。我們很少會建立非常多的計時器,通常都是獨立的處理不同的計時器。這個例子中緩衝也沒太大意義。

第二部分:那些原本可能使用的管道

這篇文章是一篇長文,所以我準備分成兩部分講。接下來會提很多問題,為什麼標準庫中可以使用管的地方卻沒有用管道。例如,http.Serve 返回了一個永不結束的等待被處理的請求流,為什麼用了回撥函式而不是將這些請求傳送到一個處理管道中?第二部分會介紹更多!

相關文章