鄭建勳:程式碼優化的三重境界

韓楠發表於2022-06-30




寫在前面:


俗話說:“九層之臺,起於累土”。除了上層建築,我們也必然得考慮程式底層結構的效能。對效能問題,我採取的是 分層擊破的策略,今天我將帶著大家,分析影響高效能程式的底層基石 —— 程式碼實施。


我曾分享過一篇也是Go程式效能分析與優化的內容,講解的上層系統與程式設計。本質上,屬於不同的知識體系。當然如果有需要的朋友,我仍然建議你得空時作為擴充閱讀可以看看( 點選閱讀 ),將幫助你從更上層的角度看待效能問題 ,從而完整地看下可以如何描述這顆效能分析的大樹。


程式碼實際開發階段,考驗的是每一個Go開發者, 對於 如何 書寫更高效程式碼的理解。即便是在系統設計和程式架構都已經確定的情況下,實際的開發階段也不是像搬磚一樣毫無新意,其仍然充滿創造力。



責編 | 韓楠

約 4895 字 | 10 分鐘閱讀




 以下,Enjoy~ 




▶︎   程式碼優化要權衡利弊

開始介紹程式碼實施的優化之前, 我覺得有必要 強調的是, 最快的程式碼是從未執行過的程式碼。 幾乎總是能讓程式變得更快,優化通常是一種收益遞減的遊戲。

實際上, 大部分的架構與程式碼,都是逐漸迭代的過程。很多程式碼現在看起來很糟糕,但當時也許是成本最低的一種實現方式。不得不說,漫無目的的優化,會無端消耗精力,我們開發者需要直面遇到的最嚴重的效能瓶頸問題,並儘可能想辦法解決。

我相信,二八定律仍然適用於程式碼優化中, 20%的程式碼消耗了程式80%的時間。如果你將只佔用 5% 執行時間的程式碼速度提高一倍, 那麼總程式的速度只會提高 2.5%。但是,將佔用程式80%時間的程式碼,僅加速 10% 就將為程式帶來8%的提速,成本與收益是顯而易見的。

圖 - 二八法則

程式碼實施階段的優化,可以分為下圖的3個層面。其貫穿於程式碼設計階段、程式碼開發階段、到後期為滿足特定的目標,對效能進行的合理甚至是極致的優化。


01   程式碼實施階段 | 合理的程式碼XXX

▶︎   合理的程式碼,在高階開發者的指尖自然流淌

優秀的開發者 能夠在頭腦中想象出不同程式執行的過程和結果。只有在我們掌握了這樣的技能之後,才能學會如何可靠地構建起能夠表現出所需行為和結果的程式。

舉一個例子,下圖是用遞迴實現的斐波拉契數列,以及其執行的示意圖。遞迴形式,是程式中比較複雜的一種過程了。高階開發者在書寫這樣的程式時,實際上腦海中就已經有了程式 每一步如何執行的完整影像,看到程式過程的脈絡。時間和空間複雜度盡收眼底。

Go語言擁有強大的標準庫和保姆級別的執行時,這意味著對Go程式執行過程的把握,同樣離不開對於程式碼表象背後底層原理的深入理解,才能夠將看到的毛細血管延伸得更細緻一些。

▶︎   制度約束,保證程式在正常的軌道執行

合理的程式碼看起來是如此自然,然而對開發者的個人素質要求卻極高。由於開發者對語言和程式設計理解上的差異,常常開發出來的程式碼風格迥異。因此,我們需要一些制度和規範,來幫助我們寫出更符合規範的程式碼。

這些規範涉及程式開發的方方面面,從目錄結構規範、測試規範、版本規範、目錄結構規範、程式碼評審規範、開發規範等等。 這部分,你還 可以參考一下 UBER 開源的Go語言開發中的約定規範 ,連結我放這裡,方便你檢視

只需要遵守一些簡單的規則,就能夠更多 減少之後效能上的困擾。

這裡舉一個《Efficient Go》中的例子 ,下面的程式從功能上來看是沒有問題的,往切片中新增元素

但是這種寫法卻忽略了一個事實,即 切片在自動擴容過程中, Go執行時會建立新的記憶體空間,並執行拷貝(參見《Go語言底層原理剖析》)。

    這種操作顯然不是沒有成本的。當在迴圈操作中執行這樣的程式碼,效能損失將會被放大,減慢程式的執行。效能損失的對比可參考:

#prefer-specifying-container-capacity

