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