Linux中國

Go 通道是糟糕的,你應該也覺得很糟糕

更新:如果你是從一篇題為 《糟糕的 Go 語言》 的彙編文章看到這篇博文的話,那麼我想表明的是,我很慚愧被列在這樣的名單上。Go 絕對是我使用過的最不糟糕的的編程語言。在我寫作本文時,我是想遏制我所看到的一種趨勢,那就是過度使用 Go 的一些較複雜的部分。我仍然認為 通道 Channel 可以更好,但是總體而言,Go 很棒。這就像你最喜歡的工具箱中有 這個工具;它可以有用途(甚至還可能有更多的用途),它仍然可以成為你最喜歡的工具箱!

更新 2:如果我沒有指出這項對真實問題的優秀調查,那我將是失職的:《理解 Go 中的實際並發錯誤》。這項調查的一個重要發現是...Go 通道會導致很多錯誤。

從 2010 年中後期開始,我就斷斷續續地在使用 Google 的 Go 編程語言,自 2012 年 1 月開始(在 Go 1.0 之前!),我就用 Go 為 Space Monkey 編寫了合規的產品代碼。我對 Go 的最初體驗可以追溯到我在研究 Hoare 的 通信順序進程 並發模型和 Matt MightUCombinator 研究組 下的 π-演算 時,作為我(現在已重定向)博士工作的一部分,以更好地支持多核開發。Go 就是在那時發布的(多麼巧合啊!),我當即就開始學習嘗試了。

它很快就成為了 Space Monkey 開發的核心部分。目前,我們在 Space Monkey 的生產系統有超過 42.5 萬行的純 Go 代碼( 包括我們所有的 vendored 庫中的代碼量,這將使它接近 150 萬行),所以也並不是你見過的最多的 Go 代碼,但是對於相對年輕的語言,我們是重度用戶。我們之前 寫了我們的 Go 使用情況。也開源了一些使用率很高的庫;許多人似乎是我們的 OpenSSL 綁定(比 crypto/tls 更快,但請保持 openssl 本身是最新的!)、我們的 錯誤處理庫日誌庫度量標準收集庫/zipkin 客戶端 的粉絲。我們使用 Go、我們熱愛 Go、我們認為它是目前為止我們使用過的最不糟糕的、符合我們需求的編程語言。

儘管我也不認為我能說服自己不要提及我的廣泛避免使用 goroutine-local-storage 庫 (儘管它是一個你不應該使用的魔改技巧,但它是一個漂亮的魔改),希望我的其他經歷足以證明我在解釋我故意煽動性的帖子標題之前知道我在說什麼。

等等,什麼?

如果你在大街上問一個有名的程序員,Go 有什麼特別之處? 她很可能會告訴你 Go 最出名的是 通道 Channels 和 goroutine。 Go 的理論基礎很大程度上是建立在 Hoare 的 CSP( 通信順序進程 Communicating Sequential Processes )模型上的,該模型本身令人著迷且有趣,我堅信,到目前為止,它產生的收益遠遠超過了我們的預期。

CSP(和 π-演算)都使用通信作為核心同步原語,因此 Go 會有通道是有道理的。Rob Pike 對 CSP 著迷(有充分的理由)相當深 已經有一段時間了。(當時現在)。

但是從務實的角度來看(也是 Go 引以為豪的),Go 把通道搞錯了。在這一點上,通道的實現在我的書中幾乎是一個堅實的反模式。為什麼這麼說呢?親愛的讀者,讓我細數其中的方法。

你可能最終不會只使用通道

Hoare 的 「通信順序進程」 是一種計算模型,實際上,唯一的同步原語是在通道上發送或接收的。一旦使用 互斥量 mutex 信號量 semaphore 條件變數 condition variable 、bam,你就不再處於純 CSP 領域。 Go 程序員經常通過高呼 「通過交流共享內存」 的 緩存的思想 來宣揚這種模式和哲學。