圖 - Go切片擴容示意圖

   

上述程式可以改寫如下,在初始化時指定切片容量:

除了切片的動態擴容,雜湊表的動態擴容也有類似的情況。這都是新手比較容易犯錯的地方。這啟發我們,即便是一開始不太理解底層的實現邏輯,也需要準守一些基本的規則和常見的效能陷阱規避,從小事情上開始優化程式碼習慣。

▶︎   程式優化 = 演算法優化 + 資料結構優化

書寫合理程式碼的第二方面,涉及到具體功能的演算法和資料結構的設計與改造。有人說“程式=演算法+資料結構”,可見其二者的重要性。 在接觸到很多業務屎山程式碼之後,我們總是感嘆,有時候真的稍微寫得合理一點,就可以避免掉後期理解成本與重構成本的大大增加。

對於演算法, 通常對於關鍵演算法的調整,能帶來數倍效能的提升。例如將氣泡排序(o(n^2)),替換為快速排序(o(n*logn)),將線性查詢(o(n)) 替換為二分查詢(o(log n)),甚至到雜湊表的方式(o(1)),都是常見演算法導致的效能提升。

對於資料結構,指的是新增或更改正在處理的資料的表示。這種表示很大程度上決定了資料的處理方式,進而決定了時間與空間複雜度。舉一個簡單的例子,如果在連結串列中,能夠在頭部節點新增一個表明連結串列長度的欄位,則不必遍歷連結串列來得到連結串列的總長度。

圖 - 連結串列節點增加總長度

有時候,我們需要做一些以空間換時間的tradeoff。快取就是一種提高效能,減少資料庫訪問和防止全域性結構鎖的機制。快取這種空間換時間的策略,在高併發程式中應用廣泛,例如 不管是CPU多級快取,還是Go執行時排程器,Go記憶體分配管理,甚至到標準庫中sync.pool的設計,都體現了利用區域性快取提高整體併發速度的設計。

開發中通常在記憶體,或者藉助redis等資料庫快取資料,減輕對原有資料訪問的壓力。當然,這中間如何提高快取的命中率,如何設計快取失效策略考驗的,這取決於開發者基於實際場景對演算法和業務的理解程度。

設計演算法與資料結構時,要考慮的另一個重要因素,是關於實現的複雜度。例如Go1.13前記憶體分配使用了Treap平衡樹,Treap 是一種引入了隨機數的二叉樹搜尋樹,其實很簡單,並且引入的隨機數以及必要時的旋轉,保證了比較好的平衡特性。又如redis中選擇跳錶,都是因為實現複雜度考慮,而沒有選擇實現更加複雜的紅黑樹。

▶︎   效率提升能對比與量化

當完成重要過程的優化之後,鼓勵大家對於功能進行benckmark效能測試,並通過benchstat 工具,對比兩次benckmark的差別,做到心中有數。


02   程式碼實施階段 | 刻意的優化


可以對程式刻意優化的點很多,例如:

放入介面中的資料會進行記憶體逃逸,需不需要優化?

位元組陣列與string互轉的效能損失需不需要優化?

無用的記憶體需不需要複用?

這裡我不考慮這種細節,一方面其依賴於開發者的水平和細膩程度,另一方面這些微小的效能損失很少成為瓶頸,至少在專案開發的初期是這樣。

因此這裡我想要討論的刻意優化,指的是在專案開發和迭代過程中,為了達成需求目標而刻意對於效能瓶頸的優化。只關注最核心要解決的問題。總地來說,基於兩者在通訊層面的對比,我們因此能總結出它們本質上的差異:SOA基於配置,微服務則基於約定。

▶︎   優化前提,是定位瓶頸問題

能夠發現程式哪裡有問題,想辦法解決,比寫出正確的程式,對開發者的要求其實要更高一些。因為在排查問題時遇到的不確定性更多,需要掌握的能力也更多。接下來,我將介紹排查程式效能瓶頸的常見手段。

很顯然,如果能從上帝視角檢視到 這段時間內程式執行的完整影像,就像是把胃鏡深入胃裡,一覽無餘定位到問題。 執行中的程式就像是一個黑盒,我們能在多大程度上還原、收集、統計、分析程式的執行軌跡,就能夠多大程度上更容易檢視到程式的病症。

