go 怎么等待所有的协程完成_理解真实世界中 Go 的并发 BUG
有幾個學生研究歸納了go編程中的并發bugs,發表了一篇(英文)論文:《Understanding Real-World Concurrency Bugs in Go》。為你下載好了 PDF,關注公眾號 Go語言中文網,回復 gostudy 獲取。
在此做一個筆記,便于查閱。
文章以六個產品級go應用作為研究對象:Docker、Kubernetes、etcd、gRPC、CockroachDB、BoltDB,總共研究了這些應用中的171個bug,研究它們的根本原因,并重現這些bugs,以及檢查它們的修復補丁。最后用兩個現有go并發bug檢測器測試了這些bug。
文章試圖回答一個問題:對于兩種線程/協程間通信機制,消息傳遞機制和共享內存機制,哪個更不容易出錯?
文章從兩個維度對bug進行了分類,bug原因(對共享內存的誤用、對消息傳遞的誤用)和bug表現(阻塞性bug、非阻塞性bug)。
研究結果及提交日志可以在以下地址查閱:https://github.com/system-pclub/go-concurrency-bugs
many concurrency bugs are caused by the mixed usage of message passing and other new semantics and new libraries in Go, which can easily be overlooked but hard to detect.
背景
使用共享內存實現同步
Go支持協程間共享內存,提供了多種傳統的同步手段,如鎖(Mutex)、讀寫鎖(RWMutex)、條件變量(Cond)、原子讀寫(atomic)。go的RWMutex實現與C中的pthread_rwlock_t不同,go中的寫鎖請求優先級高于讀鎖。
go中還有一些新特性,Once保證一個函數只執行一次:使用 Once.Do(f) 方法,即使這一語句被多個協程調用了多次,也只有第一次的時候,函數f會被執行。
和C中的pthread_join類似,go使用WaitGroup來實現等待協程對其他協程的等待。
使用消息傳遞實現同步
channel(chan)是go的新特性,學習go語言編程的都應該熟悉了。channel分有緩沖和無緩沖兩種(buffered and unbuffered)。
使用select可以從多路channel中進行選擇。當有多路case有效時,select會從中隨機選擇一個去執行,這種隨機性可能會造成bug。
Go引入了幾種新機制來簡化協程間的交互,如用context攜帶數據傳遞在不同協程之間,還有Pipe可在讀協程和寫協程之間傳遞流式數據。這兩種都是新的消息傳遞機制,不注意的話可能引起新的并發bug。
Go并發模型
在研究并發bug前,文章先研究了go中的并發模型。
首先統計了那幾個應用中創建gorutine的(靜態)語句數量(位置數量),如下表:
img文章覺得喜歡用匿名函數創建gorutine的多些(除了kubernetes和BoltDB),另外還發現C語言版gRPC比go語言版更少創建線程語句。
然后,文章還統計了各種同步機制的使用比例,如下圖:
img從中可以看出,共享內存機制的鎖還是用得最多啊!
同時,這些機制的使用比例,隨著項目時間推進,是否有什么變化趨勢的?似乎沒有明顯變化,如下截圖:
imgBug分類
分類如下:
img從數值看,阻塞性bug和非阻塞性bug出現數量差不多。
(筆者注:對于原因而言,從數值上看使用共享內存的造成bug比較多,但是這里只統計了絕對值,沒有和前面共享機制的使用量結合起來考慮比例,似乎不大妥當。)
對于這些bug,文章作者使用相應有bug的版本,根據bug報告中的操作嘗試重現這些bug,結果發現并發bug是很難重現的。從而這些bug存在時間都比較長,而一旦被發現,一般會比較快地得到解決。bug生存時間統計如下:
imgBug原因分析
1、阻塞性bug
統計如下:
img具體分析
(1)對共享內存保護的失誤:
Mutex:28個阻塞性bug由對鎖的不當使用造成,包括重復鎖、以沖突的順序申請鎖、忘記解鎖*。這些bug都是傳統bug,文章覺得傳統的死鎖檢測算法應該能檢測出這類bug。
RWMutex:前面提到過,go中的寫鎖優先級高。這種實現機制可以造成如下bug:協程A對同一個RWMutex申請兩次讀鎖,但在這兩次申請中間,協程B申請寫鎖。此時,由于A已經持有了一個讀鎖,而寫鎖又是排他性的,所以B被阻塞。然后,A第二次申請讀鎖時,由于B的寫鎖優先級高,所以A的讀鎖必須排在B的寫鎖請求之后,導致A被阻塞。從而發生了死鎖。
統計中有5個bug是由這個原因造成。由于在C語言中這種情況不會造成死鎖,所以參考C語言類似機制在Go中寫這樣的代碼,容易導致這樣的bug。
Wait:3個阻塞性bug歸因于等待操作無法繼續。跟Mutex和RWMutex不同,這里并不涉及循環等待。有兩個bug是這樣的:Cond被用來保護共享內存訪問,其中一個協程調用了Cond.Wait(),但是在這之后卻沒有別的協程調用Cond.Signal()(或Cond.Broadcast())。
另一個bug,Docker#25384,如下圖所示,使用了一個共享的WaitGroup變量,造成bug主要是Wait()放在了錯誤的地方即第7行,修復bug只需要把Wait()挪到圖中的第8行(循環外)。
img(2)對消息傳遞的誤用
Channel:對通過channel傳遞消息的錯誤使用導致了29個阻塞性bug。很多都跟發送和接收的錯配有關。如下圖所示,在使用第2行代碼初始化channel的情況下,在子協程執行到第6行代碼前,如果超時時間到了,或者子協程執行到第6行時,select的兩個case同時可用,由于select的隨機性而跑到了超時的那個case,就會導致finishReq函數返回,從而子協程阻塞。這個問題的修復方法是將channel定義為緩沖channel,這樣無論何種情況子協程都不會阻塞住。
img當組合使用go特定類庫時,channel的創建和協程阻塞有可能被埋在了類庫的調用之中。如下圖所示,行1創建了一個新的context對象 hcancel,同時一個新的協程被創建,消息可以通過hcancel的channel傳遞到新協程。如果在行4 timeout大于0,另一個context對象在行5被創建,并且hcancel指向了新的對象。之后,將無法向協程所關聯的舊對象發送消息,舊對象也沒法被關閉。這個問題的避免方法是,避免創建額外的context對象。
imgChannel和其他的阻塞特性:有16個bugs,其中一個協程阻塞在Channel操作,而別的協程阻塞在鎖或等待上。如下圖,協程1在發送消息到ch時阻塞了,而同時協程2卻被m.Lock()阻塞。解決方案是對協程1使用具有default分支的select來確保ch不再阻塞。
img消息庫函數:go提供了幾種傳遞消息和數據的庫,如Pipe。對這些的不正確使用也會造成bug。例如,和Channel類似,如果一個Pipe未關閉,Pipe的兩端一個伙伴掛了,另一個伙伴等著讀或寫數據,那這是等著讀或寫數據的伙伴就被阻塞住了。類似的bug有4個。
最后,關于阻塞性bug,文章認為消息傳遞機制更容易造成更多類型的bug。
2、非阻塞性bug
統計如下:
img(1)對共享內存的保護失敗
已有很多研究發現,未保護共享內存或保護錯誤是造成數據競爭或其他非阻塞性bug的主要原因。本文也發現80%非阻塞性bug都歸因于未保護或錯誤地保護共享內存。但go中的情況和傳統編程語言的情況也并非完全相同。
傳統bug:超過一半非阻塞性bug都是由于傳統問題造成的,就跟在Java、C這些編程語言中一樣,如原子操作的破壞、順序混亂、數據競爭。有幾個bug是對go新特性的不夠理解造成的,如:Docker#22985 和 CockroachDB#6111 是由于將一個變量的引用通過Channel在不同協程間傳遞,從而造成了共享變量的競爭狀態。
匿名函數:Go語言中在一個函數前加go關鍵字就可以啟動協程,這個函數是可以沒有名字的(匿名)。在匿名函數之前定義的所有局部變量,在匿名函數中都是可見的。不幸的是,由于開發者可能不夠注意對這些在不同協程中的共享變量做保護,從而可能容易導致數據競爭的bug。有11個bug就是這種類型,其中9個是父協程和子協程之間的數據競爭,2個是兩個子協程之間的數據競爭。如下圖的一個例子,含bug的版本中,變量i在父協程和子協程之間共享了,開發者想要得到不同的i值所生成的apiVersion,但是如果在父協程的for循環結束后子協程才運行起來,那所有的apiVersion都將等于”v1.21”。解決方案就是將i作為參數傳遞到子協程中,此時傳遞的是i的拷貝。
imgWaitGroup的誤用:使用WaitGroup的一個基本準則是,Add必須在Wait之前執行。有6個bug是因為違反了這條準則。如下圖所示,這是etcd中的一個bug,這里是無法保證func1中行8的Add一定在func2中行5的Wait之前執行的。解決方案就是將Add操作遇到行6的位置,保證要么Add在Wait之前執行,要么根本不會執行到idle這個case。
img特定庫函數:go中有些類庫的變量是隱式在多協程中共享的。如context就被設計為可以被多個關聯協程訪問。etcd#7816就是因為在多個協程中競爭使用一個context對象的一個字符串字段導致的。
另一個例子是testing包。測試函數只有一個testing.T類型的變量,這個變量用于傳遞測試狀態如error何日志。有3個bug就是在測試函數以及測試函數內啟動的子協程之間競爭使用testing.T變量導致。
(2)消息傳遞中的錯誤
channel的誤用:前面也提到過,channel的使用需要遵循一定的規則,否則就會引起一些bug。如下圖所示(Docker#24007),可能有多個協程會運行到這段代碼,其中可能有多個跑到了select的default分支,導致對channel的多次關閉,從而引發panic。這種情況,可以使用Once.Do將關閉channel的語句包起來,保證它只會執行一次。
img還有一種類型是將channel和select一起使用,當select收到多個case的消息時,是沒辦法保證會執行哪一個的,這種非確定性的選擇,導致了3個bug。下圖是一個例子,其中f函數執行耗時操作,當它執行完之后,stopCh的消息和ticker有可能同時到達,此時并不一定會執行到11行return語句,也有可能執行到case
img特定庫函數:一些庫函數內部會使用channel,也可能導致非阻塞性bug。下圖是一個與time包有關的bug。開發者想實現的是,要么收到Done信號,要么超時,然后再返回。但是含bug的版本先創建了超時時間為0的timer,然后再判斷參數dur是否大于0 ,大于0的話修改timer。但是,當dur為0的情況下,timer實際上一開始就被設置為有信號了,可能導致函數過早返回。解決方案是不要讓timer過早創建。
img非阻塞性bug的檢測
Go提供了數據競爭檢測,在build的時候使用 -race 標志即可啟用。
文章的一些結論是,消息傳遞機制也容易造成bug,情況并不比共享內存機制好。消息傳遞機制更多地會造成一些阻塞性bug,比較少造成非阻塞性bug,而且可以用于解決由于共享內存導致的非阻塞性bug。
關于bug檢測,目前很多在傳統語言中針對共享內存的檢測算法,在go中也是適用的,但是針對go的消息傳遞機制所引起bug的檢測,還需研究。
譯者:Darlzan
譯文鏈接:https://blog.csdn.net/notjusttech/article/details/88294964
推薦閱讀
Socket Server的N種并發模型匯總
總結
以上是生活随笔為你收集整理的go 怎么等待所有的协程完成_理解真实世界中 Go 的并发 BUG的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: python课设代码_python课程编
- 下一篇: html 循环_一个不被程序员认为是编程