2個月前開源了Dragonboat這個Go實現的高性能多組Raft共識庫,它的一大賣點是其高吞吐性能,在使用內存內的狀態機的場景下,能在三組單插服務器上達到千萬每秒的吞吐性能。做爲我的用Go寫的第一個較大的應用庫,Dragonboat的開發過程可謂踏坑無數,逐步才具有了目前的性能和可靠性。本文選取幾個在各種Go項目中踏坑機率較高的具備廣泛性的問題,以Dragonboat踏坑詳細過程爲背景,具體分享。git
Channel的實現沒有黑科技github
雖然是最核心與基礎的內建類型,chan的實現卻真的沒有黑科技,它的性能很普通。性能優化
在Dragonboat的舊版中,有大體入下的這樣一段核心代碼。它在有待處理的讀寫請求的時候,用以通知執行引擎。名爲workReadyCh的channel系統中有不少個,執行引擎的每一個worker一個,client用它來提供待處理請求的信息v。而考慮到該channel可能已滿且等待的時候系統可能被關閉,一個全局惟一的用於表示系統已被要求關閉的channel會一塊兒被select,用以接收系統關閉的通知。服務器
select { case <-closeCh: return case workReadyCh<-v: }
這大概是Go最多見的訪問channel的pattern之一,實在太常見了!暫且不論千萬每秒的寫吞吐意味着每秒千萬次的channel的寫這一問題自己(前文詳細分析),數萬併發請求的goroutine經過數十個OS thread同時去select一個全局惟一的closeCh就已足夠把高性能秒殺成了低性能蝸牛。併發
這種大量線程互相踩踏式的select訪問一個channel所凸顯的chan性能問題Go社羣有詳細討論。該Issue討論裏貼出的profiling結果以下,很直觀。但很遺憾,runtime層面無解決方案,而無鎖channel的實現上雖然衆人前赴後繼,終無任何突破。現實中的Go runtime沒有黑科技,它只提供性能很通常的chan。函數
爲了繞開該坑,仍是得從應用設計出發,把上述單一的closeCh分區作sharding,根據不一樣的Raft組的組號,由不一樣的chan來負責作系統已關閉這一狀況的通知。此改進馬上大幅度緩解了上述性能問題。更進一步的優化,更能徹底排除掉上述訪問模式,這也是目前的實現方法,篇幅緣由這裏不展開。工具
sync.RWMutex隨核心數升高其性能伸展性不佳性能
下面是Dragonboat老版本上抓的一段cpu profiling的結果,RWMutex的RLock和RUnlock性能不好,用於保護這個map的RWMutex上的耗時比訪問map自己高一個數量級。測試
這是由於在高核心數下,大量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究竟多"慢":
調用一個簡單的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泄漏問題,而使用上,在各內建的測試中,只需一行便能完成調用獲得結果,絕對是費效比完美。
實現上它也特別簡單,就是先後兩次分別抓stacktrace,解析出進程裏全部的Goroutine ID並對比是否測試運行結束後產生了多餘的滯留在系統中的Goroutine。官方雖然不倡導對Goroutine ID作任何操做,但此類僅在測試中僅針對Goroutine泄漏的特殊場景的使用,應該不拘泥於該約束,這就如同官方不怎麼推薦用sync/atomic一個道理。
總結
基於Dragonboat的幾個具體例子,本文分享了幾個常見的Go性能與使用問題。總結來講:
經過sharding分區減小contention是優化經常使用手段 作的再快也不可能比什麼也不作更快,減小沒必要要操做比優化這個操做有效 多用Go內建的benchmark功能,數據爲導向的作決策 官方提倡的東西確定有他的道理,但在合適的狀況下,需懂得如何無視某些官方的提倡
後續將再推出針對Go內存性能優化的文章,敬請期待。在閱讀完此乾貨軟文後,也請你們訪問Dragonboat項目並點star支持!謝謝閱讀。