那麼,讓我們嘗試在 Go 中僅使用 CSP 編寫一個小程序!讓我們成為高分接收者。我們要做的就是跟蹤我們看到的最大的高分值。如此而已。

首先,我們將創建一個 Game 結構體。

type Game struct {
  bestScore int
  scores    chan int
}

bestScore 不會受到 互斥量 mutex 的保護!這很好,因為我們只需要一個 goroutine 來管理其狀態並通過通道來接收新的分值即可。

func (g *Game) run() {
  for score := range g.scores {
    if g.bestScore < score {
      g.bestScore = score
    }
  }
}

好的,現在我們將創建一個有用的構造函數來開始 Game

func NewGame() (g *Game) {
  g = &Game{
    bestScore: 0,
    scores:    make(chan int),
  }
  go g.run()
  return g
}

接下來,假設有人給了我們一個可以返回分數的 Player。它也可能會返回錯誤,因為可能傳入的 TCP 流可能會死掉或發生某些故障,或者玩家退出。

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
  }
}

好極了!現在我們有了一個 Game 類型,可以以線程安全的方式跟蹤 Player 獲得的最高分數。

你圓滿完成了自己的開發工作,並開始擁有客戶。你將這個遊戲伺服器公開,就取得了令人難以置信的成功!你的遊戲伺服器上也許正在創建許多遊戲。

很快,你發現人們有時會離開你的遊戲。許多遊戲不再有任何玩家在玩,但沒有任何東西可以阻止遊戲運行的循環。死掉的 (*Game).run goroutines 讓你不知所措。

挑戰: 在無需互斥量或 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()
  }
}

你想選擇哪一個?不要被欺騙了,以為通道的解決方案可以使它在更複雜的情況下更具可讀性和可理解性。 拆解 Teardown 是非常困難的。這種拆解若用 互斥量 mutex 來做那只是小菜一碟,但最困難的是只使用 Go 專用通道來解決。另外,如果有人回復說發送通道的通道更容易推理,我馬上就是感到頭疼。

重要的是,這個特殊的情況可能真的 很容易 解決,而通道有一些運行時的幫助,而 Go 沒有提供!不幸的是,就目前的情況來看,與 Go 的 CSP 版本相比,使用傳統的 同步原語 synchronization primitives 可以更好地解決很多問題,這是令人驚訝的。稍後,我們將討論 Go 可以做些什麼來簡化此案例。

練習: 還在懷疑? 試著讓上面兩種解決方案(只使用通道與只使用互斥量channel-only vs mutex-only)在一旦 bestScore 大於或等於 100 時,就停止向 Players 索要分數。繼續打開你的文本編輯器。這是一個很小的玩具問題。

這裡的總結是,如果你想做任何實際的事情,除了通道之外,你還會使用傳統的同步原語。

通道比你自己實現要慢一些

Go 如此重視 CSP 理論,我認為其中一點就是,運行時應該可以通過通道做一些殺手級的調度優化。也許通道並不總是最直接的原語,但肯定是高效且快速的,對吧?

正如 Dustin HiattTyler Treat』s post about Go 上指出的那樣,

在幕後,通道使用鎖來序列化訪問並提供線程安全性。 因此,通過使用通道同步對內存的訪問,你實際上就是在使用鎖。 被包裝在線程安全隊列中的鎖。 那麼,與僅僅使用標準庫 sync 包中的互斥量相比,Go 的花式鎖又如何呢? 以下數字是通過使用 Go 的內置基準測試功能,對它們的單個集合連續調用 Put 得出的。

> BenchmarkSimpleSet-8 3000000 391 ns/op
> BenchmarkSimpleChannelSet-8 1000000 1699 ns/o
>

無緩衝通道的情況與此類似,甚至是在爭用而不是串列運行的情況下執行相同的測試。

也許 Go 調度器會有所改進,但與此同時,良好的舊互斥量和條件變數是非常好、高效且快速。如果你想要提高性能,請使用久經考驗的方法。

通道與其他並發原語組合不佳

