鄭建勳:Go程式效能分層優化 | CPU篇

韓楠發表於2022-06-28


責編 | 韓楠

約 5, 500 字 | 11 分鐘閱讀





效能問題,是所有程式和系統隨著時間都可能面臨的問題,雖然本文中以Go程式作為切入點,但其思考方式,我認為仍然適用於其他語言編寫的系統,並有啟發意義。



不過早優化 ≠ 不優化


電腦科學中有一句名言:過早的優化是萬惡之源。[1]

但實際上,這句話隱含的第一層含義是開發者應該更多關注於程式中的關鍵部分,而忽略掉不關鍵的部分。可以優化並不意味著值得優化。

這句話隱含的第二層含義就是遲早也要對關鍵程式碼做優化,逃得過初一,躲不過十五……

另外,這句話強調的是不要過早,而不是不進行優化。

實際上, 一個完全不考慮設計的系統 最終帶來的就是難以維護的“屎山”,逐漸又慢又臭。就像《人月神話》[2] 中描述的焦油坑,所有進入的努力都慢慢地陷入其中並被埋葬。我們多數人應該都深有體會吧。


圖1 拉布雷亞瀝青坑 (圖源:維基百科)


所以, 程式的效能問題 實際上貫穿於程式的整個生命週期 不管是程式正確的架構設計、快速的演算法和資料結構設計,還是為了應對快速增長的需求或降低成本需要做的攻堅,可以說,這些都離不開效能的分析與優化。



效能優化是成本與收益的權衡


同時,效能分析與優化,又是一個比較龐大的話題。首先我們必須要明確一點,就是效能指的是什麼。這裡引入IBM關於效能的一個說法,一起來看下:

效能是指計算機系統響應特定工作負載時的行為方式。按照系統響應時間、吞吐量和資源利用率來測量效能。                                                              
——IBM [3]




簡單來說 效能優化就是用有限的最 的資源 滿足人們對程式的期待


優化的資源可能是多方面的,比如記憶體、磁碟I/O、網路、CPU等。可能出現的瓶頸或者可以優化的地方,也可能是多方面的,例如程式碼層面、演算法與資料結構層面、作業系統層面、硬體層面。特別是現在雲原生技術的興起,給排查程式效能問題帶來了新的挑戰。

Go程式的效能分析,顯然是複雜的。考驗開發者計算機綜合素養和具備的知識體系 。雖然複雜,但是在這一系列文章中, 筆者 依然試圖抽絲剝繭,構建起分析效能問題的模型和思路。在本文中,專注於程式的CPU效能問題優化。





一、效能優化分層抽象


由於效能的複雜性,首先讓我們有的放矢,構建起效能分析依賴的優化級別,在這裡參考了《 Efficient Go》[4] 的劃分方法, 自上而下劃分為了這樣的幾個級別

圖2 效能優化分層抽象


上圖中效能優化的分層抽象,有助於我們將複雜的效能問題拆解開,來並逐個擊破。接下來筆者將對照圖2,帶著大家一同分別看看每一層在效能上面臨的問題和挑戰。





(一)系統級別


在大多數情況下,我們的軟體是某個系統的一部分。也許它是許多分散式程式之一,也許它是更大的單體應用程式中的一個執行緒。在系統級別進行優化意味著如何對功能和模組進行拆分、如何將它們連結在一起、元件呼叫的關係以及呼叫頻率,API如何設計等。


圖3 系統級別優化考慮因素


特別是在當下硬體越來越便宜、容器化以及大資料時代,分散式、微服務架構應用得越來越多,這一級別變得更加重要。


圖4 單體服務(左) VS 微服務(右) 

(圖源:《Kubernetes in Action》[5] )




(二)程式設計和組織


程式內好的架構設計是構建高效能、可維護程式的基礎。這包括瞭如何完成功能元件的拆分、流程的抽象(例如一個叫車流程可以抽象為:預估->派單->接駕->開始計費->行程中->結束計費...)適應業務或外部的需求。

使用何種形式組織程式碼,定義清晰的模組間的介面邊界。使用何種框架、快取、併發模型。甚至包括搭建的開發流程和規範,重點指標體系的設計和監控。


圖5 程式設計和組織考慮因素


好的架構設計,能夠比較輕鬆地進行擴充套件和後續的優化,也能夠總體上提升程式的效能。

