當前位置:
首頁 > 科技 > Go 代碼重構:23 倍性能提升!

Go 代碼重構:23 倍性能提升!

要說寫代碼是每位程序員的使命,那麼寫優秀的代碼則是每位程序員的底線。本文作者分享基於 Go 語言的代碼重構,使得性能提升 23 倍的快速方法。

以下為譯文:

幾周前,我讀了一篇名為「Go 語言中的好代碼與差代碼」(https://medium.com/@teivah/good-code-vs-bad-code-in-golang-84cb3c5da49d)的文章,作者一步步地向我們介紹了一個實際業務用例的重構。

文章的主旨是利用 Go 語言的特性將「差代碼」轉換成「好代碼」,即更加符合慣例和更易讀的代碼。但是它也堅持性能是項目重要的方面。這就引起了我探索的好奇心:讓我們深入看看!

1

這篇文章里的程序基本上就是讀取輸入文件,然後解析每一行並存儲到內存的對象中。

作者不僅在 Github 上發布了他的代碼(https://github.com/teivah/golang-good-code-bad-code),還寫了個性能測試程序。這真是個好主意,鼓勵大家調整代碼並用如下命令重現測量結果:

每次執行所需的微秒數(越小越好)

基於此,在我的機器上測出「好代碼」速度提升了 16%。那麼我們可以進一步提高嗎?

以我的經驗看來,代碼質量和性能間的相互關係非常有趣。如果你成功地重構代碼,讓代碼更清晰,更進一步分離,那麼最終代碼速度會加快,因為它不會像以前一樣徒勞無功地執行不相關的指令,而且一些可能的優化會凸顯出來,且易於實現。

另一方面,如果進一步追求性能,那就不得不放棄簡單性並訴諸黑科技。實際上你只減少了幾毫秒,但是代碼的質量會受到影響,會變得晦澀難懂、脆弱且缺乏靈活性。

簡單性先是上升,繼而下降

你需要權衡利弊:應該進行到什麼程度?

為了正確地確定性能的優先順序,最有價值的策略是找到瓶頸,然後集中精力改善。可以使用分析工具來做!例如 Pprof(https://blog.golang.org/profiling-go-programs) 和 Trace(https://making.pusher.com/go-tool-trace/):

一個非常大CPU使用圖

彩虹追蹤:許多小任務

追蹤結果證明所有的 CPU 內核都得到了利用,乍一看似乎不錯。但是它顯示了幾千個很小的彩色計算片段,還有一些空白表示內核閑置。讓我們放大一點:

3毫秒的窗口

實際上,每個內核都有大量閑置的時間,並且在多個微型任務間不斷切換。看起來任務的粒度並不理想,從而導致大量上下文切換,還有同步引起的資源爭搶。

我們用數據衝突檢測器檢查下同步是否正確(如果同步都不正確,那問題就不只是性能了):

很好!看起來沒問題,沒有遇到數據衝突。

「好代碼」中的並發策略是把輸入中的每一行交給單獨的 Go 常式,以便利用多核。這是合理的直覺,因為 Go 常式以輕量和廉價著稱。那麼並發能帶來多少好處呢?讓我們比較一下使用單一 Go 常式順序執行的代碼(僅需在調用行解析函數的時候,刪掉關鍵字go)。

每次執行所需的微秒數(越小越好)

哎呀,實際上不用並行的代碼速度更快。這意味著啟動go常式的開銷超過了同時使用多核所節省的時間。

現在我們放棄並發,轉而使用順序執行,那麼下一步自然是不要使用通道來傳遞結果,以節省開銷。我們用一個裸分片來代替。

每次執行所需的微秒數(越小越好)

僅僅通過簡化代碼,刪除並發,現在「好代碼」版本將速度提高了40%。

使用單個go常式的時候,一段時間內僅有1個CUP在工作

現在讓我們看看Pprof圖形都調用了哪個函數。

找到瓶頸

我們目前的版本的狀況是:86%的時間真正用在了解析消息上,這非常好。我們立刻注意到43%的時間用在了匹配正則表達式上:調用(*Regexp).FindAll。

雖然從原始文本中抽取數據時,正則表達式非常方便,而且很靈活,但是它們也有弊端,例如需要耗費內存和運行時間。正則表達式很強大,但是在很多情況下是殺雞用牛刀。

在我們的程序中,文本模式為:

主要是為了識別以「-」開頭的「命令」,而且一行可能有多個命令。我們可以用bytes.Split做一些略微的調整。讓我們用Split替換代碼中的正則表達式:

每次執行所需的微秒數(越小越好)

哇,這一改速度又提高了40%!

現在 CPU 的圖如下所示:

沒有正則表達式的巨大開銷了。5個不同的函數中的內存分配佔用了40%的時間,還說得過去。很有意思的是現在21%的時間被bytes.Trim佔據了。

這個函數調用讓我很感興趣:我們可以改善它嗎?

bytes.Trim需要一個「cutset string」作為參數(用於分隔符),但我們的分隔符只是一個空格而已。這就是個可以引入一些複雜性來提高性能的例子:實現自己定義的「trim」函數來代替標準庫。自定義的「trim」僅處理單個分隔符位元組。

每次執行所需的微秒數(越小越好)

哈哈,又快了20%。目前的版本的速度是最初「差代碼」的4倍,雖然我們只用到了機器的一個CPU內核。相當可觀!

2

早些時候,我們在處理每行輸入的級別放棄了並發,但是我們仍然可以在更粗的力度上使用並發提高性能。 例如,如果每個文件在各自的go常式中進行處理,那麼在我的工作站上處理6千個文件(6千個消息)的速度要比串列更快:

每次執行所需的微秒數(越小越好,紫色代表並發)

速度提高了66%(也就是提到了3倍),看起來不錯,但是想到它使用了我所有12個CPU內核,那麼這個結果「也沒有那麼好」。這可能意味著,使用新的優化代碼,處理單個文件仍然是一項「小任務」,go常式和同步的開銷不可忽略。

有趣的是,如果將消息數量從6千增加到12萬,對於串列版本的性能沒有影響,而且還會降低「每個消息1個常式」版本的性能。這是因為啟動大量go常式是可能的,有時也很有用,但它確實給go的運行時間調度帶來了一些壓力。

我們可以通過僅創建幾個工作進程(例如12個持續運行的go常式)來進一步縮短執行時間(雖然達不到12倍,但還是會加快速度),每個go常式處理消息的一個子集:

每次執行所需的微秒數(越小越好,紫色代表並發)

與串列版本相比,針對大量消息進行改進後的並發減少了79%的執行時間。 請注意,只有在確實需要處理大量文件時,此策略才有意義。

最佳地利用所有CPU內核的代碼由幾個go常式組成,每個go常式負責處理一定量的數據,在處理完成之前不進行任何通信和同步。

一種常見的啟發式方法就是選擇與可用CPU核心數量相等的進程(go常式),但它並不總是最佳選擇,因為每個任務的情況都不一樣。 例如,如果任務是從文件系統讀取數據或發出網路請求,那麼從性能的角度來看,go常式多於CPU核心數量是完全正確的。

每次執行所需的微秒數(越小越好,紫色代表並發)

現在,解析代碼的效率很難再通過局部改進來提高了。執行時間中的主要部分是小對象的分配和垃圾回收(例如消息結構),這是合理的,因為我們知道內存管理操作相對較慢。 對分配策略的進一步優化......權當是留給高手們的一個練習吧。

3

使用完全不同的演算法也會可以大幅提高速度。

這時,我從 Rob Pike 的《Lexical Scanning in Go》演講中獲得了靈感。構建自定義語法分析其和自定義解析器。 這只是一個原型(我沒有實現所有的極端情況),它不如原始演算法直觀,並且正確實現錯誤處理可能會很棘手。 但是,它的速度比前一個版本提高了30%。

每次執行所需的微秒數(越小越好,紫色代表並發)

好了,與最初的代碼相比,速度提高了 23 倍。

4

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 CSDN 的精彩文章:

A 站徹底要涼?近千萬條用戶數據外泄!
Github 用戶喊話微軟:放棄 ICE 吧,不然會失去我們的

TAG:CSDN |