好的,希望我已經說服了你,有時候,你至少還會與除了通道之外的原語進行交互。標準庫似乎顯然更喜歡傳統的同步原語而不是通道。

你猜怎麼著,正確地將通道與互斥量和條件變數一起使用,其實是有一定的挑戰性的。

關於通道的一個有趣的事情是,通道發送是同步的,這在 CSP 中是有很大意義的。通道發送和通道接收的目的是為了成為同步屏蔽,發送和接收應該發生在同一個虛擬時間。如果你是在執行良好的 CSP 領域,那就太好了。

實事求是地說,Go 通道也有多種緩衝方式。你可以分配一個固定的空間來考慮可能的緩衝,以便發送和接收是不同的事件,但緩衝區大小是有上限的。Go 並沒有提供一種方法來讓你擁有任意大小的緩衝區 —— 你必須提前分配緩衝區大小。 這很好,我在郵件列表上看到有人在爭論,因為無論如何內存都是有限的

What。

這是個糟糕的答案。有各種各樣的理由來使用一個任意緩衝的通道。如果我們事先知道所有的事情,為什麼還要使用 malloc 呢?

沒有任意緩衝的通道意味著在 任何 通道上的幼稚發送可能會隨時阻塞。你想在一個通道上發送,並在互斥下更新其他一些記賬嗎?小心!你的通道發送可能被阻塞!

// ...
s.mtx.Lock()
// ...
s.ch <- val // might block!
s.mtx.Unlock()
// ...

這是哲學家晚餐大戰的秘訣。如果你使用了鎖,則應該迅速更新狀態並釋放它,並且儘可能不要在鎖下做任何阻塞。

有一種方法可以在 Go 中的通道上進行非阻塞發送,但這不是默認行為。假設我們有一個通道 ch := make(chan int),我們希望在其上無阻塞地發送值 1。以下是在不阻塞的情況下你必須要做的最小量的輸入:

select {
case ch <- 1: // it sent
default:      // it didn&apos;t
}

對於剛入門的 Go程序員來說,這並不是自然而然就能想到的事情。

綜上所述,因為通道上的很多操作都會阻塞,所以需要對哲學家及其就餐仔細推理,才能在互斥量的保護下,成功地將通道操作與之並列使用,而不會造成死鎖。

嚴格來說,回調更強大,不需要不必要的 goroutines

每當 API 使用通道時,或者每當我指出通道使某些事情變得困難時,總會有人會指出我應該啟動一個 goroutine 來讀取該通道,並在讀取該通道時進行所需的任何轉換或修復。

呃,不。如果我的代碼位於熱路徑中怎麼辦?需要通道的實例很少,如果你的 API 可以設計為使用 互斥量 mutexes 信號量 semaphores 回調 callbacks ,而不使用額外的 goroutine (因為所有事件邊緣都是由 API 事件觸發的),那麼使用通道會迫使我在資源使用中添加另一個內存分配堆棧。是的,goroutine 比線程輕得多,但更輕量並不意味著是最輕量。

正如我以前 在一篇關於使用通道的文章的評論中爭論過的(呵呵,互聯網),如果你使用回調而不是通道,你的 API 總是 可以更通用,總是 更靈活,而且佔用的資源也會大大減少。「總是」 是一個可怕的詞,但我在這裡是認真的。有證據級的東西在進行。

如果有人向你提供了一個基於回調的 API,而你需要一個通道,你可以提供一個回調,在通道上發送,開銷不大,靈活性十足。

另一方面,如果有人提供了一個基於通道的 API 給你,而你需要一個回調,你必須啟動一個 goroutine 來讀取通道,並且 你必須希望當你完成讀取時,沒有人試圖在通道上發送更多的東西,這樣你就會導致阻塞的 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() 通道觸發時記錄相應的錯誤。你該怎麼辦?如果你沒有在通道中選擇的好地方,則必須啟動 goroutine 進行處理:

go func() {
  <-ctx.Done()
  logger.Errorf("canceled: %v", ctx.Err())
}()