前兩層我們從系統外部模組互動,到系統內部架構設計進行了梳理。接下來再更加深入看看系統內部的實際程式碼實施階段、到程式碼的實際執行環境(作業系統和硬體層面),我們所需要考慮的效能問題。





(三)程式碼實施


程式碼實施這一級別,簡單來說就是實際的程式碼開發,如下圖在emacs中編寫程式碼。在編寫程式碼時,避免效能問題有3個階段:
1.合理的程式碼:避免常見的陷阱和坑等;
2.刻意的優化:結合編譯時和執行時原理等;
3.危險的嘗試: 指標和彙編等。


圖6 程式碼實施階段


這一層要考慮的效能內容,包括了遵守常見的程式碼規範,為程式某一模組選擇正確合適的演算法,降低複雜度解決我們面臨的問題,例如二分搜尋、快速排序、合併排序、map-reduce等等。


也包含了選擇何種資料結構,例如陣列、雜湊對映、連結串列、堆疊、環形佇列等。還包括了增加快取、提高快取命中率、優化程式結構、減少鎖爭用甚至無鎖。甚至包括了標準庫、執行時與編譯時的調優。


結合下面這圖來看下,在Go1.14中,對執行時對於記憶體管理進行了優化,將原先基於Treap平衡樹的記憶體分配演算法,替換為了基於點陣圖的基數樹分配方法[6],解決鎖爭用問題,加速了併發的記憶體分配。

圖7 Go1.14記憶體管理基數樹結構





(四)作業系統級別



當今的軟體,通常不會直接在機器硬體上直接執行,相反,我們通過作業系統將程式分為了多個執行緒,並將執行緒排程到 CPU 上執行。


作業系統排程、硬/軟中斷、執行緒上下文切換等因素,都與程式效能息息相關。同時作業系統提供了其他服務,如記憶體和 IO 管理、裝置訪問等。


圖8  linux CFS 排程演算法
 (圖源:IBM developer [7])



特別是在當前雲原生興起的時代,作業系統層面還包括了額外的虛擬 化層(虛擬機器、容器),這給效能分析帶來了新的挑戰。




(五)硬體級別



程式碼轉換而來的一組指令,由計算機 CPU 單元執行,其內部連線到主機板中的其他重要部分:RAM、本地磁碟、網路介面、輸入和輸出裝置等等。

藉助作業系統,開發人員或運營商可以從硬體的複雜性中脫離出來,抽象處理。

然而,應用程式的效能,確實受到硬體設計和規格的限制。下圖為具有多核CPU和統一記憶體訪問 (UMA) 的高階計算機體系結構,CPU具有多級快取,可能會遇到CPU快取失效、CPU快取假共享等問題。

另外新的CPU架構(例如多核CPU的NUMA架構)、CPU 和記憶體節點之間的有限記憶體匯流排頻寬,都在不同程度上影響程式的執行。


圖9 具有多核CPU和統一記憶體訪問 (UMA) 的高階計算機體系結構 (來自《Efficient Go》)


前面對於效能分析優化級別進行了抽象,但是要強調的是,並不是每一個級別,Go開發者都有深入的涉及。比如作業系統層面和硬體層面就很少涉及到,而第一層系統級別的抽象又常常是更大規模架構的一部分,例如涉及到分散式架構的選擇。

更多的時候,開發者可能關注的範圍,在第二層程式碼設計層面和第三層程式碼具體實施過程中錯誤的程式碼導致的效能問題。


二、Go語言協程執行模型


Go語言在作業系統執行緒的基礎上創造了輕量級的協程。在解釋每一層可能遇到的效能瓶頸之前,首先需要簡單介紹一下Go語言執行和排程的GMP模型。




GMP模型瞭解Go排程模型



經典的GMP概念模型,生動地概括了執行緒與協程的關係:Go程式中的眾多協程其實依託於執行緒,藉助作業系統將執行緒排程到CPU執行,從而最終執行協程。

在GMP 模型中,G 代表的是Go語言中的協程(Goroutine),M 代表的是實際的執行緒,而P 代表的是Go邏輯處理器(Process)。


圖10 GMP模型含義



Go語言為了方便協程排程與快取考慮,抽象出了邏輯處理器P。G、M、P之間的對應關係可對照下圖來看。在任一時刻,一個P可能在其本地包含多個G,同時,一個P在任一時刻只能繫結一個M。

