大規模Go項目幾乎必踏的幾個大坑

個月前開源了Dragonboat這個Go實現的高性能多組Raft共識庫,它的一大賣點是其高吞吐性能,在使用內存內的狀態機的場景下,能在三組單插服務器上達到千萬每秒的吞吐性能。作為個人用Go寫的第一個較大的應用庫,Dragonboat的開發過程可謂踏坑無數,逐步才具備了目前的性能和可靠性。本文選取幾個在各類Go項目中踏坑概率較高的具有普遍性的問題,以Dragonboat踏坑詳細過程為背景,具體分享。

Channel的實現沒有黑科技

雖然是最核心與基礎的內建類型,chan的實現卻真的沒有黑科技,它的性能很普通。

在Dragonboat的舊版中,有大致入下的這樣一段核心代碼。它在有待處理的讀寫請求的時候,用以通知執行引擎。名為workReadyCh的channel系統中有很多個,執行引擎的每個worker一個,client用它來提供待處理請求的信息v。而考慮到該channel可能已滿且等待的時候系統可能被關閉,一個全局唯一的用於表示系統已被要求關閉的channel會一起被select,用以接收系統關閉的通知。

select {
case return
case workReadyCh}

這大概是Go最常見的訪問channel的pattern之一,實在太常見了!暫且不論千萬每秒的寫吞吐意味著每秒千萬次的channel的寫這一問題本身(前文詳細分析),數萬併發請求的goroutine通過數十個OS thread同時去select一個全局唯一的closeCh就已足夠把高性能秒殺成了低性能蝸牛。


大規模Go項目幾乎必踏的幾個大坑 - 實例分享


這種大量線程互相踩踏式的select訪問一個channel所凸顯的chan性能問題Go社群有詳細討論。該Issue討論裡貼出的profiling結果如下,很直觀。但很遺憾,runtime層面無解決方案,而無鎖channel的實現上雖然眾人前赴後繼,終無任何突破。現實中的Go runtime沒有黑科技,它只提供性能很一般的chan。

大規模Go項目幾乎必踏的幾個大坑 - 實例分享

為了繞開該坑,還是得從應用設計出發,把上述單一的closeCh分區做sharding,根據不同的Raft組的組號,由不同的chan來負責做系統已關閉這一情況的通知。此改進立刻大幅度緩解了上述性能問題。更進一步的優化,更能完全排除掉上述訪問模式,這也是目前的實現方法,篇幅原因這裡不展開。


大規模Go項目幾乎必踏的幾個大坑 - 實例分享


sync.RWMutex隨核心數升高其性能伸展性不佳

下面是Dragonboat老版本上抓的一段cpu profiling的結果,RWMutex的RLock和RUnlock性能很差,用於保護這個map的RWMutex上的耗時比訪問map本身高一個數量級。

大規模Go項目幾乎必踏的幾個大坑 - 實例分享

這是因為在高核心數下,大量RLock和RUnlock請求會在鎖的同一個內存位置併發的去做atomic write。與上面chan的問題類似,還是高contention。