接下來要介紹的pprof與trace工具,就是藉助作業系統與Go執行時,收集與統計一段時間內程式執行過程中產生的各種指標、聚合統計並視覺化。對這些視覺化影像的正確理解與分析,有助於幫助你在實際遇到效能問題時有的放矢,把握程式的執行脈絡。

對於CPU資源來講,要排查延遲和評估耗時最多的瓶頸在哪一個地方,除了檢視程式碼、單元測試、在程式碼中列印耗時等手段,實踐中通常使用pprof工具檢視CPU耗時。示例如下:

圖 - pprof cpu 取樣

pprof CPU 分析器,使用定時傳送SIGPROF 訊號中斷程式碼執行。

當呼叫 pprof.StartCPUProfile 函式時,SIGPROF 訊號處理程式,將被註冊為預設每 10 毫秒間隔呼叫一次(100 Hz)。在 Unix 上,它使用 setitimer(2) 系統呼叫,來設定訊號計時器。

當核心態返回到使用者態呼叫註冊好的sighandler 函式,sighandler 函式識別到訊號為_SIGPROF 時,執行sigprof 函式記錄該CPU樣本,並以此機會獲取當前程式碼的棧幀資料。

圖 - 棧回溯原始碼

這些堆疊資訊,最終合併為profile檔案。並最終被pprof分析程式處理。

鄭建勳:程式碼優化的三重境界

圖 - 訊號處理過程

另一種常見效能分析的形式,是檢視火焰圖,仍然可以藉助pprof。火焰圖是軟體分析中用於特徵和效能分析的利器,因其形狀和顏色像火焰而得名。火焰圖可以快速準確地識別出最頻繁使用的程式碼路徑,從而得知程式的瓶頸所在。

圖 - pprof 火焰圖

以CPU 火焰圖為例說明如下:

•  最上方的root 框代表整個程式的開始,其他的框都代表一個函式。

•  火焰圖每一層中的函式都是平級的,下層函式是其對應的上層函式的子函式。

•  函式呼叫棧越長,火焰就越高。

•  框越長、顏色越深,代表當前函式佔用CPU 時間越久。

•  可以單擊任何框,檢視該函式更詳細的資訊。

知道pprof 和火焰圖中數字背後的意義 是非常重要的 現實中 常常有很多人對這一指標 有比較深的誤解,主要在於詳細講解如何觀察pprof的文章很少。pprof通過統計學中取樣的方式 得到了呼叫頻率最多的函式。一個函式可能本身耗時不多,但是有大量的迴圈導致了總的函式耗時上漲。

同樣地,一個函式可能耗時多,也不一定能夠被捕獲住。比如正常情況下,我們無法捕獲到處理GC的函式。

通過pprof,找到我們關心的耗時最多 即cpu耗時最多的耗時,很多時候這樣的函式 就是我們想要解決的瓶頸。 不過, 我們仍然需要清醒 認識到,在pprof影像中並不能夠涵蓋所有資訊,例如一個長時間在等待狀態下的協程 其本身是不消耗CPU的,也就不可能出現在pprof中 但是其可能會導致耗時變慢。

就是說,耗時變高可能並不來自於CPU繁忙,甚至恰恰相反,可能完全沒有進入CPU處理。

對於這種情況 可以進行進一步的分析,在這裡藉助更強悍的trace工具 去觀察程式的執行狀態。在之前藉助trace分析過程式的並行情況,這種強大的分析 藉助於Go執行時在關鍵時刻進行的埋點。例如當進行協程切換時,記錄下來一個事件,即可知道某一時刻執行緒對應的M從協程A切換到了協程B。

trace中的Goroutine analysis 是一個非常有用的工具,可以用來分析某一個協程的具體情況。

圖 - trace工具

如下示例中,在抓取trace樣本的一段時間內,一段協程可能執行了n次。而下圖展示的就是對於這些協程按照時間進行由大到小的排序。從這裡我們能看到某一個耗時最多的協程其主要的耗時是在哪一個地方。是耗時在了CPU、網路I/ O 、GC、排程器延遲、鎖、還是系統呼叫上。這是一種非常強悍的觀察方式,甚至是在實踐中解決p99問 題的法寶。

圖 - trace工具

當我們知道了具體是哪一個部分,例如鎖導致的瓶頸,GC導致的瓶頸,這時還需要定位到具體是哪一行函式,點選上圖中的某一個協程ID號,即會跳轉到該協程的排程頁面,找到加鎖的位置或觸發GC的那一次記憶體操作。 

