發現 conc 併發庫一個有趣的問題

圖片拍攝於 2022 年 02 月 04 日 衢州開化老家 下週回家了

上週看到一個新庫 conc,

better structured concurrency for go.

這個庫的目標是:

我們一條條細說。

Make it harder to leak goroutines

goroutine 泄漏還是很常見的。

日常我們使用 go 的時候直接 go func 開啓一個 goroutine,寫上對應的邏輯,g 會進入到某個 p 的本地隊列,最終由 p 綁定的 m 執行這個 goroutine。

你能保證一個 goroutine 在某個時刻一定會結束它的生命週期嗎?

搜了下著名開源項目 etcd,goroutine leak 還真不少。

看其中一個簡單的泄漏 bug。

done 是一個無緩衝的 chan,一開始接收動作在最下面,因爲中間還有一些沒展開的代碼可能會導致程序不會執行到 <-done,然後 goroutine 就會發生泄漏。

解決方法就是通過 defer 保證一定會執行 <-done。

那麼 conc 是如何做的?

conc 的理念是程序中的每一個 goroutine 由一個 owner 創建,歸屬於 owner。一個 owner 確保它擁有的所有 goroutine 正常退出,這裏的 owner 也就是 conc.WaitGroup。ps: 這個結構是不是超級熟悉。

但是這真的能像他說的那樣 Make it harder to leak goroutines 嗎?

goroutine 的泄漏問題取決於用戶對 goroutine 能正確退出的邏輯保證,和你如何封裝沒關係吧?

在我看來 conc 和標準庫的 sync.WaitGroup 一樣,只是等待所有 goroutine 執行完畢,用於檢測到這個行爲。

要是 goroutine 裏面含有泄漏的 bug,該泄漏還得泄漏,Wait 該等待還得老實等待。

Handle panics gracefully

如果直接使用 go func, 那麼可能每一個 goroutine 都得寫上 recover,所以一般我們在使用 goroutine 的時候,都是自己封裝一個 GoSafe 函數。

這樣就可以在裏面統一捕獲 panic,然後打包調用棧一些信息,進一步處理。

在 conc 中,每個 goroutine 有 owner 概念,所以是由 owner 捕獲 goroutine 的 panic。

只會記錄第一個 panic 的 goroutine 堆棧信息。然後操作 Wait 的時候,

超級粗暴。當 conc.WaitGroup 裏面任何一個 goroutine 發生 panic,調用 wg.Wait() 的時候就會 panic,把 goroutine panic 的堆棧信息作爲 panic 的值。

寫這篇文章的時候看到他們在 issue 上討論添加一個類似 WaitSafe() 函數 [1]

Make concurrent code easier to read

這個還是節省了一些工作的,作者給了幾個例子。

上面我們看到的 WaitGroup,不再需要用戶執行 Add 和 Done 操作了。同時內部捕獲 panic,雖然處理有點粗暴。

除此之外,控制 goroutine 數量來併發處理批量數據的例子。

可以看出,確實省了很多的操作,其他例子可以自行查看。

上面我們說到 pool,其實就是一個併發執行任務的 worker 池,之前文章也介紹過這種模式。

conc 裏面有幾個類型的 pool:ContextPool,ErrorPool,ResultPool,ResultContextPool,ResultErrorPool。

它們都基於最基礎的 Pool 結構。

limiter 就是一個很簡單的用 chan 控制 worker goroutine 數量。

核心邏輯也很簡單,

如果沒有設置 limiter,那麼優先找空閒的 worker。否則就創建一個新 worker,然後投遞任務進去。

設置了 limiter,達到了 limter worker 數量上限,那就只能把任務投遞給空閒的 worker,沒有空閒就阻塞等着。

如果沒有達到上限,空閒 worker 也存在,那就由 select 隨機選擇。否則的話就創建一個新的 worker。

彩蛋

看代碼的時候發現這裏面有個問題,

這個函數會返回一個新的 Pool,上面我說過 limiter 是一個 chan 結構,在 go 中 chan 是一個引用類型,所以這裏對 limiter 就是一個淺拷貝。

因此,下面這段代碼,

正因爲這種 “特性”, 如果我們寫了下面的代碼,

ep.Go 裏面的邏輯永遠都沒機會執行。

原因就在於,第一個 loop 創建的時候限制了 goroutine 數量。然後執行兩次 p.Go,這樣就會創建兩個只屬於 p 的 workers,同時也讓 limiter 到達限制數。

當 ep 想要執行 ep.Go 的時候,只能執行 p.tasks <- f,但是這時候 ep 還沒有機會創建屬於自己的 worker,所以會阻塞到死。

我提了一個 pr[2],其實就是把上面的淺拷貝換成深拷貝。

但是作者回復說,

Currently calling any configuration methods on the pool after calling pool.Go() is unsupported because the configuration methods take ownership of and mutate the pool. This might not be ideal though since ownership can't actually be enforced with Go. It would be less of a footgun to just return a fully copy each time.

我理解的意思就是不支持我這麼玩,簡單的說不支持在執行 pool.Go() 後調用這些操作😂。

[1]https://github.com/sourcegraph/conc/issues/29

[2]https://github.com/sourcegraph/conc/issues/43

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/z9mHIOJT0VyElc0GUOFlaA