本文轉自 code2life,查看原文請點擊 這里
目錄
- 軟件設計雜談——性能優化的十種手段(上篇),介紹六種普適的性能優化方法,包括 索引、壓縮、緩存、預取、削峰填谷、批量處理,簡單講解每種技術手段的原理和實際應用;
- 軟件設計雜談——性能優化的十種手段(中篇),介紹程序是如何消耗執行時間和內存空間的;
- 軟件設計雜談——性能優化的十種手段(下篇),介紹另外幾類涉及更多技術細節的性能優化方向。
引言
上一篇,我們總結了六種普適的性能優化方法,包括 索引、壓縮、緩存、預取、削峰填谷、批量處理,簡單講解了每種技術手段的原理和實際應用。在開啟最后一篇前,我們先需要搞清楚:在程序運行期間,時間和空間都耗在哪里了?
時間都去哪兒了?
人眨一次眼大約100毫秒,而現代1核CPU在一眨眼的功夫就可以執行數億條指令。
現代的CPU已經非常厲害了,頻率已經達到了GHz級別,也就是每秒數十億個指令周期。
即使一些CPU指令需要多個時鐘周期,但由于有流水線機制的存在,平均下來大約每個時鐘周期能執行1條指令,比如一個3GHz頻率的CPU核心,每秒大概可以執行20億到40億左右的指令數量。
程序運行還需要RAM,也可能用到持久化存儲,網絡等等。隨著新的技術和工藝的出現,這些硬件也越來越厲害,比如CPU高速緩存的提升、NVMe固態硬盤相對SATA盤讀寫速率和延遲的飛躍等等。這些硬件具體有多強呢?
有一個非常棒的網站“Latency Numbers Every Programmer Should Know”,可以直觀地查看從1990年到現在,高速緩存、內存、硬盤、網絡時間開銷的具體數值。
https://colin-scott.github.io/personal_website/research/interactive_latency.html
下圖是2020年的截圖,的確是“每個開發者應該知道的數字”。
這里有幾個非常關鍵的數據:
- 存取一次CPU多級高速緩存的時間大約1-10納秒級別;
- 存取一次主存(RAM)的時間大概在100納秒級別;
- 固態硬盤的一次隨機讀寫大約在10微秒到1毫秒這個數量級;
- 網絡包在局域網傳輸一個來回大約是0.5毫秒。
看到不同硬件之間數量級的差距,就很容易理解性能優化的一些技術手段了。
比如一次網絡傳輸的時間,是主存訪問的5000倍,明白這點就不難理解寫for循環發HTTP請求,為什么會被扣工資了。
放大到我們容易感知的時間范圍,來理解5000倍的差距:如果一次主存訪問是1天的話,一趟局域網數據傳輸就要13.7年。
如果要傳輸更多網絡數據,每兩個網絡幀之間還有固定的間隔(Interpacket Gap),在間隔期間傳輸Idle信號,數據鏈路層以此來區分兩個數據包,具體數值在鏈接Wiki中有,這里截取幾個我們熟悉的網絡來感受一下:
- 百兆以太網: 0.96 μs
- 千兆以太網:96 ns
- 萬兆以太網:9.6 ns
不過,單純看硬件的上限意義不大,從代碼到機器指令中間有許多層抽象,僅僅是在TCP連接上發一個字節的數據包,從操作系統內核到網線,涉及到的基礎設施級別的軟硬件不計其數。到了應用層,單次操作耗時雖然沒有非常精確的數字,但經驗上的范圍也值得參考:
- 用Memcached/Redis存取緩存數據:1-5 ms
- 執行一條簡單的數據庫查詢或更新操作:5-50ms
- 在局域網中的TCP連接上收發一趟數據包:1-10ms;廣域網中大約10-200ms,視傳輸距離和網絡節點的設備而定
- 從用戶態切換到內核態,完成一次系統調用:100ns - 1 μs,視不同的系統調用函數和硬件水平而定,少數系統調用可能遠超此范圍。
空間都去哪兒了?
在計算機歷史上,非易失存儲技術的發展速度超過了摩爾定律。除了嵌入式設備、數據庫系統等等,現在大部分場景已經不太需要優化持久化存儲的空間占用了,這里主要講的是另一個相對稀缺的存儲形式 —— RAM,或者說主存/內存。
以JVM為例,在堆里面有很多我們創建的對象(Object)。
- 每個Object都有一個包含Mark和類型指針的Header,占12個字節
- 每個成員變量,根據數據類型的不同占不同的字節數,如果是另一個對象,其對象指針占4個字節
- 數組會根據聲明的大小,占用N倍于其類型Size的字節數
- 成員變量之間需要對齊到4字節,每個對象之間需要對齊到8字節
如果在32G以上內存的機器上,禁用了對象指針壓縮,對象指針會變成8字節,包括Header中的Klass指針,這也就不難理解為什么堆內存超過32G,JVM的性能直線下降了。
舉個例子,一個有8個int類型成員的對象,需要占用48個字節(12+32+4),如果有十萬個這樣的Object,就需要占用4.58MB的內存了。這個數字似乎看起來不大,而實際上一個Java服務的堆內存里面,各種各樣的對象占用的內存通常比這個數字多得多,大部分內存耗在char[]這類數組或集合型數據類型上。
堆內存之外,又是另一個世界了。
從操作系統進程的角度去看,也有不少耗內存的大戶,不管什么Runtime都逃不開這些空間開銷:每個線程需要分配MB級別的線程棧,運行的程序和數據會緩存下來,用到的輸入輸出設備需要緩沖區……
代碼“寫出來”的內存占用,僅僅是冰山之上的部分,真正的內存占用比“寫出來”的要更多,到處都存在空間利用率的問題。
比如,即使我們在Java代碼中只是寫了 response.getWriter().print(“OK”),給瀏覽器返回2字節,網絡協議棧的層層封裝,協議頭部不斷增加的額外數據,讓最終返回給瀏覽器的字節數遠超原始的2字節,像IP協議的報頭部就至少有20個字節,而數據鏈路層的一個以太網幀頭部至少有18字節。
如果傳輸的數據過大,各層協議還有最大傳輸單元MTU的限制,IPv4一個報文最大只能有64K比特,超過此值需要分拆發送并在接收端組合,更多額外的報頭導致空間利用率降低(IPv6則提供了Jumbogram機制,最大單包4G比特,“浪費”就減少了)。
這部分的“浪費”有多大呢?下面的鏈接有個表格,傳輸1460個字節的載荷,經過有線到無線網絡的轉換,至少再添120個字節,空間利用率<92.4%。
https://en.wikipedia.org/wiki/Jumbo_frame
這種現象非常普遍,使用抽象層級越高的技術平臺,平臺提供高級能力的同時,其底層實現的“信息密度”通常越低。像Java的Object Header就是使用JVM的代價,而更進一步使用動態類型語言,要為靈活性付出空間的代價則更大。哈希表的自動擴容,強大的反射能力等等,背后也付出了空間的代價。
再比如,二進制數據交換協議通常比純文本協議更加節約空間。但多數廠家我們仍然用JSON、XML等純文本協議,用信息的冗余來換取可讀性。即便是二進制的數據交互格式,也會存在信息冗余,只能通過更好的協議和壓縮算法,盡量去逼近壓縮的極限 —— 信息熵。
結語
理解了時間和空間的消耗在哪后,還不能完全解釋軟件為何傾向于耗盡硬件資源。有一條定律可以解釋,正是它錘爆了摩爾定律。
它就是安迪-比爾定律。
“安迪給什么,比爾拿走什么”。
安迪指的是Intel前CEO安迪·葛洛夫,比爾指的是比爾·蓋茨。這句話的意思就是:軟件發展比硬件還快,總能吃得下硬件。20年前,在最強的計算機也不見得可以玩賽車游戲;10年前,個人電腦已經可以玩畫質還可以的3D賽車游戲了;現在,自動駕駛+5G云駕駛已經快成為現實。在這背后,是無數的硬件技術飛躍,以及吃掉了這些硬件的各類軟件。這也是我們每隔兩三年都要換手機的原因:不是機器老化變卡了,是嗜血的軟件在作怪。
因此,即使現代的硬件水平已經強悍到如此境地,性能優化仍然是有必要的。軟件日益復雜,抽象層級越來越高,就越需要底層基礎設施被充分優化。對于大部分開發者而言,高層代碼逐步走向低代碼化、可視化,“一行代碼”能產生的影響也越來越大,寫出低效代碼則會吃掉更多的硬件資源。