Golang中errgroup的常見誤用詳解
errgroup想必稍有經驗的golang程序員都應該聽說過,實際項目中用過的也應該不在少數(shù)。它和sync.WaitGroup
類似,都可以發(fā)起執(zhí)行并等待一組協(xié)程直到所有協(xié)程運行結束。除此之外errgroup還可以在協(xié)程出錯時取消當前的context,以及它還能控制可運行的協(xié)程的數(shù)量。
但在日常的代碼review時我注意到了幾個比較常見的問題,這些問題有的無傷大雅最多只會造成一些性能損失,有的則會導致資源泄露甚至是死鎖崩潰。
這里對這些比較典型的誤用做下記錄。
多余的context嵌套
先說個不是很常見但我還是遇到過兩三次的不太妥當?shù)挠梅ā?/p>
我們知道errgroup在協(xié)程返回錯誤的時候會取消掉創(chuàng)建時傳入的context,這是為了能讓同組的其他協(xié)程知道有錯誤發(fā)生應該盡快退出執(zhí)行。
所以errgroup使用的context應該是派生于當前上下文的新的context,這樣才不會讓可能的取消操作影響到errgroup之外的范圍。
因此第一個常見誤用出現(xiàn)了:
func DoWork(ctx context.Context) { errCtx, cancel := context.WithCancel(ctx) defer cancel() group, errCtx := errgroup.WithContext(ctx) ... }
誤用在哪呢?答案是context會自動幫我們派生出新的context,除了需要設置超時一般不需要再次額外封裝,看源代碼:
// https://github.com/golang/sync/blob/master/errgroup/errgroup.go // WithContext returns a new Group and an associated Context derived from ctx. // // The derived Context is canceled the first time a function passed to Go // returns a non-nil error or the first time Wait returns, whichever occurs // first. func WithContext(ctx context.Context) (*Group, context.Context) { ctx, cancel := withCancelCause(ctx) return &Group{cancel: cancel}, ctx } // https://github.com/golang/sync/blob/master/errgroup/go120.go func withCancelCause(parent context.Context) (context.Context, func(error)) { return context.WithCancelCause(parent) }
多于的嵌套會浪費內存,以及會對性能帶來負面影響,尤其是需要從context里取出某些value的時候,因為取value是對一層層嵌套的context遞歸查找的,嵌套層數(shù)越多查找就有可能越慢。
不過前面也說到了,有一種情況是允許的,那就是對整個errgroup所有的協(xié)程設置超時:
func DoWork(ctx context.Context) { errCtx, cancel := context.WithTimeout(ctx, 10 * time.Second) defer cancel() group, errCtx := errgroup.WithContext(ctx) ... }
目前想設置超時只能這樣做,所以這種算是特例。
Wait返回的時機
第二種誤用比第一種要常見些。主要是對errgroup的行為理解上有誤解。
這種誤解經常表現(xiàn)為:如果協(xié)程返回錯誤或者ctx的超時被觸發(fā),Wait
方法就會立即返回。
這并不是事實。
先來看看Wait
的文檔怎么說的:
Wait blocks until all function calls from the Go method have returned, then returns the first non-nil error (if any) from them.
Wait
需要等到所有goroutine返回后它才會返回。哪怕超時了,context取消了也一樣,需要先等所有協(xié)程退出。再來看代碼:
// https://github.com/golang/sync/blob/master/errgroup/errgroup.go func (g *Group) Wait() error { g.wg.Wait() if g.cancel != nil { g.cancel(g.err) } return g.err }
可以看到確實需要先等所有協(xié)程返回。如果你觀察比較敏銳的話,其實能發(fā)現(xiàn)errgroup會對協(xié)程做包裝,會不會包裝的代碼里有什么辦法提前中止協(xié)程的執(zhí)行呢?還是來看代碼:
// https://github.com/golang/sync/blob/master/errgroup/errgroup.go func (g *Group) Go(f func() error) { // 檢查當前協(xié)程是否可運行的代碼,先忽略 g.wg.Add(1) go func() { defer g.done() // 重點在這 if err := f(); err != nil { g.errOnce.Do(func() { g.err = err if g.cancel != nil { g.cancel(g.err) } }) } }() }
注意那個defer,這意味著done只有在包裝的函數(shù)運行結束(在你自己的函數(shù)f運行完并設置了error以及取消了ctx之后)時才會執(zhí)行。
如果你自己的函數(shù)里不檢查超時和上下文是否被取消,那leak和卡死問題就要找上門來了,比如下面這樣的:
func main() { errCtx, cancel := context.WithTimeout(context.Background(), 1 * time.Second) defer cancel() group, errCtx := errgroup.WithContext(errCtx) group.Go(func () error { time.Sleep(10 * time.Second) fmt.Println("running") return nil }) group.Go(func () error { return errors.New("error") }) fmt.Println(group.Wait()) }
猜猜運行結果和執(zhí)行時間。答案是running\nerror\n
,運行需要10秒以上。
這種誤用也很好識別,只要傳給Go
方法的函數(shù)里沒有好好處理errCtx
,那多半是有問題的。
不過要說句公道話,Go
的參數(shù)形式不符合一般使用context的慣例,Wait
的行為和其他能自主取消線程執(zhí)行的語言也不一樣造成了誤用,語言和接口設計得背一半鍋不能全賴用它的程序員。
SetLimit和死鎖
這種就更常見了,尤其發(fā)生在把errgroup當成普通協(xié)程池用的時候。
先來我最愛的猜謎游戲,下面的代碼運行結果是什么?
func main() { group, _ := errgroup.WithContext(context.Background()) group.SetLimit(2) // 想法:只允許2個協(xié)程同時運行,但多個任務提交到“協(xié)程池” group.Go(func () error { fmt.Println("running 1") // 運行子任務 group.Go(func () error { fmt.Println("sub running 1") return nil }) group.Go(func () error { fmt.Println("sub running 2") return nil }) return nil }) group.Go(func () error { fmt.Println("running 2") // 運行子任務 group.Go(func () error { fmt.Println("sub running 3") return nil }) group.Go(func () error { fmt.Println("sub running 4") return nil }) return nil }) fmt.Println(group.Wait()) }
答案是會死鎖panic。而且是100%觸發(fā)。
我會詳細的解釋這是為什么,但在之前我要說一個重要的知識點:
SetLimit
設置的不是同時在運行的協(xié)程數(shù)量,而是設置errgroup內最多同時能持有多少個協(xié)程,errgroup持有的協(xié)程可以在運行也可以在等待運行。
如果每個running的sub running只有一個,那么有小概率不會死鎖,所以我特地每組創(chuàng)建了兩個,原因沒那么復雜,看來后面的解釋之后可以自行推理。
下面來解釋,首先看SetLimit
的代碼,一切是從這開始的:
// https://github.com/golang/sync/blob/master/errgroup/errgroup.go // SetLimit limits the number of active goroutines in this group to at most n. // A negative value indicates no limit. // // Any subsequent call to the Go method will block until it can add an active // goroutine without exceeding the configured limit. // // The limit must not be modified while any goroutines in the group are active. func (g *Group) SetLimit(n int) { if n < 0 { g.sem = nil return } if len(g.sem) != 0 { panic(fmt.Errorf("errgroup: modify limit while %v goroutines in the group are still active", len(g.sem))) } g.sem = make(chan token, n) }
g.sem
是chan strcut{}
。做的事很簡單,如果參數(shù)大于0就按參數(shù)初始化一個長度為n的chan給g.sem
,小于0就清空g.sem
。如果你經驗比較豐富的話,已經可以看出來這是一個簡單的ticket pool
模式了,這個模式在grpc里也有應用。
ticket pool
模式的原理是設置一個固定大小為n的空chan,然后協(xié)程要運行的時候向這個chan寫入數(shù)據(jù),協(xié)程運行結束的時候從chan里把寫入的數(shù)據(jù)讀出(可能會讀到別人寫進去的,但只要遵循這個寫入讀出的順序就沒問題)。如果chan的寫入阻塞了,就說明已經有n個協(xié)程在運行了,新的協(xié)程需要等到有協(xié)程執(zhí)行完并讀出數(shù)據(jù)后才能繼續(xù)執(zhí)行;正常情況下讀出操作不會被阻塞。這個是限制goroutine數(shù)量的最常見的手段之一。根據(jù)寫入操作實在協(xié)程內部還是發(fā)起協(xié)程的調用者那里進行,這個模式還能分別控制“最大同時運行的goroutine數(shù)量”或“goroutine總數(shù)量”。其中goroutine的總數(shù)量 = 在運行的goroutine數(shù)量 + 其他等待運行goroutine的數(shù)量
。
而errgroup屬于后者。還記得Go
的代碼里我注釋掉的那部分吧,現(xiàn)在可以看了:
// https://github.com/golang/sync/blob/master/errgroup/errgroup.go func (g *Group) Go(f func() error) { if g.sem != nil { g.sem <- token{} // token是struct{} } g.wg.Add(1) go func() { defer g.done() if err := f(); err != nil { // 設置錯誤值 } }() } func (g *Group) done() { if g.sem != nil { <-g.sem // 從ticket pool里讀出 } g.wg.Done() }
進入Go
的時候并沒有啟動協(xié)程,而是先檢查sem
,如果有設置limit,就需要按操作ticket pool的流程先寫入數(shù)據(jù)。寫入成功才會創(chuàng)建協(xié)程,協(xié)程運行結束后把數(shù)據(jù)讀出。這樣限制了errgroup最大可以持有的協(xié)程數(shù)量,因為超過數(shù)量限制會阻塞住不創(chuàng)建新的協(xié)程。
在Go
完成sem的寫入并執(zhí)行go語句之前,errgroup并沒有“持有”go語句創(chuàng)建的這個協(xié)程。協(xié)程運行結束并把sem的數(shù)據(jù)讀出后,group將不會繼續(xù)“持有”這個協(xié)程。
問題就出在寫入那里。假設調度器是這樣運行我們的猜謎代碼的:
- 先啟動running 1的協(xié)程,sem空位有2個,正常運行,running 1運行結束后它寫入的數(shù)據(jù)才會被讀出
- 接著啟動running 2,sem還剩一個空位,沒問題,running 2運行結束后它寫入的數(shù)據(jù)才會被讀出
- running 2先被執(zhí)行,于是準備創(chuàng)建sub running 3的協(xié)程
- 這時sem沒空位了,創(chuàng)建sub running 3的
Go
阻塞 - 調度器發(fā)現(xiàn)running 2被阻塞了,于是讓running 1執(zhí)行(假設而已,多核處理器上很可能是同時運行的)
- running 1輸出后準備創(chuàng)建sub running 1的協(xié)程
- sem還是滿的,
Go
又阻塞了 - 調度器發(fā)現(xiàn)running 1和running 2都阻塞了,于是只能讓main goroutine執(zhí)行(這里忽略runtime自己的協(xié)程,因為不影響死鎖檢測結果)
- main阻塞在
Wait
上,所有其他協(xié)程執(zhí)行完才能繼續(xù)執(zhí)行 - 沒有能繼續(xù)運行下去的協(xié)程,全都阻塞了(注意是阻塞不是sleep),死鎖檢測發(fā)現(xiàn)這種情況,panic
我知道實際執(zhí)行順序肯定不一樣,但死鎖的原因一樣的:因為之前的協(xié)程沒有讓出ticket pool,后面的子任務需要向pool寫入,而前面占有pool的協(xié)程需要等子任務執(zhí)行完才會讓出pool。這是一個典型的循環(huán)依賴導致的死鎖,誘因是同一個errgroup的嵌套使用。
是什么導致了你踩坑呢?最大的可能是文檔里那個“active”。這個詞太模糊了,你可以發(fā)現(xiàn)它即能代指running又能代指runnable,還能兩個同時代指。這里因為下面還有一段話,所以可以根據(jù)上下文估摸著猜出active想代指的是所有被創(chuàng)建出來的協(xié)程不管它們在不在運行。但如果你只看了第一段話就先入為主放心大膽用的話,坑就來了。這樣的詞缺少足夠的上下文時連母語者都會覺得有二義性,更何況我們這些作為第二語言甚至第三語言的人。
而errgroup選擇限制goroutine總數(shù)量也是有原因的:只限制同時運行的goroutine的數(shù)量就沒法限制協(xié)程的總數(shù)量,協(xié)程雖然很輕量,但還是要占用內存以及花費cpu資源來調度的,不受控制很可能會產生災難性后果,比如一個不當心在循環(huán)里創(chuàng)建了數(shù)百萬個協(xié)程導致嚴重的內存占用和調度壓力,控制了總數(shù)量這類問題就可以避免。
幸運的是,這個誤用也很好識別,但凡有嵌套使用同一個errgroup的時候,就要警報大作了。
更幸運的是,如果你沒有嵌套調用,那么這個SetLimit
不管設置成哪個數(shù)字,都能正常限制頂層的goroutine的數(shù)量(或者不做限制),它不能限制的是從頂層協(xié)程里嵌套調用派生出的子協(xié)程,只要不嵌套調用同一個group,什么問題的不會有。
前面兩種誤用都是該避免的,然而嵌套的errgroup雖然不多見但確實有用處,所以我也會提供寫簡單的解決方案以供參考。
第一種是設置一個足夠的limit數(shù)值,聰明人應該發(fā)現(xiàn)了,如果把limit設置成希望group里同時存在的協(xié)程的總數(shù)量(頂層+所有嵌套派生的),問題就能避免。這沒錯,但我不推薦,兩點原因:
- 設置成總數(shù)后起不到限制同時運行的協(xié)程的數(shù)量,在go里控制同時運行的協(xié)程數(shù)量是個很麻煩的事,limit通常只能起到“上限”的作用,但如果上限設置大了就容易出現(xiàn)問題。比如你的系統(tǒng)只能同時運行3個協(xié)程,你還有別的任務占用了一個協(xié)程在運行,為了避免死鎖你設置了limit為4,這時候資源搶占和協(xié)程調度延遲都會明顯上升,出現(xiàn)這類情況你的系統(tǒng)就離崩潰只有一步之遙了。
- 算這個數(shù)量很麻煩,上面的例子你可以很簡單算出是4,如果我再套一層或者加上幾個可以跳過
Go
調用的條件分支呢?而且limit設置多了是起不到限制goroutine數(shù)量的作用的,設少了會死鎖。 - limit多半是個寫死的常量或者干脆是魔數(shù),那么下次協(xié)程的邏輯改了這個數(shù)字多半得跟著改,如果你算錯了或者忘記改了,那么你就慘了,死鎖就像個地雷一樣埋下了。
綜上,你應該用第二種方法:永遠不要嵌套使用同一個errgroup,真有嵌套需求也應該使用新的errgroup實例,這樣可以避免死鎖,也最符合當前需求的語義:
func main() { group, errCtx := errgroup.WithContext(context.Background()) group.SetLimit(1) // 想法:只允許2個協(xié)程同時運行,但多個任務提交到“協(xié)程池” group.Go(func () error { fmt.Println("running 1") // 運行子任務 // 新建一個errgroup,上下文使用外層group的 subGroup, _ := errgroup.WithContext(errCtx) subGroup.SetLimit(1) subGroup.Go(func () error { fmt.Println("sub running 1") return nil }) subGroup.Go(func () error { fmt.Println("sub running 2") return nil }) fmt.Println(subGroup.Wait()) return nil }) group.Go(func () error { fmt.Println("running 2") // 運行子任務 subGroup, _ := errgroup.WithContext(errCtx) subGroup.SetLimit(1) subGroup.Go(func () error { fmt.Println("sub running 3") return nil }) subGroup.Go(func () error { fmt.Println("sub running 4") return nil }) fmt.Println(subGroup.Wait()) return nil }) fmt.Println(group.Wait()) }
是的,現(xiàn)在所有l(wèi)imit設置成1也不會死鎖。因為沒有嵌套調用,因此也沒有資源間的循環(huán)依賴了。
當然還有終極方案:別把errgroup當成協(xié)程池,如果你有復雜功能依賴于協(xié)程池找個功能全面的真正的協(xié)程池比如ants之類的用。
對了。你問SetLimit
傳0進去會發(fā)生什么,那當然是直接死鎖了。這也符合語義,因為你的group里不能有任何協(xié)程,這時候再調Go
當然是不對的,死鎖panic也是應該的。所以傳0進去導致死鎖這不算坑,也算不上誤用。
總結
總結下上面三個誤用:
- 傳遞有多余嵌套的context給errgroup
- 在加入errgroup的協(xié)程里沒有正確處理context取消和超時
- 嵌套使用同一個errgroup
已有的靜態(tài)分析工具不是很能識別這類問題,要么自己寫個能識別的,要么只能靠review把關了。
比較大眾的觀點認為go簡單易用,但實際上并不總是如此,有句話叫“Simple is not Easy”,go的使用者需要時刻為“大道至簡”付出相應的代價。
以上就是Golang中errgroup的常見誤用詳解的詳細內容,更多關于Go errgroup的資料請關注腳本之家其它相關文章!
相關文章
Golang通過SSH執(zhí)行交換機操作實現(xiàn)
這篇文章主要介紹了Golang通過SSH執(zhí)行交換機操作實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2020-06-06