圖11 GMP模型 (圖源:《Go底層原理剖析》[8] )






協程上下文切換速度明顯快於執行緒



協程的速度要快於執行緒,其原因在於:


上下文切換的速度受到諸多因素的影響,這裡列出一些值得參考的量化指標: 執行緒切換的速度大約為1~2 微秒,Go 語言中協程切換的速度比它快數倍,為0.2 微秒左右。


圖12 執行緒切換 VS 協程切換

 (圖源:《Go底層原理剖析》[8] )



Go原語級別支援協程,由於上述原因,使得Go語言更容易書寫併發程式,可以在 程式輕易存在成千上萬的協程,藉助Go執行時強大的排程器公平排程

接下來,讓我們分別看待一下在前兩層可能會遇到的CPU效能瓶頸問題,並思考如何進行規避、分析和解決。





三、系統級別優化


現代大型專案通常要處理的qps規模和資料規模,都是海量的。在這種背景下,難以靠單臺機器去承受所有流量。這時候面臨的瓶頸,可能是來自單臺機器CPU無法處理大量負載帶來的處理延遲。

因此,現代大型系統普遍採用了分散式、微服務的系統架構,藉助靈活的程式擴充套件快速適應動態的外部變化,這要求系統設計時服務本身是可擴充套件的,並規劃好負載均衡的策略。



分散式系統設計是另一門艱深的學問


本文無意過多涉及分散式系統架構設計的內容,關於如何構建合適的分散式系統,推薦閱讀堪稱神作的《Designing Data-Intensive Applications》一書[9]。

最後,系統級別優化還包括服務治理,涉及到服務限流、重試、降級、熔斷等策略,避免異常情況下的大流量打垮整個服務,甚至服務雪崩的問題。還需要壓測好單個服務本身的負載水平,必要時進行手動和自動擴容。




系統效能監控必不可少



不難發現,為系統建立合適的觀測監控指標,瞭解服務的執行狀態有重要意義。機器和系統cpu重要觀測指標包括使用者 CPU 使用率,系統 CPU 使用率,等待 I/O 的 CPU 使用率,軟中斷和硬中斷的 CPU 使用率,loadavg平均活躍程式數、執行緒上下文切換等。

CPU相關的指標、分析工具與思考方式,可查閱《Systems Performance, 2nd Edition》一書。






四、程式內部設計和組織優化




前面介紹的系統級別優化將服務看作了一個黑盒,在此基礎上做架構設計。然而,這一節介紹的程式內部架構設計,將把黑盒開啟,考慮如何最大限度地利用好現有資源,滿足需求。


這裡面涉及多個話題,即如何進行有效地設計,在明確優化目標後,如何發現現有系統中存在的CPU效能瓶頸,如何通過工具和指標分析找到造成瓶頸的根因,如何針對問題進行有效地優化。


在程式設計時,我們們需要圍繞著需要實現的效能目標,結合Go在不同場景下的併發特點,充分壓榨多核CPU的效能。



非同步化求快速返回



第一是求非同步化,為了外部使用者的體驗,降低延遲,有時我們可以結合業務對流程進行非同步化,快速返回結果給外部使用者。這可以提高服務的qps與吞吐量。

 

例如任務執行完畢後需要將一些資料存入快取中,這時,可以直接返回結果,並非同步的寫入資料庫。
再比如呼叫一個執行週期很長的函式,可以先直接返回,並在執行完畢後請求使用者給的回撥地址。但是無論如何非同步化,終究是需要執行任務的。



並行化縮短關鍵路徑



第二是在執行的關鍵階段求並行化,儘可能把序列改為並行。

大家可能都聽說過華羅庚燒水泡茶的故事,講的就是將整個大任務分割為小任務,而關鍵任務並行進行處理,從而大大減少整個任務的處理時間。

例如任務分別耗時 T1、T2、T3,如果序列呼叫總耗時 T=T1+T2+T3。而如果三個任務並行執行,總耗時為max(T1,T 2,T3)。在程式設計中也遵循類似的思路。只有做到真正的並行,才能充分發揮多核CPU的效能。


圖13 併發與並行 
 (圖源:《Go底層原理剖析》)





併發模型發揮執行時的最大威力