有時候問題是棘手的,特別是當這些問題來自於Go原始碼的bug時,這裡需要觀察程式碼並結合dlv等更高階的除錯手段,找到問題的根因。

▶︎   瓶頸問題需對症下藥

工具暴露出來的瓶頸,通常就是我們要優化的目標,有時這種瓶頸是不明顯的,還需要開發者做一些假設並驗證自己的猜想。除了將不合理的資料結構與演算法變得更加合理。這裡要強調,有一些結構在之前是合理的,但是當結構或併發越來越大,就不太合理了。

例如程式中使用json進行結構序列化,由於標準庫比較通用並使用大量反射,導致在併發量上來之後可能變為瓶頸,這時可以考慮將其替換為更快的第三方庫。甚至替換序列化的方式為protobuf等更快的序列化方式。

還有一些優化涉及到對Go語言編譯時與執行時的調整。例如之前介紹過的將環境變數GOMAXPROC調整為更合適的大小,本質上就是在修改執行時可並行的執行緒數量。

另外當併發量上來之後,GC確實可能成為系統的瓶頸所在。原因是GC有一段STW的時長,並且在並行標記期間佔用了25%的CPU時間。而且在併發標記階段出現的頻繁記憶體分配,可能會導致輔助標記,進而導致程式無法有效處理使用者協程,出現嚴重的響應超時問題。

鄭建勳:程式碼優化的三重境界

圖 - 垃圾回收(來自《Go底層原理剖析》)

一般GC問題需要修改程式碼,減少頻繁的記憶體分配,或是藉助sync.pool等記憶體池複用記憶體。另外執行時也暴露了一些有限的api能夠干預垃圾回收的執行: 

•  執行時環境變數GOGC調整GC的記憶體觸發水位,GOGC=off甚至能夠關閉GC的執行;

•  Runtime.GC()手動強制執行GC;

•  GODEBUG=gctrace=1 追蹤GC的表現。

還有一些刻意的優化與Go版本有關,例如GO1.14以前死迴圈沒有辦法被搶佔,導致程式被卡死的現象在實踐中經常出現。那麼在使用這種版本時,就不得不做一些特殊的判斷和處理。


03   程式碼實施階段 | 危險的優化


程式碼實施階段,由於迫不得已的原因,需要進行一些特別的處理。例如由於很多機器學習庫是用C或者C++完成的,因此需要使用CGO的技術。我曾經深度寫過CGO相關的專案,苦不堪言。 沒有編輯器的提示,繁瑣的語法,難以除錯,記憶體不受到Go執行時的管理等問題,在迫不得已下,不要使用CGO。

圖 - cgo程式碼

Go語言語法本身遮蔽了指標的操作,而且確實有些場景為了提高效能等高階操作會使用到unsafe庫。然而,想要正確地使用unsafe,是很難的。

首先Go語言中的unsafe庫本身,不是向後相容的,這意味著在之後當前版本中正確的程式碼在之後的版本中可能是不正確的。

另外,對指標進行運算的uintptr,其本質上是一個整數,Go內建的垃圾回收無法管理。當操作指標時,由於Go執行時棧的自動擴容,可能導致之前指標指向的內容無效。這些危險的操作,需要開發者正確 的權衡,並知道其正確的使用規則。對unsafe包的用法,可以參考:。

有時候一些底層操作,為了獲得更高的效能和語言,未暴露的功能甚至涉及到需要書寫彙編程式碼。


04   總結


一座房子是由每一塊磚砌成的,程式碼實施在碼好每一塊磚的同時,也極具創造力,考驗開發者深刻的功力。程式碼實施階段,分為了三層境界: 合理的優化,刻意的優化,危險的優化。其貫穿於設計、開發與問題排查、調優。

全文思維導圖


當優 秀的程式碼能從指尖自然流淌,我們是一個合格的開發者,而當我們可以精細化的調優,駕馭整個程式時,我們的武功已經臻於化境。


參考資料:
[1] uber Go語言規範:
[2] Go底層原理:《Go底層原理剖析》

[3] 關於unsafe與cgo危險的優化:《Learning Go》Chapter 14. Here There Be Dragons: Reflect, Unsafe, and Cgo




THE END 

轉載請聯絡ITPUB官方公眾號獲得授權

—————————————————————————————————

歡迎各領域技術人員投稿

投稿郵箱 |  hannan@it168.com




來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70016482/viewspace-2903638/,如需轉載,請註明出處,否則將追究法律責任。

相關文章