如果 ctx 在不關閉返回 Done() 通道的情況下被垃圾回收怎麼辦?哎呀!這正是一個 goroutine 泄露!

現在假設我們更改了 Done 的簽名:

// Done calls cb when this work unit should be canceled.
Done(cb func())

首先,現在日誌記錄非常容易。看看:ctx.Done(func() { log.Errorf ("canceled:%v", ctx.Err()) })。但是假設你確實需要某些選擇行為。你可以這樣調用它:

ch := make(chan struct{})
ctx.Done(func() { close(ch) })

瞧!通過使用回調,不會失去表現力。 ch 的工作方式類似於用於返回的通道 Done(),在日誌記錄的情況下,我們不需要啟動整個新堆棧。我必須保留堆棧跟蹤信息(如果我們的日誌包傾向於使用它們);我必須避免將其他堆棧分配和另一個 goroutine 分配給調度程序。

下次你使用通道時,問問你自己,如果你用互斥量和條件變數代替,是否可以消除一些 goroutine ? 如果答案是肯定的,那麼修改這些代碼將更加有效。而且,如果你試圖使用通道只是為了在集合中使用 range 關鍵字,那麼我將不得不請你放下鍵盤,或者只是回去編寫 Python 書籍。

more like Zooey De-channel, amirite

通道 API 不一致,只是 cray-cray

在通道已關閉的情況下,執行關閉或發送消息將會引發 panics!為什麼呢? 如果想要關閉通道,你需要在外部同步它的關閉狀態(使用互斥量等,這些互斥量的組合不是很好!),這樣其他寫入者才不會寫入或關閉已關閉的通道,或者只是向前沖,關閉或寫入已關閉的通道,並期望你必須恢復所有引發的 panics。

這是多麼怪異的行為。 Go 中幾乎所有其他操作都有避免 panic 的方法(例如,類型斷言具有 , ok = 模式),但是對於通道,你只能自己動手處理它。

好吧,所以當發送失敗時,通道會出現 panic。我想這是有一定道理的。但是,與幾乎所有其他帶有 nil 值的東西不同,發送到 nil 通道不會引發 panic。相反,它將永遠阻塞!這很違反直覺。這可能是有用的行為,就像在你的除草器上附加一個開罐器,可能有用(在 Skymall 可以找到)一樣,但這肯定是意想不到的。與 nil 映射(執行隱式指針解除引用),nil 介面(隱式指針解除引用),未經檢查的類型斷言以及其他所有類型交互不同,nil 通道表現出實際的通道行為,就好像為該操作實例化了一個全新的通道一樣。

接收的情況稍微好一點。在已關閉的通道上執行接收會發生什麼?好吧,那會是有效操作——你將得到一個零值。好吧,我想這是有道理的。獎勵!接收允許你在收到值時進行 , ok = 樣式的檢查,以確定通道是否打開。謝天謝地,我們在這裡得到了 , ok =

但是,如果你從 nil 渠道接收會發生什麼呢? 也是永遠阻塞! 耶!不要試圖利用這樣一個事實:如果你關閉了通道,那麼你的通道是 nil!

通道有什麼好處?

當然,通道對於某些事情是有好處的(畢竟它們是一個通用容器),有些事情你只能用它們來做(比如 select)。

它們是另一種特殊情況下的通用數據結構

Go 程序員已經習慣於對泛型的爭論,以至於我一提起這個詞就能感覺到 PTSD(創傷後應激障礙)的到來。我不是來談論這件事的,所以擦擦額頭上的汗,讓我們繼續前進吧。

無論你對泛型的看法是什麼,Go 的映射、切片和通道都是支持泛型元素類型的數據結構,因為它們已經被特殊封裝到語言中了。

在一種不允許你編寫自己的泛型容器的語言中,任何允許你更好地管理事物集合的東西都是有價值的。在這裡,通道是一個支持任意值類型的線程安全數據結構。

所以這很有用!我想這可以省去一些陳詞濫調。