第三是要合理選擇與實際系統匹配的併發模型,根據自身服務的不同,需要了解Go語言在網路I/O、磁碟I/O,CPU密集型系統在程式處理過程中的不同處理模型。

雖然協程是非常輕量級的資源,但也不是免費的。過多的協程,只會額外增加排程器的負擔和記憶體的數量,由於並行的執行緒是有限的,而排程器又需要保證公平性。


因此控制程式中工作的協程數量,是一個改善程式效能的思路。


經典的fin-in、fin-out、job-worker併發模型,都是為了解決類似的問題。



網路I/O底層多路複用



在實際中,很多專案是基於tcp、http這樣的網路伺服器,這時候需要掌握Go語言的網路模型。Go原生的網路庫每一個請求,都會新建立一個協程。


圖14 Go網路模型 (圖源:騰訊技術工程 [10] )

由於 G o網路庫底層封裝了(epoll/kqueue/iocp)庫,使用了I/O 多路複用技術,並巧妙 利用協程的排程建立了一個看似同步,實則非同步的網路模型,這也是Go語言在網路方面處理效率 十分 優良的原因。

注意:除非在非常極端大量的請求情況下,才會考慮Go網路庫建立大量協程帶來的效能瓶頸。




磁碟I/O同步阻塞



對於磁碟I/O密集型的系統,並不像網路I/O那樣底層是非同步,而是同步的。這也意味著執行檔案的讀寫操作,需要等待系統呼叫read/write操作結束才能繼續執行。

雖然現代作業系統,並不是所有寫操作都直接訪問磁碟,而是寫入到page cache中,並有核心執行緒定時寫入到磁碟,這加快了處理速度。在一些關鍵場景,需要手動呼叫檔案file.Sync() 函式強制將在快取中的資料落盤。

另外當一些特殊場景需要立即落盤時,可呼叫direct I/O,資料將不經過快取,直接操作磁碟。


圖15 linux檔案I/O系統





多種手段應對磁碟I/O堵塞



對於磁碟I/O密集型的系統, 面臨的一個效能問題可能是由於同步I/O的堵塞導致延遲上漲。這在三個方面啟發了程式的設計。



對於陷入到I/O堵塞狀態的協程, go語言有系統監控,當檢測到10ms的系統呼叫堵塞,可能會新建執行緒去處理其他的協程。這時候增加GOMAXPROCS的數量就顯得更重要了,因為其可以增加並行處理的執行緒數量,從而增加系統的吞吐量。


圖16 Go執行時定時系統監控原始碼





無鎖化減少並行的溝通成本



除了要考慮併發模型,還有一點是要考慮無鎖化,保證併發的威力。

考慮一個極端的不合理的鎖設計,可能會讓所有的使用者協程等待某一個協程執行完成,從而退化為了序列執行。

無鎖化並不是完全不加鎖,指的是合理設計併發控制,例如設計無鎖的結構,在多讀少寫場景用讀鎖替代寫鎖,用區域性快取來減少對於全域性結構的訪問(可以在sync.pool、go記憶體分配、go排程器等元件的巧妙設計中看到這種努力,具體可參考《Go底層原理剖析》)。

由於鎖和通道導致請求耗時增加的情況,可以通過pprof觀察到。


go tool pprof