RWMutex的性能問題是一個困擾Go社區很久但至今沒有在標準庫層面上解決的問題(#17973)。有用戶提出過一種稱為Big Reader的變種,在犧牲寫鎖性能的前提下改善讀鎖的操作性能。但此時寫鎖的性能是崩跌的,以Intel LGA3647處理器高端雙插服務器為例,Big Reader鎖在操作寫鎖的時候需要對112個RWMutex做Lock/Unlock操作,因此只適用於讀寫比極大的場景,不具備通用性。

在Dragonboat中,所觀察到的上述RWMutex問題,其本質在於在每次對某個Raft組做讀寫之前都需要反覆去查詢獲取該指定的Raft節點。顯然,無論鎖的實現本身如何優化,或是改用sync.Map來替代上述需要鎖保護的map的使用,試圖去避免反覆做此類無意義的重複查詢,才是從根本上解決問題。本例中,Big Reader變種是適用的,軟件後期也改用了sync.Map,但避免反覆的getCluster操作則徹底避免鎖操作,完全饒開了鎖的實現和用法是否高效這點。減少不必要操作,遠比把此類多餘的操作變得更高效來的直接有效。

Cgo遠沒那麼爛

前兩年網上無腦Go黑的四大必選兵器肯定是:GC性能、依賴管理、Cgo性能和錯誤處理。GC性能這兩年已經在停頓方面吊打Java,吞吐的改進也在積極進行中。Go 1.12版Module的引入從官方工具層面關管住了依賴管理,而Go 2對錯誤處理也將有大改進。種種這些之外,Cgo的性能依舊誤解重重。

多吹無意義,先跑個分,看看Cgo究竟多"慢":


大規模Go項目幾乎必踏的幾個大坑 - 實例分享


調用一個簡單的C實現的函數的開銷是60ns級,和一次沒有cache的對內存的訪問一樣。

這是什麼概念呢?用個踩過的坑來說明吧。Dragonboat早期版本對RocksDB的WriteBatch的Put操作是一次操作一個Raft Log Entry,一秒該Cgo請求在多個goroutine上共並行操作數百萬次。因為聽信網上無腦黑對Cgo的評價,起初認為這顯然是嚴重性能問題,於是優化歸併後大幅度減少了Cgo調用次數。可結果發現這對延遲、吞吐的性能改進很小很小。事後再跑profiler去看舊的實現,發現舊版的Cgo開銷起初便完全不主要。

Go內建了很好的benchmark工具,一切性能的討論都應該是基於客觀有效的benchmark跑分結果,而不是諸如“我認為”、“我感覺”之類的無腦互蒙。

Goroutine洩漏與內存洩漏一樣普遍

Goroutine的最大賣點是量大價廉使用方便,一個程序裡輕鬆開啟萬把個Goroutine基本都不用考慮其本身的代價......一切似乎很美好,直到系統內類型眾多的Goroutine開始洩漏。也許是因為Goroutine的特性,它在Go程序裡的使用的頻度密度遠超線程在Java/C++程序中情況,同時用戶思維中Goroutine簡單易用代價低的概念根深蒂固、與生俱來,無形中更容易放鬆對資源管理的考慮,因此更容易發生Goroutine洩漏情況。Dragonboat的經驗是Goroutine洩漏的概率不比內存洩漏少。

Dragonboat從實現之初就開始使用Goroutine洩漏檢查,具體的洩漏檢查的實現是來自CockroachDB的一小段代碼。效果方面,這個小工具發現過Dragonboat及其依賴的第三方庫裡多個goroutine洩漏問題,而使用上,在各內建的測試中,只需一行便能完成調用得到結果,絕對是費效比完美。


大規模Go項目幾乎必踏的幾個大坑 - 實例分享


實現上它也特別簡單,就是前後兩次分別抓stacktrace,解析出進程裡所有的Goroutine ID並對比是否測試運行結束後產生了多餘的滯留在系統中的Goroutine。官方雖然不倡導對Goroutine ID做任何操作,但此類僅在測試中僅針對Goroutine洩漏的特殊場景的使用,應該不拘泥於該約束,這就如同官方不怎麼推薦用sync/atomic一個道理。

總結

基於Dragonboat的幾個具體例子,本文分享了幾個常見的Go性能與使用問題。總結來說:

通過sharding分區減少contention是優化常用手段 做的再快也不可能比什麼也不做更快,減少不必要操作比優化這個操作有效 多用Go內建的benchmark功能,數據為導向的做決策 官方提倡的東西肯定有他的道理,但在合適的情況下,需懂得如何無視某些官方的提倡。

歡迎工作一到五年的Java工程師朋友們加入Java程序員開發: 854393687

群內提供免費的Java架構學習資料(裡面有高可用、高併發、高性能及分佈式、Jvm性能調優、Spring源碼,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多個知識點的架構資料)合理利用自己每一分每一秒的時間來學習提升自己,不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代!


分享到:


相關文章: