軟體教練說:效能優化與效能設計,“相親相愛”的一對

華為雲開發者社群發表於2021-01-30
摘要:效能優化通常是在現有系統和程式碼基礎上做改進,考驗的是開發者反向修復的能力,而效能設計考驗的是設計者的正向設計能力,但效能優化的方法可以指導效能設計,兩者互補。

效能優化是指在不影響正確性的前提下,使程式執行得更快,它是一個非常廣泛的話題。

軟體教練說:效能優化與效能設計,“相親相愛”的一對

優化有時候是為了降低成本,但有時候,效能能決定一個產品的成敗,比如遊戲伺服器的團戰玩法需要單服達到一定的同時線上人數才能支撐起這類玩法,而電信軟體的效能往往是競標的核心競爭力,效能關乎商業成敗。

軟體產品多種多樣,影響程式執行效率的因素很多,因此,效能優化,特別是對不熟悉的專案做優化,不是一件容易的事。

效能優化可分為巨集觀微觀兩個層面。巨集觀層面包括架構重構,而微觀層面,則包括演算法的優化,編譯優化,工具分析,高效能編碼等,這些方法是有可能獨立於具體業務邏輯,因而有更加廣泛的適應性,且更易於實施。

具體到效能優化的方法論,首先,應建立度量,你度量什麼,你得到什麼。所以,效能優化測試先行,須基於資料而不能憑空猜測,這是做優化的一個基本原則。搭建真實的壓測環境,或者逼近真實環境,有時候是困難的,也可能非常耗費時間,但它依然是值得的。

有許多工具能幫助我們定位程式瓶頸,有些工具能做很友好的圖形化展示,定位問題是解決問題的前置條件,但定位問題可能不是最難的,分析和優化才是最耗時的關鍵環節,修改之後,要再回歸測試,驗證是否如預期般有效。

什麼是高效能程式?架構致廣遠、實現盡精微。

架構優化的關鍵是識別瓶頸,這類優化有很多套路:比如通過負載均衡做分散式改造,比如用多執行緒協程做並行化改造,比如用訊息佇列做非同步化和解耦,比如用事件通知替代輪詢,比如為資料訪問增加快取,比如用批處理+預取提升吞吐,比如IO與邏輯分離、讀寫分離等等。

架構調整和優化雖然收效很大,卻因受限於各種現實因素,因而並不總是可行。

能不做的儘量不做、必須做的高效做是效能優化的一個根本法則,提升處理能力和降低計算量可視為效能優化的兩個方向。

有時候,我們不得不從細節的維度去改程式序。通常,我們應該使用簡單的資料結構和演算法,但如有必要,就應積極使用更高效的結構和演算法,不止邏輯結構,實現結構(儲存)同樣影響執行效率;分支預測、反饋優化、啟發性以及基於機器學習編譯優化的效果日益凸顯;熟練掌握程式語言深刻理解標準庫實現能幫助我們規避低效能陷阱;深入細節做程式碼微調甚至指令級優化有時候也能取得意想不到的效果。

有時候,我們需要做一些交換,比如用空間置換時間,比如犧牲一些通用性可讀性換取高效能,我們只應當在非常必要的情況下才這麼做,它是權衡的藝術。

1、架構優化

通常系統的throughput越大,latency就越高,但過高的latency不可接受,所以架構優化不是一味追求throughput,也需要關注latency,追求可接受latency下的高throughput。

負載均衡

負載均衡其實就是解決一個分活的問題,對應到分散式系統,一般在邏輯服的前面都會安放一個負載均衡器,比如NGINX就是經典的解決方案。負載均衡不限於分散式系統,對於多執行緒架構的伺服器內部,也需要解決負載均衡的問題,讓各個worker執行緒的負載均衡。

多執行緒、協程並行化

雖然硬體架構的複雜化對程式開發提出了更高的要求,但編寫充分利用多CPU多核特性的程式能獲得令人驚歎的收益,所以,在同樣硬體規格下,基於多執行緒/協程的並行化改造依然值得嘗試。

多執行緒不可避免要面臨資源競爭的問題,我們的設計目標應該是充分利用硬體多執行核心的優勢,減少等待,讓多個執行流暢快的奔跑起來。

對於多執行緒模型,如果把每一個要乾的活抽象為一個task,把幹活的執行緒抽象為worker,那麼,有兩種典型的設計思路,一種是對task型別做出劃分,讓一類或者一個worker去幹特定的task,另一種是讓所有worker去幹所有task。

第一種劃分,能減少資料爭用,編碼實現也更簡單,只需要識別有限的競爭,就能讓系統工作的很好,缺點是任務的工作量很可能不同,有可能導致有些worker忙碌而另一些空閒。

第二種劃分,優點是能均衡,缺點是編碼複雜性高,資料競爭多。

有時候,我們會綜合上述兩種模式,比如讓單獨的執行緒去做IO(收發包)+反序列化(產生protocol task),然後啟動一批worker執行緒去處理包,中間通過一個task queue去連線,這即是經典的生產者消費者模型。

協程是一種使用者態的多執行流,它基於一個假設,即使用者態的任務切換成本低於系統的執行緒切換。

通知替代輪詢

輪詢即不停詢問,就像你每隔幾分鐘去一趟宿管那裡檢視是否有信件,而通知是你告訴宿管阿姨,你有信的時候,她打電話通知你,顯然輪詢耗費CPU,而通知機制效率更高。

新增快取

快取的理論依據是區域性性原理。

一般系統的寫入請求遠少於讀請求,針對寫少讀多的場景,很適合引入快取叢集。

在寫資料庫的時候同時寫一份資料到快取叢集裡,然後用快取叢集來承載大部分的讀請求,因為快取叢集很容易做到高效能,所以,這樣的話,通過快取叢集,就可以用更少的機器資源承載更高的併發。

快取的命中率一般能做到很高,而且速度很快,處理能力也強(單機很容易做到幾萬併發),是理想的解決方案。

CDN本質上就是快取,被使用者大量訪問的靜態資源快取在CDN中是目前的通用做法。

訊息佇列

訊息佇列、訊息中介軟體是用來做寫請求非同步化,我們把資料寫入MessageQueue就認為寫入完成,由MQ去緩慢的寫入DB,它能起到削峰填谷的效果。

訊息佇列也是解耦的手段,它主要用來解決寫的壓力。

IO與邏輯分離、讀寫分離

IO與邏輯分離,這個前面已經講了。讀寫分離是一種資料庫應對壓力的慣用措施,當然,它也不僅限於DB。

批處理與資料預取

批處理是一種思想,分很多種應用,比如多網路包的批處理,是指把收到的包攢到一起,然後一起過一遍流程,這樣,一個函式被多次呼叫,或者一段程式碼重複執行多遍,這樣i-cache的區域性性就很好,另外,如果這個函式或者一段裡要訪問的資料被多次訪問,d-cache的區域性性也能改善,自然能提升效能,批處理能增加吞吐,但通常會增大延遲。

另一個批處理思想的應用是日誌落盤,比如一條日誌大概寫幾十個位元組,我們可以把它快取起來,攢夠了一次寫到磁碟,這樣效能會更好,但這也帶來資料丟失的風險,不過通常我們可以通過shm的方式規避這個風險。

指令預取是CPU自動完成的,資料預取是一個很有技巧性的工作,資料預取的依據是預取的資料將在接下來的操作中用到,它符合空間區域性性原理,資料預取可以填充流水線,降低訪存等待,但資料預取會侵害程式碼,且並不總如預期般有效。

哪怕你不增加預取程式碼,硬體預取器也有可能幫你做預取,另外gcc也有編譯選項,開啟它會在編譯階段自動插入預取程式碼,手動增加預取程式碼需要小心處理,時機的選擇很重要,最後一定要基於測試資料,另外,即使預取表現很好,但程式碼修改也有可能導致效果衰減,而且預取語句執行本身也有開銷,只有預取的收益大於預取的開銷,且CACHE-MISS很高才是值得的。

2、演算法優化

資料量小的集合上遍歷查詢即可,但如果迴圈的次數過百,便需要考慮用更快的查詢結構和演算法替換蠻力遍歷,雜湊表,紅黑樹,二分查詢很常用。

雜湊(HASH)

雜湊也叫雜湊,是把任意長度的輸入通過雜湊演算法變換成固定長度的輸出,該輸出就是雜湊值,也叫摘要。比如把一篇文章的內容通過雜湊生成64位的摘要,該過程不可逆。

這種轉換是一種壓縮對映,也就是,雜湊值的空間通常遠小於輸入的空間,不同的輸入可能會雜湊成相同的輸出,所以不可能從雜湊值來確定唯一的輸入值,但如果輸出的位數足夠,雜湊成相同輸出的概率非常非常小。

字串的比較有時會成為消耗較大的操作,雖然strcmp或者memcpy的實現用到了很多加速和優化技巧,但本質上它還是逐個比較的方式。

字串比較的一個改進方案就是雜湊,比較雜湊值(通常是一個int64的整數)而非比較內容能快很多,但需要為字串提前計算好雜湊值,且需要額外的空間儲存雜湊值,另外,在雜湊值相等的時候,還需要比較字串,但因為衝突的概率極低,所以後續的字串比較不會很多次。

這樣不一定總是更高效,但它提供了另一個思路,你需要測試你的程式,再決定要不要這樣做。

另一個雜湊的用法是雜湊表,雜湊表的經典實現是提前開闢一些桶,通過雜湊找到元素所在的桶(編號),如果衝突,再拉鍊解決衝突。

為了減少衝突經常需要開闢更多的桶,但更多的桶需要更大的儲存空間,特別是元素數量不確定的時候,桶的數量選擇變得兩難,隨著裝載的元素變多,衝突加劇,在擴容的時候,將需要對已存在的元素重新雜湊,這是很費的點。

雜湊表的衝突極端情況下會退化成連結串列,當初設想的快速查詢變得不再可行,HashMap是普通雜湊表的改進版,結合雜湊和二叉平衡搜尋樹。

另一個常用來做查詢的結構是紅黑樹,它能確保最壞情況下,有logN的時間複雜度,但紅黑樹的查詢過程需要沿著鏈走,不同結點記憶體通常不連續,CACHE命中性經常很差,紅黑樹的中序遍歷結果是有序的,這是雜湊表不具備的,另外,紅黑樹不存在雜湊表那般預估容量難的問題。

基於有序陣列的二分查詢

二分查詢的時間複雜度也是logN,跟紅黑樹一致,但二分查詢的空間區域性性更好,不過二分查詢有約束,它只能在有序陣列上進行,所以,如果你需要在固定的資料集合(比如配置資料)做查詢,二分查詢是個不錯的選擇。

跳錶(Skip List)

跳錶增加了向前指標,是一種多層結構的有序連結串列,插入一個值時有一定概率晉升到上層形成間接的索引。

跳錶是一個隨機化的資料結構,實質就是一種可以進行二分查詢的有序連結串列。跳錶在原有的有序連結串列上面增加了多級索引,通過索引來實現快速查詢。跳錶不僅能提高搜尋效能,同時也可以提高插入和刪除操作的效能。

跳錶適合大量併發寫的場景,可以認為是隨機平衡的二叉搜尋樹,不存在紅黑樹的再平衡問題。Redis強大的ZSet底層資料結構就是雜湊加跳錶。

相比雜湊表和紅黑樹,跳錶用的不那麼多。

資料結構的實現優化

我們通常只會講資料的邏輯結構,但資料的實現(儲存)結構也會影響效能。

陣列在儲存上一定是邏輯地址連續的,但連結串列不具有這樣的特點,連結串列通過鏈域尋找臨近節點,如果相鄰節點在地址上發散,則沿著鏈域訪問效率不高,所以實現上可以通過從單獨的記憶體配置器分配結點(儘量記憶體收斂)來優化訪問效率,同樣的方法也適應紅黑樹、雜湊表等其他結構。

排序

儘量對指標、索引、ID排序,而不要對物件本身排序,因為交換物件比交換地址/索引慢;求topN不要做全排序;非穩定排序能滿足要求不要搞穩定排序。

延遲計算 & 寫時拷貝

延遲計算和寫時拷貝(COW)思想上是一樣的,即可以通過把計算儘量推遲來減少計算開銷。

我拿遊戲伺服器開發來舉例,假設玩家的戰鬥力(fight)是通過等級,血量,稱號等其他屬性計算出來的,我們可以在等級、血量、稱號變化的時候立即重算fight,但血量可能變化比較頻繁,所以就會需要頻繁重算戰力。通過延遲計算,我們可以為戰力新增一個dirtyFlag,在等級、血量、稱號變化的時候設定dirtyFlag,等到真正需要用到戰力的時候(GetFight函式)裡判斷dirtyFlag,如果dirtyFlag為true則重算戰力並清dirtyFlag,如果dirtyFlag為false則直接返回fight值。

寫時拷貝(COW)跟這個差不多,linux kernel在fork程式的時候,子程式會共享父程式的地址空間,只有在子程式對自身地址空間寫的時候,才會clone一份出來,同樣,string的設計也用到了類似的思想。

預計算

有些值可以提前計算出結果並儲存起來,不用重複計算的儘量不重複計算,特別是迴圈內的計算,要避免重複的常量計算,C++甚至增加了一個constexpr的關鍵詞。

增量更新

增量更新的原理不復雜,只做增量,只做DIFF,不做全量,這個思想有很多應用場景。

舉個例子,遊戲伺服器每隔一段時間需要把玩家的屬性(比如血量、魔法值等)同步到客戶端,簡單的做法是把所有屬性打包一次性全傳送過去,這樣比較耗費頻寬,可以考慮為每個屬性編號,在傳送的時候,只傳送變化的屬性。

在傳送端,編碼一個變化的屬性的時候,需要傳送一個屬性編號+屬性值的對子,接收端類似,先解出屬性編號,再解出屬性值,這種方式可能需要犧牲一點CPU換頻寬。

3、程式碼優化

記憶體優化

(a)小物件分配器

C的動態記憶體分配是介於系統和應用程式的中間層,malloc/free本身體現的就是一種按需分配+複用的思想。

當你呼叫malloc向glibc的動態記憶體分配器ptmalloc申請6位元組的記憶體,實際耗費的會大於6位元組,6是動態分配塊的有效載荷,動態記憶體分配器會為chunk新增首部和尾部,有時候還會加一下填充,所以,真正耗費的儲存空間會遠大於6位元組,在我的機器上,通過malloc_usable_size發現申請6位元組,返回的chunk,實際可用的size為24,加上首尾部就更多了。

但你真正申請(可用)的大小是6位元組,可見,動態記憶體分配的chunk內有大量的碎片,這就是內碎片,而外碎片是存在chunk之間的,是另一個問題。

當你申請的size較大,有效載荷 / 耗費空間的比例會比較高,內碎片佔比不高,但但size較小,這個佔比就高,如果這種小size的chunk非常多,就會造成記憶體的極大浪費。

《C++設計新思維》一書中的loki庫實現了一個小物件分配器,通過隱式連結串列的方式解決了這個問題,有興趣的可以去看看。

(b)cached obj

《C++ Primer》實現了一個CachedObj類别範本,任何需要擁有這種cached能力的型別都可以通過從CachedObj<T>派生而獲得。

它的主要思想是為該種型別維護一個FreeList,每個節點就是一個Object,物件申請的時候,檢查FreeList,如果FreeList不為空,則摘除頭結點返回,如果FreeList為空,則new一批Object並串到FreeList,然後走FreeList不為空的分配流程,通過過載類的operator new和operator delete,達到對類的使用者透明的目的。

(c)記憶體分配和物件構建分離

c的malloc用來動態分配記憶體,free用來歸還記憶體;C++的new做了3件事,通過operator new(本質上等同malloc)分配記憶體,在分配的記憶體上構建物件,返回物件指標;而delete幹了兩件事,呼叫解構函式,歸還記憶體。

C++通過placement new可以分離記憶體分配和物件構建,結合顯示的解構函式呼叫,達到自控的目的。

我優化過一個遊戲專案,啟動時間過長,記憶中需要幾十秒(至少十幾秒),分析後發現主要是因為遊戲執行預分配策略(物件池),在啟動的時候按最大容量建立怪和玩家,物件構建很重,大量物件構建耗時過長,通過分離記憶體分配和物件構建,把物件構建推遲到真正需要的時候,實現了服務的重啟秒起。

(d)記憶體複用

編解碼、加解密、序列化反序列化(marshal/unmarshal)的時候一般都需要動態申請記憶體,這種呼叫頻次很高,可以考慮用靜態記憶體,為了避免多執行緒競爭,可以用thread local。

當然你也可以改進靜態記憶體策略,比如封裝一個GetEncodeMemeory(size_t)函式,維護一個void* + size_t結構體物件(初始化為NULL+0),對比引數size跟物件的size成員,如果引數size<=物件size,直接返回物件大的void*指標,否則free掉void*指標,再按引數size分配一個更大的void*,並用引數size更新物件size。

cache優化

i-cache優化:i-cache的優化可以通過精簡code path,簡化呼叫關係,減少程式碼量,減少複雜度來實現。

具體措施包括,減少函式呼叫(就地展開、inline),利用分支預測,減少函式指標,可以考慮把code path上相關的函式定義在一起,把相關的函式定義到一個原始檔,並讓它們在原始檔上臨近,這樣生成的object檔案,在執行時載入後相關函式大概率也記憶體臨近,當然編譯器也一直在做這方面的努力,但我們寫程式碼不應該依賴編譯器優化,儘量去幫助編譯器生成更高效的程式碼。

d-cache優化:d-cache優化包括改進資料結構和演算法獲取更好的資料訪問時空區域性性,比如二分查詢就是d-cache友好演算法。一個cache line一般是64B,如果資料跨越兩個cache-line,則會導致load & store2次,所以,需要結合cache對齊,儘量讓相關訪問的資料在一個cache-line。

如果結構體過大,則各成員不僅可能在不同cache-line,甚至可能在不同page,所以應該避免結構體過大。

如果結構體的成員變數過多,一般而言對各成員的訪問頻次也會滿足2-8定律,可以考慮把hot和cold的成員分開,重排結構體成員變數順序,但這些騷操作我不建議在開始的時候用,因為說不定哪天又要增刪成員,從而破壞苦心孤詣搭建的積木。

判斷前置

判斷前置指在函式中講判斷返回的語句前置,這樣不至於忙活半天,你跟我說對不起不合適,玩兒呢?

在寫多個判斷的時候,把不滿足可能性高的放在前面。

在寫條件或的時候把為true的放在前面,在寫條件與的時候把為false的放在前面。

另外,如果在迴圈裡呼叫一個函式,而這個函式裡檢查某條件,不符合就返回,這種情況,可以考慮把檢查放到呼叫函式的外面,這樣不滿足的話就不用陷入函式,當然,你也可以說,這樣的操作違背軟體工程,但看你想要什麼,你不總是能夠兩全其美,對吧?

湊零為整與化整為零

湊零為整其實的思想在日誌批處理裡提了,不再展開。

化整為零體現了分而治之的思想,可以把一個大的操作,分攤開來,避免在做大操作的時候導致卡頓,從而讓CPU佔比更加平穩。

分頻

之前我優化過一個遊戲伺服器,遊戲伺服器的邏輯執行緒是一個大迴圈,裡面呼叫tick函式,tick函式裡呼叫了所有需要check timer & do的事情,然後所有需要check timer & do的事情都塞進tick裡。

改進:tick裡呼叫了tick50ms、tick100ms、tick500ms,tick1000ms,tick5000ms,然後把需要check timer & do的邏輯根據精度要求塞到不同的tickXXms裡去。

減法

  • 減少冗餘
  • 減少拷貝、零拷貝
  • 減少引數個數(暫存器引數、取決於ABI約定)
  • 減少函式呼叫次數/層次
  • 減少儲存引用次數
  • 減少無效初始化和重複賦值

迴圈優化

這方面的知識很多,感覺一下子講不完,提幾點,迴圈套迴圈要內大外小,儘量把邏輯提取到迴圈外。

  • 提取與迴圈無關的表示式,儘量減少迴圈內不必要計算。
  • 迴圈內儘量使用區域性變數。
  • 迴圈展開是一種程式變換,通過增加每次迭代計算的元素的數量,減少迴圈的迭代次數。
  • 還有迴圈分塊的騷操作。

防禦性程式設計適可而止

有兩個流派,一個是完全的不信任,即所有函式呼叫裡都對引數判斷,包括判空,有效性檢查等,但這樣做有幾點不好:

  • 第一,它只是貌似更安全,並不是真的更安全。
  • 第二,它稀釋程式碼濃度,淹沒關鍵語句。
  • 第三,如果通過返回值報告錯誤,則加重了呼叫者負擔,呼叫者需要新增額外程式碼檢查,不然更奇怪。
  • 第四,重複判斷空耗CPU。
  • 第五,埋雷,把本該crash或者暴露的問題埋得更深。

但這種做法大行其道,它有一定的市場和道理。

另一個是界定邊界,區分公開介面和內部實現,檢查只在模組之間進行,就相當於進園區的時候,門衛會檢查你證件,但之後,則不再檢查。因為內部實現是受控的安全上下文,開發者應該完全cover住。

我主張防禦性程式設計適可而止,一些著名的開源專案也不會做過多防禦,比如linux kernel、NGINX、skynet等,但現實中,軟體開發通常多人合作,每個開發者素質不一樣,這就是客觀現實,所以我也理解前一種做法。

release乾淨

開發過程中,我們會加很多診斷資訊,比如我們可能接管記憶體分配,從而附加額外的首尾部,通過填寫magic Num捕獲異常或者記憶體越界,但這些資訊應該只用於開發階段的DEBUG需要,在release階段應該通過預處理的方式刪除掉。

日誌分級其實也體現了這種思想,通常有兩種做法,一個是定義級別變數,另一個是預處理,預處理乾淨,但需要重新編譯生成image,而變數更靈活,但變數的比較還是有開銷的。

不要忽視這些診斷除錯資訊的開銷,牢記不必做的事情絕不做的原則。

慎用遞迴

遞迴的寫法簡單,理解起來也容易,但遞迴是函式呼叫,有棧幀建立撤銷控制跳轉的開銷,另外也有爆棧的風險,在效能敏感關鍵路徑,優先考慮用非遞迴版本。

4、編譯優化與優化選項

  • inline
  • restrict
  • LTO
  • PGO
  • 優化選項

5、其他優化

  • 綁核

  • SIMD

  1. 鎖與併發
  2. 鎖的粒度
  3. 無鎖程式設計
  4. Per-cpu data structure & thread local
  5. 記憶體屏障
  6. 異構優化/TCO優化

比如用GPGPU、FPGA、SmartNIC來offload原來cpu的任務,TCO優化指的是不以效能優化為單一指標,而是在滿足效能條件下以綜合成本為優化直播,當然異構也包括主動利用CPU的avx或者其他邏輯單元,這類優化往往編譯器不能自動展開(@zrg)

常識和資料

CPU拷貝資料一般一秒鐘能做到幾百兆,當然每次拷貝的資料長度不同,吞吐不同。

一次函式執行如果耗費超過1000 cycles就比較大了(刨除呼叫子函式的開銷)。

pthread_mutex_t是futex實現,不用每次都進入核心,首次加解鎖大概耗時4000-5000 cycles左右,之後,每次加解鎖大概120 cycles,O2優化的時候100 cycles,spinlock耗時略少。

lock記憶體匯流排+xchg需要50 cycles,一次記憶體屏障要50 cycles。

有一些無鎖的技術,比如CAS,比如linux kernel裡的kfifo,主要利用了整型迴繞+記憶體屏障。

幾個如何?

1. 如何定位CPU瓶頸?

CPU是通常大家最先關注的效能指標,巨集觀維度有核的CPU使用率,微觀有函式的CPU cycle數,根據效能的模型,效能規格與CPU使用率是互相關聯的,規格越高,CPU使用率越高,但是處理器的效能往往又受到記憶體頻寬、Cache、發熱等因素的影響,所以CPU使用率和規格引數之間並不是簡單的線性關係,所以效能規格翻倍並不能簡單地翻譯成我們的CPU使用率要優化一倍。

至於CPU瓶頸的定位工具,最有名也是最有用的工具就是perf,它是效能分析的第一步,可以幫我們找到系統的熱點函式。就像人看病一樣,只知道症狀是不夠的,需要通過醫療機器進一步分析病因,才能對症下藥。

所以我們通過效能分析工具PMU或者其他工具去進一步分析CPU熱點的原因比如是指令數本身就比較多,還是Cache miss導致的等,這樣在做效能優化的時候不會走偏。

優化CPU的目標就是讓CPU執行不受阻礙。

2. 如何定位IO瓶頸?

系統IO的瓶頸可以通過CPU和負載的非線性關係體現出來。當負載增大時,系統吞吐量不能有效增大,CPU不能線性增長,其中一種可能是IO出現阻塞。