go tool pprof


    注意:我們一般不會考慮標準庫和執行時中鎖帶來的瓶頸問題,只有在非常大量的併發訪問下,例如上萬次qps,才會考慮標準庫可能面臨的鎖競爭問題。





    驗證並行效率,做到心中有數



    當我們考慮到了上面的幾點,如何來驗證程式實際並行的效率?

    如果瞬時協程的數量大於了GOMAXPROCS,也就是當前執行緒數量,那麼CPU才有可能被充分壓榨。因此協程的瞬時數量,其實是一個重要的觀測指標,其表徵了當前程式的並行處理狀況。獲取協程數量的方式有多種[13]:

    圖17  獲取協程數量的幾種方式


    這種瞬時的協程數,通過metric 取樣的方式採集到監控平臺,從而變得有時序性,更有監控意義。在這裡要注意的是,協程數量並不是一個準確的東西,因為有一些協程,比如是初始化時候的定時任務,並不佔用使用者太長時間內的CPU執行。再比如,兩個協程由於鎖的原因並不能夠同時執行。

    因此,除了觀察協程的數量,還需要分析整個排程器的執行狀態,有這樣倆思路:

    圖18 排程器分析


    首先,我們檢視cpu idle 隨著負載的增加是不是已經很難上漲了,例如只有50%,無法完全壓榨CPU資源,同時請求的耗時增加。這可能是由於併發不夠導致的。

    第一種思路我們們結合程式碼看一下,是在啟動時加入啟動引數,scheddeta il=1,並且schedtrace指定為1000毫秒即1秒鐘列印一次排程器瞬時的執行情況。


      GODEBUG=schedtrace=1000,scheddetail=1 ./main


      列印出來的資訊結合下圖來看看,其可以列印出GMP之間的對應關係,並且列印出區域性執行佇列與全域性執行佇列的個數。如果當前M都繫結了G,那麼curg對應的是G的協程id。如果當前所有的M都有對應的G執行,那麼表明當前執行緒都已充分執行。關於排程器列印資訊的詳細說明,可檢視文末的參考資料[14]。


      圖19  排程器列印日誌細節

      最好的觀察手段,需要使用更好的工具,pprof 和trace 工具是分析Go效能的常見工具。


        curl -o trace.out


        下面通過trace工具檢視排程資訊,能夠看到每一個P中正在執行的G的情況,下圖中的空白區域代表P找不到接下來需要執行的G,表明CPU利用率低。


        圖20 trace檢視排程細節


        造成這種現象的原因,可能是錯誤的併發模型導致的鎖延遲、排程延遲等,需要進一步具體分析。我們會在下一篇文章中看到進一步分析的例子。




        結語


        效能優化重要且非常複雜,考驗開發者的內功。對不懂的人來說,就是一個知識的大雜燴,摸不著頭腦。 

        然而, 通過對知識的分層抽象與梳理,可以讓我們有的放矢,將問題聚焦於特定的層面。


        在本文中,程式面臨的任何效能優化問題,都可對應到系統設計到硬體層面的5層抽象模型中。


        自上而下逐個擊破,找到對應的設計思路,瓶頸問題,觀察指標、排查手段和解決方法。 這將幫助你更早地規避效能問題、更快地定位效能問題、更有效地解決效能問題。


        對於本文的知識,總地來說,效能分析與優化在思考上要抽象分層逐個擊破、在系統設計上要合理拆分與組合、在程式設計上要非同步化、求並行、無鎖化、設計和系統特性匹配的併發模型。


        圖21 全文思維導圖

        (可幫助你回顧內容要點)




        最後我們們延申思考下:你實際中常用的分析效能的工具,又是什麼呢?

        在後面的 分享 我們還將看到在程式碼實施、作業系統和硬體級別面臨的效能挑戰和優化手段,更多強悍的效能分析工具等著你









        參考資料:


        [1] 過早的效能優化是萬惡之源,檢視這句話的上下文:《Structured Programming With Go To Statements》by Donald Knuth


        [2] 人月神話是專案管理的經典書籍,論述焦油坑《Mythical Man-Month》


        [3]  效能的定義:


        [4] 效率優化的級別:《Efficient Go》


        [5] 單體服務 VS 微服務:《Kubernetes in Action》


        [6] go1.14記憶體分配基數樹:


        [7] linux排程器原理:https://developer.ibm.com/tutorials/l-completely-fair-scheduler/


        [8] Go排程器原理,可以參考筆者的著作:《Go語言底層原理剖析》


        [9] 分散式系統設計原理:《Designing Data-Intensive Applications》


        [10] Go語言網路模型:


        [11] 檔案buffer與直接寫入效能對比:https://www.instana.com/blog/practical-golang-benchmarks/#file-i-o


        [12] io堵塞導致建立上萬個執行緒,panic的案例與原理: https://mp.weixin.qq.com/s/0nwe-YrMGrl2futS5wkT6A


        [13] Go執行時metric指標詳解: https://mp.weixin.qq.com/s/4mFFbzrviLWViws8NbRzbA


        [14]  排程器列印指標解釋:https://www.ardanlabs.com/blog/2015/02/scheduler-tracing-in-go.html


        [15] 系統效能的思考方式、指標與工具《Systems Performance, 2nd Edition》







        THE END 

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

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

        歡迎各領域技術人員投稿

        投稿郵箱 |  hannan@it168.com



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

        相關文章