我很難把這算作是通道的勝利。

Select

使用通道可以做的主要事情是 select 語句。在這裡,你可以等待固定數量的事件輸入。它有點像 epoll,但你必須預先知道要等待多少個套接字。

這是真正有用的語言功能。如果不是 select,通道將被徹底清洗。但是我的天吶,讓我告訴你,第一次決定可能需要在多個事物中選擇,但是你不知道有多少項,因此必須使用 reflect.Select

通道如何才能更好?

很難說 Go 語言團隊可以為 Go 2.0 做的最具戰術意義的事情是什麼(Go 1.0 兼容性保證很好,但是很費勁),但這並不能阻止我提出一些建議。

在條件變數上的 Select !

我們可以不需要通道!這是我提議我們擺脫一些「 聖牛 sacred cows 」(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) {
  // 我們不會直接保留對遊戲的引用,因為這樣我們就會保留著通道的發送端。
  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 中的評論暗示著有一把相當大的槍瞄準了你的腳,但至少現在這個問題可以很容易地解決了,而以前確實不是這樣。此外,一個聰明的編譯器可能會做出適當的證明,以減少這種腳槍造成的損害。

其他小問題

  • Dup 通道嗎? —— 如果我們可以在通道上使用等效於 dup 的系統調用,那麼我們也可以很容易地解決多生產者問題。 每個生產者可以關閉自己的 dup 版通道,而不會破壞其他生產者。
  • 修復通道 API! —— 關閉不是冪等的嗎? 在已關閉的通道上發送信息引起的 panics 沒有辦法避免嗎? 啊!
  • 任意緩衝的通道 —— 如果我們可以創建沒有固定的緩衝區大小限制的緩衝通道,那麼我們可以創建非阻塞的通道。

那我們該怎麼向大家介紹 Go 呢?

如果你還沒有,請看看我目前最喜歡的編程文章:《你的函數是什麼顏色》。雖然不是專門針對 Go,但這篇博文比我更有說服力地闡述了為什麼 goroutines 是 Go 最好的特性(這也是 Go 在某些應用程序中優於 Rust 的方式之一)。

如果你還在使用這樣的一種編程語言寫代碼,它強迫你使用類似 yield 關鍵字來獲得高性能、並發性或事件驅動的模型,那麼你就是活在過去,不管你或其他人是否知道這一點。到目前為止,Go 是我所見過的實現 M:N 線程模型(非 1:1 )的語言中最好的入門者之一,而且這種模型非常強大。

所以,跟大家說說 goroutines 吧。

如果非要我選擇 Go 的另一個主要特性,那就是介面。靜態類型的 鴨子模型 duck typing 使得擴展、使用你自己或他人的項目變得如此有趣而令人驚奇,這也許值得我改天再寫一組完全不同的文章來介紹它。

所以…

我一直看到人們爭先恐後衝進 Go,渴望充分利用通道來發揮其全部潛力。這是我對你的建議。

夠了!

當你在編寫 API 和介面時,儘管「絕不」的建議可能很糟糕,但我非常肯定,通道從來沒有什麼時候好過,我用過的每一個使用通道的 Go API,最後都不得不與之抗爭。我從來沒有想過「哦 太好了,這裡是一個通道;」它總是被一些變體取代,這是什麼新鮮的地獄?

所以,請在適當的地方,並且只在適當的地方使用通道。

在我使用的所有 Go 代碼中,我可以用一隻手數出有多少次通道真的是最好的選擇。有時候是這樣的。那很好!那就用它們吧。但除此之外,就不要再使用了。

特別感謝我的校對讀者 Jeff Wendling、Andrew HardingGeorge ShankTyler Treat 提供的寶貴反饋。

如果你想和我們一起用 Go 在 Space Monkey 項目工作,請給我打個招呼

via: https://www.jtolio.com/2016/03/go-channels-are-bad-and-you-should-feel-bad

作者:jtolio.com 選題:lujun9972 譯者:gxlct008 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國