系統的佇列長度特別是傳送、寫磁碟執行緒的佇列長度也是IO瓶頸的一個間接指標。

對於網路系統來講,我建議先從外部觀察系統。所謂外部觀察是指通過觀察外部的網路報文交換,可以用tcpdump, wireshark等工具,抓包看一下。

比如我們優化一個RPC專案,它的吞吐量是10TPS,客戶希望是100TPS。我們使用wireshark抓取TCP報文流,可以分析報文之間的時間戳,響應延遲等指標來判斷是否是由網路引起來的。

然後可以通過netstat -i/-s選項檢視網路錯誤、重傳等統計資訊。還可以通過iostat檢視cpu等待IO的比例。IO的概念也可以擴充套件到程式間通訊。

對於磁碟類的應用程式,我們最希望看到寫磁碟有沒有時延、頻率如何。其中一個方法就是通過核心ftrace、perf-event事件來動態觀測系統。比如記錄寫塊裝置的起始和返回時間,這樣我們就可以知道磁碟寫是否有延時,也可以統計寫磁碟時間耗費分佈。有一個開源的工具包perf-tools裡面包含著iolatency, iosnoop等工具。

3. 如何定位IO瓶頸?

應用程式常用的IO有兩種:Disk IO和網路IO。判斷系統是否存在IO瓶頸可以通過觀測系統或程式的CPU的IO等待比例來進行,比如使用mpstat、top命令。

系統的佇列長度特別是傳送、寫磁碟執行緒的佇列長度也是IO瓶頸的一個重要指標。

對於網路 IO來講,我們可以先使用netstat -i/-s檢視網路錯誤、重傳等統計資訊,然後使用sar -n DEV 1和sar -n TCP,ETCP 1檢視網路實時的統計資訊。ss (Socket Statistics)工具可以提供每個socket相關的佇列、快取等詳細資訊。

更直接的方法可以用tcpdump, wireshark等工具,抓包看一下。

對於Disk IO,我們可以通過iostat -x -p xxx來檢視具體裝置使用率和讀寫平均等待時間。如果使用率接近100%,或者等待時間過長,都說明Disk IO出現飽和。

一個更細緻的觀察方法就是通過核心ftrace、perf-event來動態觀測Linux核心。比如記錄寫塊裝置的起始和返回時間,這樣我們就可以知道磁碟寫是否有延時,也可以統計寫磁碟時間耗費分佈。有一個開源的工具包perf-tools裡面包含著iolatency, iosnoop等工具。

4.如何定位鎖的問題?

大家都知道鎖會引入額外開銷,但鎖的開銷到底有多大,估計很多人沒有實測過,我可以給一個資料,一般單次加解鎖100 cycles,spinlock或者cas更快一點。

使用鎖的時候,要注意鎖的粒度,但鎖的粒度也不是越小越好,太大會增加撞鎖的概率,太小會導致程式碼更難寫。

多執行緒場景下,如果cpu利用率上不去,而系統吞吐也上不去,那就有可能是鎖導致的效能下降,這個時候,可以觀察程式的sys cpu和usr cpu,這個時候通過perf如果發現lock的開銷大,那就沒錯了。

如果程式卡住了,可以用pstack把堆疊打出來,定位死鎖的問題。

5. 如何提⾼Cache利用率?

記憶體/Cache問題是我們常見的負載瓶頸問題,通常可利用perf等一些通用工具來輔助分析,優化cache的思想可以從兩方面來著手,一個是增加區域性資料/程式碼的連續性,提升cacheline的利用率,減少cache miss,另一個是通過prefetch,降低miss帶來的開銷。

通過對資料/程式碼根據冷熱進行重排分割槽,可提升cacheline的有效利用率,當然觸發false-sharing另當別論,這個需要根據執行trace進行深入調整了;說到prefetch,用過的人往往都有一種體會,現實效果比預期差的比較遠,確實無論是資料prefetch還是程式碼prefetch,不確定性太大,指望編譯器更靠譜點。

小結

效能優化是一項細緻的工作,效能優化也是一個系統性工程。效能優化通常是在現有系統和程式碼基礎上做改進,它並非推倒重來,考驗的是開發者反向修復的能力,而效能設計考驗的是設計者的正向設計能力,但效能優化的方法可以指導效能設計,兩者互補。

 

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章