Go 通道是糟糕的,你應該也覺得很糟糕
更新:如果你是從一篇題為 《糟糕的 Go 語言》 的匯編文章看到這篇博文的話,那么我想表明的是,我很慚愧被列在這樣的名單上。Go 絕對是我使用過的最不糟糕的的編程語言。在我寫作本文時,我是想遏制我所看到的一種趨勢,那就是過度使用 Go 的一些較復雜的部分。我仍然認為 通道可以更好,但是總體而言,Go 很棒。這就像你最喜歡的工具箱中有 這個工具;它可以有用途(甚至還可能有更多的用途),它仍然可以成為你最喜歡的工具箱!
更新 2:如果我沒有指出這項對真實問題的優(yōu)秀調查,那我將是失職的:《理解 Go 中的實際并發(fā)錯誤》。這項調查的一個重要發(fā)現(xiàn)是...Go 通道會導致很多錯誤。
從 2010 年中后期開始,我就斷斷續(xù)續(xù)地在使用 Google 的 Go 編程語言,自 2012 年 1 月開始(在 Go 1.0 之前!),我就用 Go 為 Space Monkey 編寫了合規(guī)的產(chǎn)品代碼。我對 Go 的最初體驗可以追溯到我在研究 Hoare 的 通信順序進程 并發(fā)模型和 Matt Might 的 UCombinator 研究組 下的 π-演算 時,作為我(現(xiàn)在已重定向)博士工作的一部分,以更好地支持多核開發(fā)。Go 就是在那時發(fā)布的(多么巧合啊!),我當即就開始學習嘗試了。
它很快就成為了 Space Monkey 開發(fā)的核心部分。目前,我們在 Space Monkey 的生產(chǎn)系統(tǒng)有超過 42.5 萬行的純 Go 代碼(不 包括我們所有的 vendored 庫中的代碼量,這將使它接近 150 萬行),所以也并不是你見過的最多的 Go 代碼,但是對于相對年輕的語言,我們是重度用戶。我們之前 寫了我們的 Go 使用情況。也開源了一些使用率很高的庫;許多人似乎是我們的 OpenSSL 綁定(比 crypto/tls 更快,但請保持 openssl 本身是最新的!)、我們的 錯誤處理庫、日志庫 和 度量標準收集庫/zipkin 客戶端 的粉絲。我們使用 Go、我們熱愛 Go、我們認為它是目前為止我們使用過的最不糟糕的、符合我們需求的編程語言。
盡管我也不認為我能說服自己不要提及我的廣泛避免使用 goroutine-local-storage 庫 (盡管它是一個你不應該使用的魔改技巧,但它是一個漂亮的魔改),希望我的其他經(jīng)歷足以證明我在解釋我故意煽動性的帖子標題之前知道我在說什么。
等等,什么?
如果你在大街上問一個有名的程序員,Go 有什么特別之處? 她很可能會告訴你 Go 最出名的是通道 和 goroutine。 Go 的理論基礎很大程度上是建立在 Hoare 的 CSP(通信順序進程)模型上的,該模型本身令人著迷且有趣,我堅信,到目前為止,它產(chǎn)生的收益遠遠超過了我們的預期。
CSP(和 π-演算)都使用通信作為核心同步原語,因此 Go 會有通道是有道理的。Rob Pike 對 CSP 著迷(有充分的理由)相當深 已經(jīng)有一段時間了。(當時 和 現(xiàn)在)。
但是從務實的角度來看(也是 Go 引以為豪的),Go 把通道搞錯了。在這一點上,通道的實現(xiàn)在我的書中幾乎是一個堅實的反模式。為什么這么說呢?親愛的讀者,讓我細數(shù)其中的方法。
你可能最終不會只使用通道
Hoare 的 “通信順序進程” 是一種計算模型,實際上,唯一的同步原語是在通道上發(fā)送或接收的。一旦使用 互斥量、信號量 或 條件變量、bam,你就不再處于純 CSP 領域。 Go 程序員經(jīng)常通過高呼 “通過交流共享內存” 的 緩存的思想 來宣揚這種模式和哲學。
那么,讓我們嘗試在 Go 中僅使用 CSP 編寫一個小程序!讓我們成為高分接收者。我們要做的就是跟蹤我們看到的最大的高分值。如此而已。
首先,我們將創(chuàng)建一個 Game
結構體。
type Game struct {
bestScore int
scores chan int
}
bestScore
不會受到互斥量的保護!這很好,因為我們只需要一個 goroutine 來管理其狀態(tài)并通過通道來接收新的分值即可。
func (g *Game) run() {
for score := range g.scores {
if g.bestScore < score {
g.bestScore = score
}
}
}
好的,現(xiàn)在我們將創(chuàng)建一個有用的構造函數(shù)來開始 Game
。
func NewGame() (g *Game) {
g = &Game{
bestScore: 0,
scores: make(chan int),
}
go g.run()
return g
}
接下來,假設有人給了我們一個可以返回分數(shù)的 Player
。它也可能會返回錯誤,因為可能傳入的 TCP 流可能會死掉或發(fā)生某些故障,或者玩家退出。
type Player interface {
NextScore() (score int, err error)
}
為了處理 Player
,我們假設所有錯誤都是致命的,并將獲得的比分向下傳遞到通道。
func (g *Game) HandlePlayer(p Player) error {
for {
score, err := p.NextScore()
if err != nil {
return err
}
g.scores <- score
}
}
好極了!現(xiàn)在我們有了一個 Game
類型,可以以線程安全的方式跟蹤 Player
獲得的最高分數(shù)。
你圓滿完成了自己的開發(fā)工作,并開始擁有客戶。你將這個游戲服務器公開,就取得了令人難以置信的成功!你的游戲服務器上也許正在創(chuàng)建許多游戲。
很快,你發(fā)現(xiàn)人們有時會離開你的游戲。許多游戲不再有任何玩家在玩,但沒有任何東西可以阻止游戲運行的循環(huán)。死掉的 (*Game).run
goroutines 讓你不知所措。
挑戰(zhàn): 在無需互斥量或 panics 的情況下修復上面的 goroutine 泄漏。實際上,可以滾動到上面的代碼,并想出一個僅使用通道來解決此問題的方案。
我等著。
就其價值而言,它完全可以只通過通道來完成,但是請觀察以下解決方案的簡單性,它甚至沒有這個問題:
type Game struct {
mtx sync.Mutex
bestScore int
}
func NewGame() *Game {
return &Game{}
}
func (g *Game) HandlePlayer(p Player) error {
for {
score, err := p.NextScore()
if err != nil {
return err
}
g.mtx.Lock()
if g.bestScore < score {
g.bestScore = score
}
g.mtx.Unlock()
}
}
你想選擇哪一個?不要被欺騙了,以為通道的解決方案可以使它在更復雜的情況下更具可讀性和可理解性。拆解是非常困難的。這種拆解若用互斥量來做那只是小菜一碟,但最困難的是只使用 Go 專用通道來解決。另外,如果有人回復說發(fā)送通道的通道更容易推理,我馬上就是感到頭疼。
重要的是,這個特殊的情況可能真的 很容易 解決,而通道有一些運行時的幫助,而 Go 沒有提供!不幸的是,就目前的情況來看,與 Go 的 CSP 版本相比,使用傳統(tǒng)的同步原語可以更好地解決很多問題,這是令人驚訝的。稍后,我們將討論 Go 可以做些什么來簡化此案例。
練習: 還在懷疑? 試著讓上面兩種解決方案(只使用通道與只使用互斥量channel-only vs mutex-only)在一旦 bestScore
大于或等于 100 時,就停止向 Players
索要分數(shù)。繼續(xù)打開你的文本編輯器。這是一個很小的玩具問題。
這里的總結是,如果你想做任何實際的事情,除了通道之外,你還會使用傳統(tǒng)的同步原語。
通道比你自己實現(xiàn)要慢一些
Go 如此重視 CSP 理論,我認為其中一點就是,運行時應該可以通過通道做一些殺手級的調度優(yōu)化。也許通道并不總是最直接的原語,但肯定是高效且快速的,對吧?
正如 Dustin Hiatt 在 Tyler Treat’s post about Go 上指出的那樣,
在幕后,通道使用鎖來序列化訪問并提供線程安全性。 因此,通過使用通道同步對內存的訪問,你實際上就是在使用鎖。 被包裝在線程安全隊列中的鎖。 那么,與僅僅使用標準庫
sync
包中的互斥量相比,Go 的花式鎖又如何呢? 以下數(shù)字是通過使用 Go 的內置基準測試功能,對它們的單個集合連續(xù)調用 Put 得出的。
> BenchmarkSimpleSet-8 3000000 391 ns/op
> BenchmarkSimpleChannelSet-8 1000000 1699 ns/o
>
無緩沖通道的情況與此類似,甚至是在爭用而不是串行運行的情況下執(zhí)行相同的測試。
也許 Go 調度器會有所改進,但與此同時,良好的舊互斥量和條件變量是非常好、高效且快速。如果你想要提高性能,請使用久經(jīng)考驗的方法。
通道與其他并發(fā)原語組合不佳
好的,希望我已經(jīng)說服了你,有時候,你至少還會與除了通道之外的原語進行交互。標準庫似乎顯然更喜歡傳統(tǒng)的同步原語而不是通道。
你猜怎么著,正確地將通道與互斥量和條件變量一起使用,其實是有一定的挑戰(zhàn)性的。
關于通道的一個有趣的事情是,通道發(fā)送是同步的,這在 CSP 中是有很大意義的。通道發(fā)送和通道接收的目的是為了成為同步屏蔽,發(fā)送和接收應該發(fā)生在同一個虛擬時間。如果你是在執(zhí)行良好的 CSP 領域,那就太好了。
實事求是地說,Go 通道也有多種緩沖方式。你可以分配一個固定的空間來考慮可能的緩沖,以便發(fā)送和接收是不同的事件,但緩沖區(qū)大小是有上限的。Go 并沒有提供一種方法來讓你擁有任意大小的緩沖區(qū) —— 你必須提前分配緩沖區(qū)大小。 這很好,我在郵件列表上看到有人在爭論,因為無論如何內存都是有限的。
What。
這是個糟糕的答案。有各種各樣的理由來使用一個任意緩沖的通道。如果我們事先知道所有的事情,為什么還要使用 malloc
呢?
沒有任意緩沖的通道意味著在 任何 通道上的幼稚發(fā)送可能會隨時阻塞。你想在一個通道上發(fā)送,并在互斥下更新其他一些記賬嗎?小心!你的通道發(fā)送可能被阻塞!
// ...
s.mtx.Lock()
// ...
s.ch <- val // might block!
s.mtx.Unlock()
// ...
這是哲學家晚餐大戰(zhàn)的秘訣。如果你使用了鎖,則應該迅速更新狀態(tài)并釋放它,并且盡可能不要在鎖下做任何阻塞。
有一種方法可以在 Go 中的通道上進行非阻塞發(fā)送,但這不是默認行為。假設我們有一個通道 ch := make(chan int)
,我們希望在其上無阻塞地發(fā)送值 1
。以下是在不阻塞的情況下你必須要做的最小量的輸入:
select {
case ch <- 1: // it sent
default: // it didn't
}
對于剛入門的 Go程序員來說,這并不是自然而然就能想到的事情。
綜上所述,因為通道上的很多操作都會阻塞,所以需要對哲學家及其就餐仔細推理,才能在互斥量的保護下,成功地將通道操作與之并列使用,而不會造成死鎖。
嚴格來說,回調更強大,不需要不必要的 goroutines
每當 API 使用通道時,或者每當我指出通道使某些事情變得困難時,總會有人會指出我應該啟動一個 goroutine 來讀取該通道,并在讀取該通道時進行所需的任何轉換或修復。
呃,不。如果我的代碼位于熱路徑中怎么辦?需要通道的實例很少,如果你的 API 可以設計為使用互斥量、信號量和回調,而不使用額外的 goroutine (因為所有事件邊緣都是由 API 事件觸發(fā)的),那么使用通道會迫使我在資源使用中添加另一個內存分配堆棧。是的,goroutine 比線程輕得多,但更輕量并不意味著是最輕量。
正如我以前 在一篇關于使用通道的文章的評論中爭論過的(呵呵,互聯(lián)網(wǎng)),如果你使用回調而不是通道,你的 API 總是 可以更通用,總是 更靈活,而且占用的資源也會大大減少。“總是” 是一個可怕的詞,但我在這里是認真的。有證據(jù)級的東西在進行。
如果有人向你提供了一個基于回調的 API,而你需要一個通道,你可以提供一個回調,在通道上發(fā)送,開銷不大,靈活性十足。
另一方面,如果有人提供了一個基于通道的 API 給你,而你需要一個回調,你必須啟動一個 goroutine 來讀取通道,并且 你必須希望當你完成讀取時,沒有人試圖在通道上發(fā)送更多的東西,這樣你就會導致阻塞的 goroutine 泄漏。
對于一個超級簡單的實際例子,請查看 context 接口(順便說一下,它是一個非常有用的包,你應該用它來代替 goroutine 本地存儲)。
type Context interface {
...
// Done returns a channel that closes when this work unit should be canceled.
// Done 返回一個通道,該通道在應該取消該工作單元時關閉。
Done() <-chan struct{}
// Err returns a non-nil error when the Done channel is closed
// 當 Done 通道關閉時,Err 返回一個非 nil 錯誤
Err() error
...
}
想象一下,你要做的只是在 Done()
通道觸發(fā)時記錄相應的錯誤。你該怎么辦?如果你沒有在通道中選擇的好地方,則必須啟動 goroutine 進行處理:
go func() {
<-ctx.Done()
logger.Errorf("canceled: %v", ctx.Err())
}()
如果 ctx
在不關閉返回 Done()
通道的情況下被垃圾回收怎么辦?哎呀!這正是一個 goroutine 泄露!
現(xiàn)在假設我們更改了 Done
的簽名:
// Done calls cb when this work unit should be canceled.
Done(cb func())
首先,現(xiàn)在日志記錄非常容易。看看:ctx.Done(func() { log.Errorf ("canceled:%v", ctx.Err()) })
。但是假設你確實需要某些選擇行為。你可以這樣調用它:
ch := make(chan struct{})
ctx.Done(func() { close(ch) })
瞧!通過使用回調,不會失去表現(xiàn)力。 ch
的工作方式類似于用于返回的通道 Done()
,在日志記錄的情況下,我們不需要啟動整個新堆棧。我必須保留堆棧跟蹤信息(如果我們的日志包傾向于使用它們);我必須避免將其他堆棧分配和另一個 goroutine 分配給調度程序。
下次你使用通道時,問問你自己,如果你用互斥量和條件變量代替,是否可以消除一些 goroutine ? 如果答案是肯定的,那么修改這些代碼將更加有效。而且,如果你試圖使用通道只是為了在集合中使用 range
關鍵字,那么我將不得不請你放下鍵盤,或者只是回去編寫 Python 書籍。
通道 API 不一致,只是 cray-cray
在通道已關閉的情況下,執(zhí)行關閉或發(fā)送消息將會引發(fā) panics!為什么呢? 如果想要關閉通道,你需要在外部同步它的關閉狀態(tài)(使用互斥量等,這些互斥量的組合不是很好!),這樣其他寫入者才不會寫入或關閉已關閉的通道,或者只是向前沖,關閉或寫入已關閉的通道,并期望你必須恢復所有引發(fā)的 panics。
這是多么怪異的行為。 Go 中幾乎所有其他操作都有避免 panic 的方法(例如,類型斷言具有 , ok =
模式),但是對于通道,你只能自己動手處理它。
好吧,所以當發(fā)送失敗時,通道會出現(xiàn) panic。我想這是有一定道理的。但是,與幾乎所有其他帶有 nil 值的東西不同,發(fā)送到 nil 通道不會引發(fā) panic。相反,它將永遠阻塞!這很違反直覺。這可能是有用的行為,就像在你的除草器上附加一個開罐器,可能有用(在 Skymall 可以找到)一樣,但這肯定是意想不到的。與 nil 映射(執(zhí)行隱式指針解除引用),nil 接口(隱式指針解除引用),未經(jīng)檢查的類型斷言以及其他所有類型交互不同,nil 通道表現(xiàn)出實際的通道行為,就好像為該操作實例化了一個全新的通道一樣。
接收的情況稍微好一點。在已關閉的通道上執(zhí)行接收會發(fā)生什么?好吧,那會是有效操作——你將得到一個零值。好吧,我想這是有道理的。獎勵!接收允許你在收到值時進行 , ok =
樣式的檢查,以確定通道是否打開。謝天謝地,我們在這里得到了 , ok =
。
但是,如果你從 nil 渠道接收會發(fā)生什么呢? 也是永遠阻塞! 耶!不要試圖利用這樣一個事實:如果你關閉了通道,那么你的通道是 nil!
通道有什么好處?
當然,通道對于某些事情是有好處的(畢竟它們是一個通用容器),有些事情你只能用它們來做(比如 select
)。
它們是另一種特殊情況下的通用數(shù)據(jù)結構
Go 程序員已經(jīng)習慣于對泛型的爭論,以至于我一提起這個詞就能感覺到 PTSD(創(chuàng)傷后應激障礙)的到來。我不是來談論這件事的,所以擦擦額頭上的汗,讓我們繼續(xù)前進吧。
無論你對泛型的看法是什么,Go 的映射、切片和通道都是支持泛型元素類型的數(shù)據(jù)結構,因為它們已經(jīng)被特殊封裝到語言中了。
在一種不允許你編寫自己的泛型容器的語言中,任何允許你更好地管理事物集合的東西都是有價值的。在這里,通道是一個支持任意值類型的線程安全數(shù)據(jù)結構。
所以這很有用!我想這可以省去一些陳詞濫調。
我很難把這算作是通道的勝利。
Select
使用通道可以做的主要事情是 select
語句。在這里,你可以等待固定數(shù)量的事件輸入。它有點像 epoll,但你必須預先知道要等待多少個套接字。
這是真正有用的語言功能。如果不是 select
,通道將被徹底清洗。但是我的天吶,讓我告訴你,第一次決定可能需要在多個事物中選擇,但是你不知道有多少項,因此必須使用 reflect.Select
。
通道如何才能更好?
很難說 Go 語言團隊可以為 Go 2.0 做的最具戰(zhàn)術意義的事情是什么(Go 1.0 兼容性保證很好,但是很費勁),但這并不能阻止我提出一些建議。
在條件變量上的 Select !
我們可以不需要通道!這是我提議我們擺脫一些“圣牛”(LCTT 譯注:神圣不可質疑的事物)的地方,但是讓我問你,如果你可以選擇任何自定義同步原語,那會有多棒?(答:太棒了。)如果有的話,我們根本就不需要通道了。
GC 可以幫助我們嗎?
在第一個示例中,如果我們能夠使用定向類型的通道垃圾回收(GC)來幫助我們進行清理,我們就可以輕松地解決通道的高分服務器清理問題。
如你所知,Go 具有定向類型的通道。 你可以使用僅支持讀取的通道類型(<-chan
)和僅支持寫入的通道類型(chan<-
)。 這太棒了!
Go 也有垃圾回收功能。 很明顯,某些類型的記賬方式太繁瑣了,我們不應該讓程序員去處理它們。 我們清理未使用的內存! 垃圾回收非常有用且整潔。
那么,為什么不幫助清理未使用或死鎖的通道讀取呢? 與其讓 make(chan Whatever)
返回一個雙向通道,不如讓它返回兩個單向通道(chanReader, chanWriter:= make(chan Type)
)。
讓我們重新考慮一下最初的示例:
type Game struct {
bestScore int
scores chan<- int
}
func run(bestScore *int, scores <-chan int) {
// 我們不會直接保留對游戲的引用,因為這樣我們就會保留著通道的發(fā)送端。
for score := range scores {
if *bestScore < score {
*bestScore = score
}
}
}
func NewGame() (g *Game) {
// 這種 make(chan) 返回風格是一個建議
scoreReader, scoreWriter := make(chan int)
g = &Game{
bestScore: 0,
scores: scoreWriter,
}
go run(&g.bestScore, scoreReader)
return g
}
func (g *Game) HandlePlayer(p Player) error {
for {
score, err := p.NextScore()
if err != nil {
return err
}
g.scores <- score
}
}
如果垃圾回收關閉了一個通道,而我們可以證明它永遠不會有更多的值,那么這個解決方案是完全可行的。是的,是的,run
中的評論暗示著有一把相當大的槍瞄準了你的腳,但至少現(xiàn)在這個問題可以很容易地解決了,而以前確實不是這樣。此外,一個聰明的編譯器可能會做出適當?shù)淖C明,以減少這種腳槍造成的損害。
其他小問題
- Dup 通道嗎? —— 如果我們可以在通道上使用等效于
dup
的系統(tǒng)調用,那么我們也可以很容易地解決多生產(chǎn)者問題。 每個生產(chǎn)者可以關閉自己的dup
版通道,而不會破壞其他生產(chǎn)者。 - 修復通道 API! —— 關閉不是冪等的嗎? 在已關閉的通道上發(fā)送信息引起的 panics 沒有辦法避免嗎? 啊!
- 任意緩沖的通道 —— 如果我們可以創(chuàng)建沒有固定的緩沖區(qū)大小限制的緩沖通道,那么我們可以創(chuàng)建非阻塞的通道。
那我們該怎么向大家介紹 Go 呢?
如果你還沒有,請看看我目前最喜歡的編程文章:《你的函數(shù)是什么顏色》。雖然不是專門針對 Go,但這篇博文比我更有說服力地闡述了為什么 goroutines 是 Go 最好的特性(這也是 Go 在某些應用程序中優(yōu)于 Rust 的方式之一)。
如果你還在使用這樣的一種編程語言寫代碼,它強迫你使用類似 yield
關鍵字來獲得高性能、并發(fā)性或事件驅動的模型,那么你就是活在過去,不管你或其他人是否知道這一點。到目前為止,Go 是我所見過的實現(xiàn) M:N 線程模型(非 1:1 )的語言中最好的入門者之一,而且這種模型非常強大。
所以,跟大家說說 goroutines 吧。
如果非要我選擇 Go 的另一個主要特性,那就是接口。靜態(tài)類型的 鴨子模型 使得擴展、使用你自己或他人的項目變得如此有趣而令人驚奇,這也許值得我改天再寫一組完全不同的文章來介紹它。
所以…
我一直看到人們爭先恐后沖進 Go,渴望充分利用通道來發(fā)揮其全部潛力。這是我對你的建議。
夠了!
當你在編寫 API 和接口時,盡管“絕不”的建議可能很糟糕,但我非常肯定,通道從來沒有什么時候好過,我用過的每一個使用通道的 Go API,最后都不得不與之抗爭。我從來沒有想過“哦 太好了,這里是一個通道;”它總是被一些變體取代,這是什么新鮮的地獄?
所以,請在適當?shù)牡胤剑⑶抑辉谶m當?shù)牡胤绞褂猛ǖ馈?/em>
在我使用的所有 Go 代碼中,我可以用一只手數(shù)出有多少次通道真的是最好的選擇。有時候是這樣的。那很好!那就用它們吧。但除此之外,就不要再使用了。