官术网_书友最值得收藏!

1.2 效率的常見誤區

在代碼審查或沖刺計劃(sprint planning)過程中,往往更加關注軟件的功能實現,而經常會忽略掉軟件性能,這在軟件研發過程中是十分常見的。那些針對優化編寫的代碼在提交時往往會被打上“非必要”的標簽,甚至被駁回,也可能因為一些常識性的錯誤或誤解而被駁回。所以當你看到一些概括性的陳述時,需要更加謹慎,查明其真實意義從長遠看可能會幫你節省大量的開發成本。

1.2.1 誤區1:優化后的代碼可讀性差

軟件代碼最重要的特性之一,就是需要具備良好的可讀性。

代碼具備良好的可讀性比炫技更重要,調試和修改那些可讀性差的代碼會令人非常難受。此外,程序的過度優化與過度設計也會增加額外的風險。

——Brian W.Kernighan和P.J.Plauger,The Elements ofProgramming Style(McGraw-Hill,1978)

眾所周知,優化程序運行效率的最直接方式是使用大量的位運算、字節填充、循環展開等,或者直接使用匯編代碼進行部分功能實現。這些方式看起來十分炫酷,但實際上是很低級的實現(low-level optimization)。這些低級的實現會使代碼變得難以閱讀與維護,過度優化會明顯增加程序復雜度與認知難度。所以一旦提及優化,軟件工程師自然就會聯想到可能會帶來這種極端的復雜性,進而對效率優化產生抵觸心理。因為在他們的認知當中,效率優化會對程序可讀性帶來負面影響。本節的目的是為讀者展示軟件運行效率與可讀性共存的方法,使優化后的代碼與之前一樣清晰易懂。

同樣,如果因為增加功能或其他因素而更改代碼,也會面臨同樣的問題。例如,因為擔心降低可讀性而拒絕編寫更高效的代碼,等同于為避免復雜性而拒絕開發重要的功能。所以,諸多問題回歸于合理性的范疇。我們可以考慮縮減功能范圍,但應該首先評估合理性。這同樣適用于提高軟件的運行效率。在編寫代碼時,需要權衡代碼的可讀性和運行效率,以選擇最合理的方案。

例如,如果想要對函數入參進行額外的驗證,則可以直接在處理函數中粘貼一段長達50行的if-else代碼。顯然,這一定會令未來的維護者或幾個月后回顧這段代碼的你感到沮喪。相反,如果將新增內容封裝到一個形如func validate(input string)error的函數中,只在原先的代碼邏輯中增加輕微的復雜性,情況是不是會好很多呢?此外,為了避免對原先的代碼邏輯造成沖擊,可以將驗證邏輯放在調用方或中間件中。另外,還可以重新思考系統設計,將驗證復雜性的邏輯轉移到另一個系統或組件中,從而避免實現這個功能。實現特定功能的方式有很多種。

代碼效率優化和實現更多功能有什么不同?筆者認為它們本質上并沒有區別。你可以像設計功能一樣,在考慮可讀性的同時設計效率優化。如果不考慮抽象,兩者對閱讀者來說都是完全透明的。[5]

然而,代碼效率優化常被視為可讀性問題的主要來源。本節介紹的其他誤區經常被用作忽視效率優化的借口。這通常會導致所謂的過早悲觀(premature pessimization,是指在軟件設計和開發過程中,過于關注可能出現的負面情況或問題,而忽略了積極樂觀的方面和可能性),過度擔憂可讀性會讓程序效率變得更加低下。

給自己留點余地,也給代碼留點余地:在條件等同的情況下,尤其是代碼的復雜性和可讀性,高效的設計模式和編碼習慣對于開發者來說應該是自然而然的,而且要比那些過度優化(即不必要的)的替代方案更容易編寫。這并不是過早的優化,而是避免無謂的(不必要的)過度優化。

——H.Sutter和A.Alexandrescu,C++Coding Standards:101 Rules,Guidelines,and Best Practices(Addison-Wesley,2004)

代碼可讀性至關重要,筆者甚至認為,難以閱讀的代碼一般性能也比較差。隨著軟件的發展,我們很容易破壞前人精心設計的優化,因為我們不了解或誤解了它的設計初衷。與錯誤和缺陷類問題相似,復雜的代碼更加容易引發性能問題。在第10章中,你將會了解到如何在保證代碼的可維護性和可讀性的同時來做效率優化。

代碼可讀性很重要

優化可讀代碼比使高度優化的代碼可讀要容易得多。這對于可能試圖優化代碼的人和編譯器來說都是如此。

由于在編寫代碼的過程中沒有遵循一些針對效率的良好設計規范優化通常會導致代碼可讀性降低。如果從一開始就拒絕考慮效率問題,之后也很難做到在不影響可讀性的同時實現對代碼的優化。有些事情必須從一開始就考慮,后續所有的補救都為時已晚。在設計API和抽象之初,有太多的機會與時間來找到一種更優的實現方式。正如第3章將要了解到的,我們可以在系統的不同層面進行性能優化,而不僅僅是優化代碼。也許我們可以選擇一些運行效率更高的算法、更優的數據結構或設計更合理的系統架構。這會比在軟件發布后再來做優化的效果更好。但在一些約束條件(比如向后兼容、集成性或嚴格的接口定義)下,提高性能的唯一方式只能在代碼或系統中引入額外的復雜性。

優化后的代碼可讀性更強

令人驚訝的是,經過優化的代碼反而可讀性更好!下面來看幾個Go代碼示例。代碼示例1-1是一個getter模式的代碼片段,筆者在審查學生或初級開發人員的Go代碼時已經看到過數百次。

代碼示例1-1:一個計算錯誤比率的簡單邏輯實現

? 這是一個簡化的例子,在Go程序開發中有一種常用的模式,即通過函數或接口來獲得操作所需的元素,而不是直接通過傳參的方式。當元素被動態添加、緩存或從遠程數據庫中提取時,這種模式非常有用。

? 可能會執行多次Get函數來獲取錯誤比率。

代碼示例1-1在大多數情況下都能正常工作,它簡單且易于閱讀。但是,由于可能存在的效率和準確率問題,需要對代碼示例1-1進行簡單的修改,如代碼示例1-2所示。

代碼示例1-2:計算錯誤比率的更高效的邏輯實現

? 與代碼示例1-1相比,代碼示例1-2只執行一次Get函數,復用一個got臨時變量來保存結果,這比在循環體內多次調用Get函數更加高效。

一些開發者可能會認為,像FailureRatio這種函數在實際應用中可能很少使用,它不是關鍵路徑上的函數,而且當前的ReportGetter實現非常容易且快速。如果沒有進行基準測試,實際上無法直接確定哪種方法更高效,這時候這種優化策略就會被稱為“過早優化”。

然而,有編程經驗的人都能看出來,代碼示例1-2是一種正確的優化方向。這是一個典型的過早悲觀的案例,雖然它并不會為程序的運行帶來多少速度上的提升,但也沒什么壞處。筆者仍然認為代碼示例1-2在很多方面都性能優越:

在沒有測試的情況下,代碼示例1-2的效率更高

有接口就意味著可以替換實現方式,接口是開發者和實現方式之間的一種“契約”。從FailureRatio函數的內容來看,ReportGetter的實現方式基本已經定型,至少不能要求ReportGetter.Get的實現總是快速且簡單的[6],因為它可能會是非常耗費資源的I/O操作,例如,對文件系統的操作、使用互斥鎖的實現或對遠程數據庫的訪問[7]

當然,也可以在以后使用適當的效率流進行迭代和優化(將在3.6節中討論)。但如果這是一個常識性的優化點,在此處進行也無妨。

代碼示例1-2更能保證協程安全

協程安全是一個潛在問題,它并不顯而易見。如代碼示例1-1所示,當有多個協程同時影響ReportGetter.Get的結果時,如果不進行競態處理,就可能出現協程安全問題。在函數體內部最好避免競態條件并確保一致性。競態條件導致的錯誤往往是最難調試和檢測的,為安全起見,最好避免競態條件。

代碼示例1-2的可讀性更好

通過一個臨時變量來表示Get函數的結果集,可以最大限度地減少潛在的風險,并且讓代碼示例1-2的可讀性優于代碼示例1-1。

并非所有的代碼效率優化都是過早優化,但也不能將其當作拒絕或忘記做效率優化的借口。

另一個能提升代碼清晰度的優化案例見代碼示例1-3與代碼示例1-4。

代碼示例1-3:無任何優化的循環體

? 該函數返回一個變量名為slice的字符串切片,在函數調用開始的地方,底層默認會創建一個變量,該變量持有空字符串切片。

? 循環n次,每次往slice中添加7個字符串元素。

代碼示例1-3展示了在Go語言中如何往切片中添加元素,代碼表面上看上去并沒有什么問題,能正常運行。但是,在使用循環進行元素追加時,如果知道將要追加到切片中的元素數量,就不應該按照這種“常規”的方式進行追加,因為在Go語言中,如果沒有初始化切片容量,那么當切片的容量不足以存儲新元素時,Go語言會自動為其擴充一塊新的內存空間,這個操作是比較消耗資源的。如果預先知道將要追加到切片中的元素數量,則可以先對切片進行初始化,再通過循環添加元素。這樣做的好處是可以減少不必要的迭代次數,以及省去在底層進行切片擴容所帶來的消耗,從而提高代碼的效率,具體寫法參考代碼示例1-4。

代碼示例1-4:預分配優化的循環體會降低可讀性嗎

? 在函數開始的第一行,對slice變量進行初始化,為它分配n*7的內存空間。

? 循環n次,每次往slice中添加7個字符串元素。

在11.4節,我們還會結合在第4章中介紹的Go運行時知識來討論代碼示例1-2和代碼示例1-4這種“盡可能預先分配”的性能優化方法??偟膩碚f,這兩種方式都可以減少程序運行的開銷。在代碼示例1-4中,由于對切片進行了預先分配,在追加元素時Go底層不需要再對切片進行擴容。下面我們來思考另一個問題:這段代碼的可讀性如何?

可讀性的判定通常取決于人的主觀意識,筆者認為代碼示例1-4的可讀性更好。雖然它在函數體中增加了一行,使函數體的復雜性提升了少許,但增加的這一行的表意是十分明確的。它不僅幫助Go運行時做更少的工作,也表明了這個循環的最終目的和期望的迭代次數。

如果你不知道Go語言的內置函數make的基本用法,你可能會覺得代碼示例1-4可讀性差。如果你知道具體的用法且能在編寫代碼時正確使用,它就會成為一種好習慣。此后當你看到沒有初始化分配的切片時,潛意識就知道循環次數可能是不可預測的,因此你會變得更加謹慎。為了使這種習慣在Prometheus和Thanos代碼庫中得到充分應用,筆者的團隊甚至在Thanos的Go編碼風格指南中添加了一個相關條目(https://oreil.ly/Nq6tY)。

可讀性并非一成不變,需要合理評估

我們編寫代碼的能力會隨時間而變化,即使代碼從未改變,一些新生概念的提出也會讓我們的理解發生改變。了解一些關于編程的金科玉律,可以幫助我們更確切地理解一些復雜的代碼實現。

可讀性古今對比

開發人員經常會引用Knuth的那句“過早優化是萬惡之源”[8]來描述因為優化而帶來的可讀性問題。然而,這句話是很久以前說的。雖然我們可以從過去學到很多關于編程的知識,但自1974年以來,計算機科學在許多方面都有了巨大的進步。例如,那時流行在變量名稱中添加有關變量類型的信息,如代碼示例1-5所示[9]。

代碼示例1-5:使用匈牙利表示法編寫Go代碼

在以前,匈牙利表示法很有用,因為編譯器和集成開發環境(IDE)在那時還不是很成熟。但如今,在先進的IDE以及GitHub等代碼托管網站上,將鼠標懸停在變量上,就能立即知道其類型。我們可以快速查看變量定義、閱讀注釋,并找到所有調用關系。隨著20世紀90年代中期出現的代碼提示、高亮顯示和面向對象編程的流行,一些編程工具可以在不顯著影響代碼可讀性的情況下完成功能和性能優化[10]。此外,可觀測性和調試工具的可訪問性和功能也已經大大提高,我們將在第6章中對此進行探討,以更快地理解大型代碼庫。

總之,性能優化就像我們軟件中的另一個不應忽視的功能。它可能會增加復雜性,但也有一些方法可以最大限度地減少理解代碼所需的認知負荷(cognitive load)[11]。

如何才能讓優化后的代碼可讀性更高

? 移除或避免非必要的優化。

? 將復雜的邏輯封裝在清晰的抽象(比如使用interface)當中。

? 將“熱”代碼(需要更高性能的關鍵部分)與“冷”代碼(很少執行的部分)分開。

正如我們在本章中所了解到的,在某些情況下,經過優化的程序往往更加簡單、清晰,解釋性更強。

1.2.2 誤區2:YAGNI原則

YAGNI(You Aren't Going to Need It)原則,字面意思為“你不會用上它”,是一個在軟件研發工作中非常流行的原則。該原則的基本含義是,不應該去開發任何當前不會使用到的功能。

極限編程(Extreme Programming,XP)中最廣為人知的原則之一是“You Aren't Going to Need It”,即YAGNI原則。YAGNI原則強調了在投資回報不確定的情況下推遲投資決策。在極限編程的背景下則表示需要暫緩實現那些模糊的特性和功能,直到其不確定性得到解決。

——Hakan Erdogmu和John Favaro,“Keep Your Options Open:Extreme Programming and the Economics of Flexibility”

YAGNI原則強調避免去做那些需求范圍之外的工作。它有一個前提,即需求在快速變化,軟件也需要快速迭代。

我們來看一個案例。高級軟件工程師Katie接到一個開發任務,即創建一個簡單的Web服務。該任務并無任何特別之處,只需創建一個HTTP服務,并暴露出一些REST接口。Katie是一位經驗豐富的工程師,過去可能創建過100個類似的接口,她很快就開始編寫程序并測試服務。任務完成之后,她決定添加額外的功能:一個簡單的令牌授權層(https://oreil.ly/EuKD0)。Katie知道這樣的更改顯然超出了當前的需求,但她編寫過數百個REST接口,每個接口都有類似的授權層。經驗告訴她,這樣的需求可能很快就會出現,所以她要做好準備。你認為她做的這件事是有意義、且應該被接受的嗎?

雖然Katie展現出了豐富的經驗和職業素養,但我們應該避免去做這樣的事,以保持這個Web服務的功能特性的純粹和整體的開發成本效益。換句話說,她應該遵循YAGNI原則。這是為什么呢?因為在大多數情況下,功能特性都是難以預測的。嚴格遵循需求可以節省開發時間、降低代碼復雜性。如果Katie編寫的那個Web服務只是作為一個demo,永遠不需要授權層,那就是畫蛇添足,假如Web服務需要在專用授權代理后面運行,在這種情況下,Katie編寫的額外代碼即使不被使用也會帶來高昂的成本,徒增認知負荷。此外,維護或重構這樣的代碼也會給工作增加難度。

現在,我們告訴Katie應該遵循YAGNI原則,她隨即將那些授權代碼刪除。但她又決定在代碼中增加一些指標來監控服務。這一決定是否也違反了YAGNI原則呢?

如果監控是需求的一部分,則不違反YAGNI原則;如果不是,在不了解需求背景的情況下,就不一定。關鍵監控應在需求中明確提及,但如果需求沒有明確提及監控的范圍,常識告訴我們,We b服務的可觀測性很重要,是有必要做的。不然我們怎么知道它是否還在運行呢?在這種情況下,從技術上講,Katie正在做一項能立即看到成效的工作,我們應該結合常識來判斷其是否合理。

又過了一會兒,Katie決定在某個必要的計算邏輯中添加一個簡單的緩存,以提高REST接口的性能。她編寫并執行了一個快速基準測試,來驗證接口的延遲和資源消耗是否得到改進。這是否違反YAGNI原則?

在軟件研發領域有一個令人悲傷的事實,即需求提出方的需求描述中經常缺少對性能效率和響應時間的要求。應用程序的性能目標往往是“能用”和“速度能被接受”,至于細節則很少有特定的描述。本書將在3.3.2節中討論如何定義實用的軟件效率需求。例如,我們假設一種最壞的情況,即需求列表中沒有任何關于性能的內容,那么能否說Katie違背了YAGNI原則呢?

同樣,如果沒有完整的需求背景,則很難判斷。實現一個健壯且可用的緩存并非易事,我們需要考慮這些問題:加了緩存功能的新代碼有多復雜?數據是否容易“被緩存”[12]?該接口的QPS實際可以達到多少?是否所有接口都需要加緩存?我們知道,當某種特定的數據被頻繁訪問時,使用緩存才具備實際意義。

因此,和上一個改動一樣,Katie也做了一項能立即看到成效的工作,但需要結合需求評估接口提供的性能保證。如果需要使用緩存才能實現性能,則沒有違反YAGNI原則;否則,違反YAGNI原則。

最后,Katie為服務做了合理的性能優化,這種優化和代碼示例1-4中的切片預分配優化類似。這是否違反YAGNI原則?

答案當然是違反。即使某些東西是普遍適用的,也需要確定是否應該去實現它。

筆者認為,即使在需求中沒有明確提及,那些不會降低代碼可讀性(有些甚至會提高代碼可讀性)的編程習慣(比如一些考慮性能的編程習慣)應該被開發人員牢記,本書將在3.1.1節中進行介紹。同樣,需求中也不會提及那些基本的最佳實踐方法,比如代碼版本控制、接口最小化原則,或者避免較大的依賴關系等。

本節主要介紹YAGNI原則,以希望幫助開發人員做出正確的決策,同時開發人員不能完全忽視代碼性能。積土成山,積水成淵,不要因為單次只能優化一點性能就不去做,當優化匯聚起來時,應用程序的性能就會迎來質的提升。理想情況下,定義明確的需求有助于澄清軟件的性能要求,但這也并不妨礙我們使用良好的編程習慣(優化意識)來處理程序細節。

1.2.3 誤區3:硬件變得更快、更廉價

當筆者開始從事編程工作時,那時的CPU處理速度非常慢,內存也十分有限,不像現在動輒幾十上百GB。當時的內存是以KB為單位的。所以,在那個時代,要做軟件研發就必須對內存的使用細節了如指掌,將內存的消耗優化到極致。

——Valentin Simonov,“Optimize for Readability First”(https://oreil.ly/I2NPk

毫無疑問,目前的硬件絕對比以往任何時候都更便宜且性能更強大。硬件的發展幾乎是以自然月為單位來衡量的。從1995年時鐘頻率為200MHz的單核奔騰CPU,到速度為3~4GHz的小型低功耗CPU。RAM大小從2000年的幾十MB增加到20年后的64GB,且頻率也越來越高。現在,機械硬盤(HDD)逐漸被替換成固態硬盤(SSD),誕生出了速度高達7GBps的NVME協議固態硬盤,存儲空間也高達幾十TB。目前的網絡傳輸速度已經達到100GBps的吞吐量。而在遠程存儲方面,以前使用的軟盤的空間只有1.44MB,發展到CD-ROM,容量可以達到553MB,再后來出現了藍光光碟、具備讀寫能力的DVD,而現在容量達到TB級的SD卡和U盤也很容易買到。

現在,我們來看一個目前普遍流傳的觀點,即硬件的每小時使用成本要比開發人員時薪低得多。有了這個背景,大家就會認為,代碼中的單個函數多占用1MB內存或者使用過多的磁盤空間都無關緊要,因為可以購買更大的服務器,算下來總費用還更低。那為什么不為軟件產品開發更多的功能,反而去做性能優化呢?或者為什么要去培養具有性能優化意識的工程師呢?要回答這個問題并不是那么簡單,下面我們來看看為什么這種觀點對于軟件研發來說是極其有害的。

首先,將投入性能優化的成本拿來買更多的硬件是一種目光短淺的行為。這就好比你的車壞了,馬上賣掉,去買一輛新車,因為你不想把錢花在修車上。這合理嗎?看起來沒有浪費錢,還換了新車,但實際上如果每次車壞了都這樣做,長期下來,成本是無法估量的。

假設一個軟件研發人員的年薪在20萬元左右(算上社保、福利等其他雇用員工的成本)(https://oreil.ly/AxI0Y),公司每年需要花費25萬元左右的成本,每月要支出2萬元以上。在2024年,用2萬元可以買到一臺具有32GB DDR4內存、雙路8核CPU、千兆網卡和12TB硬盤空間的服務器。暫且忽略電費與運維費用,假設軟件每個月會增加這么多資源消耗,那看起來買一臺這樣的服務器比雇用一名開發人員要劃算?很不幸,實際情況并非如此。

事實證明,如果不對應用程序進行優化,堆硬件的速度也趕不上程序占用資源的速度!圖1-2展示了Thanos的單個副本服務(一共6個副本)的堆內存剖析部分截圖(https://thanos.io),其在單個集群中運行了5天。筆者將在第9章中介紹如何進行內存剖析。圖1-2展示了服務進程啟動了5天以來,其中某個Series函數分配的總內存。

雖然大部分內存已經過GC釋放,但請注意,Thanos僅運行5天就占用了17.61TB的內存[13]。即使在桌面端應用程序開發領域,也可能會遇到類似的問題。以前面的例子為例,如果一個函數執行一次就多占用1MB的內存(不會釋放),則可能看起來沒多少,但當執行的次數多了,服務器擁有再大的內存也不夠用。所以,你還會覺得堆硬件劃算嗎?

另外,增加服務器并不是購買完成后就能馬上使用的,其中的成本還包括機架、網絡、驅動程序、操作系統和基礎軟件等,以及監控、更新和操作服務器的運維成本。如果這一切還是沒有問題,那新增的服務器是否也需要運維人員耗費精力來管理和維護呢?服務器足夠多時,是不是還需要新增運維人員呢?如此發展下去,最后會成為一個惡性循環,并且后續很大一部分工作都是為了填補之前濫用資源埋下的坑,而這一切的根源僅僅是因為忽略性能優化,是典型的“占小便宜,吃大虧”。

圖1-2:內存剖析顯示,由于流量洪峰,應用不到5天就將系統內存耗盡

另一方面,隨著硬件技術的進步,目前十分昂貴的10TB內存在未來也只是一個邊際成本。難道這樣我們就能忽略性能問題,將希望寄于服務器成本降低,或等待性能更好的手機和計算機的普及嗎?雖然等待比調試棘手的性能問題更加容易,但這是“坐以待斃”。

因此,對軟件做性能優化是一個不能省去的步驟,它至關重要,不要期望用硬件來彌補性能的缺陷。雖然硬件發展得很快,性能也在逐年提升,但它本質上仍是一種有限的資源,終會有耗盡的時候。下面來分析一下這種效應背后的三個主要原因。

軟件終會耗盡可用內存

這種效應被稱為帕金森定律(Parkinson's Law)[14]。帕金森定律指出,無論擁有多少資源,需求往往與供應剛好吻合。例如,帕金森定律在大學里隨處可見,無論教授為作業或考試預留出多少時間,學生總是會使用所有的時間,而且學生很可能在最后時刻才會完成大部分的任務[15]。我們在軟件研發中也可以看到類似的行為。

硬件發展速度跟不上軟件過時速度

Niklaus Wirth提到過一個叫作“胖軟件”(fat software)的術語,通常形容那些體積龐大、不夠精簡的軟件。這類軟件可能包含許多未使用的代碼、復雜的邏輯結構、冗余的數據處理等,導致其體積龐大且運行效率低下。這個術語可以解釋為什么軟件對硬件的要求越來越高。

硬件廠商總是宣傳升級硬件就能解決問題,但很多問題并非僅靠提升硬件就能解決,這些問題早已埋藏在軟件當中,并且這類軟件往往體量相當龐大。

——Niklaus Wirth,“A Plea for Lean Software”(https://oreil.ly/bctyb

硬件性能越來越強大,但軟件卻變得越來越慢,這一切都是因為軟件產品永遠要迎合市場,有良好的用戶體驗才有盈利的可能。良好的用戶體驗包括更炫酷的操作系統、發光的圖標、復雜的動畫、網站上的高清視頻,或者通過人臉識別技術模仿用戶面部表情的花哨表情符號。這是一場客戶與開發者之間永無止境的較量,其結果就是帶來更多的復雜性,從而增加對硬件資源的消耗。

除此之外,由于能夠更加方便地訪問計算機、服務器、手機、物聯網設備和任何其他類型的電子產品,軟件的大眾化普及得以快速實現。如Marc Andreessen所說:“軟件正在吞噬世界?!保?i>https://oreil.ly/QUND4)。2019年末暴發的新冠疫情進一步加速了數字化進程,互聯網服務已然成為現代社會的關鍵支柱。世界每天都在增加計算力,但旺盛的需求仍然使其供不應求。筆者認為,造成“供不應求”的罪魁禍首可能就隱藏在某段函數代碼中,研發時多考慮一些優化,也許負擔就沒有那么重。留意一下常用的軟件就能知道,比如,日常使用的社交媒體,僅Facebook每天就能產生4PB(https://oreil.ly/oowCN)的數據[16]。搜索引擎的需求也很旺盛,這導致谷歌每天需要處理20PB的數據。當然,有人會說,擁有數十億用戶的應用軟件是極少的,一般的開發人員也沒有機會遇到這樣的情況。然而,以筆者的經驗,大多數的軟件遲早都會遇到一些性能問題。例如:

? 一個使用React編寫的Prometheus UI頁面說不定在什么時候就會對數百萬個度量名稱進行搜索,或者試圖獲取數百MB的壓縮數據,這會導致瀏覽器卡頓和客戶端內存使用率過高。

? 即便使用率低,基礎設施中的Kubernetes集群每天仍會生成0.5TB的日志并存儲(大多數從未使用過),時間一長,日志存儲組件也會告急。

? 筆者撰寫此書時使用了語法檢查工具,當輸入的文本超過20000個單詞時,它發起的網絡連接數量就會多到使瀏覽器卡頓。

? 當Markdown文檔體積很大的時候,用來格式化Markdown的腳本會耗費數分鐘來處理所有元素。

? 做Go程序靜態分析的任務使用了超過4GB的內存,導致CI平臺崩潰。

? IDE需要20min來索引mono-repo(單一代碼倉庫)中的所有代碼,盡管它是在最新的頂配筆記本計算機上進行的。

? 筆者還不能編輯GoPro的4K超寬視頻,因為手里的軟件太落后了。

筆者還能舉更多類似的例子。事實上,我們正處于一個“數據量爆炸”的時代,因此,對應用軟件進行優化刻不容緩。

在未來,數據還會呈幾何級數增加,那時的軟件和硬件必須具備處理這些目前看起來接近極端增速的數據的能力,5G通信(https://oreil.ly/CWvFG)已經可以達到每秒20GB的傳輸速度,市面上大量的商品嵌入了微型計算機,比如電視、自行車、洗衣機、冰箱、臺燈,甚至除臭劑(https://oreil.ly/DvZil)!這個時代叫作“物聯網”(IoT)時代。這些設備產生的海量數據預計將從2019年的18.3ZB增長到2025年的73.1ZB(https://oreil.ly/J1o6D[17]。現代電子工業已經可以生產8K電視,分辨率為7680×4320,約有3300萬像素。隨之而來的是如何應對渲染這8K顯示器的算力挑戰,一般的GPU很難勝任。此外,加密貨幣和區塊鏈算法也對硬件資源性能提出了挑戰,例如,比特幣在價值峰值期間的能源消耗約為130太瓦時(占全球電力消耗的0.6%)(https://oreil.ly/NfnJ9)。

技術限制

硬件發展速度不夠快的最后一個原因是某些技術到了瓶頸階段,很難突破,比如,CPU速度(時鐘頻率)或內存訪問速度。本書將在第4章中介紹這種情況下的一些挑戰,每個開發人員都應該了解這些硬件技術瓶頸。

講到這里,就不得不提到摩爾定律(Moore's Law)。1965年,英特爾前首席執行官兼聯合創始人戈登·E.摩爾(Gordon E.Moore)首次提出了這一定律。

集成電路上可容納的晶體管數目約每隔18個月便會翻一番,性能也將提升一倍。當價格不變時,一美元所能買到的計算機性能將每隔18個月翻兩番。

——Gordon E.Moore,“Cramming More Components onto Integrated Circuits”(https://oreil.ly/WhuWd),Electronics 38(1965)

1974年,羅伯特·H.登納德(Robert H.Dennard)主導的實驗表明,晶體管尺寸(恒定功率密度)與能耗是成比例的[18],這意味著晶體管越小,能效越高,后來這被稱作“登納德縮放比例定律”(Dennard Scaling)。正是基于這一理論基礎,才有了摩爾定律。最終,這兩個定律都保證了晶體管的能效比,對后世產生了深遠的影響。隨后,市場就不斷研究和開發縮小MOSFET晶體管[19]尺寸的方法。如今,芯片制程已經可以達到3nm,在單位面積上可以分布更多的晶體管,帶來更高的性能和更低的功耗。

在摩爾定律提出之后,摩爾的預測并不像他想象的那樣只持續了10年,而是持續了幾十年,雖然摩爾定律目前已經失效,但仍具有指導作用。而登納德縮放比例定律則是在2006年[20]左右達到了物理極限,如圖1-3所示。

圖1-3:摩爾定律與登納德定律(圖片參考了Emery Berger的Performance Matters

雖然從技術上講,更高密度的微型晶體管電路的功耗保持不變,但高密度的芯片很快就會發熱。當時鐘頻率超過3~4GHz時,冷卻晶體管以保持其運行會顯著增加能耗和其他成本。除非永遠在海底使用它們[21],否則,就需要考慮散熱。因此,在各種因素的制約下,多核CPU出現了。

性能越高,越節能

到目前為止,我們了解到了硬件速度越來越快,軟件卻越來越笨重,需要處理的數據和用戶數量卻持續增長。在開發軟件時,我們往往會忘記一個重要的資源:電力。代碼中的每一次計算都需要電力,這是手機、智能手表、物聯網設備或筆記本計算機等許多平臺性能受到制約的主要原因。非直觀地說,能源效率、軟件速度、軟件性能之間存在很強的相關性。Chandler Carruth的演講很好地解釋了這種聯系:

那些關于“節約用電,優化用電”的說明基本都是偽科學,毫無科學依據,實際上,最優的節能策略是在程序運行完成后,馬上關閉電源。而且程序運行得越快,關閉電源就越頻繁。

——Chandler Carruth,“Efficiency with Algorithms,Performance with Data Structures”(https://oreil.ly/9OftP),CppCon 2014

總的來說,不要陷入一種常見的思維陷阱,即可以完全依賴硬件的性能來提升軟件表現。開發人員一旦形成這種觀念,就會掉入陷阱,并且會造成惡性循環,編寫出的代碼質量也會逐漸降低。

更廉價和更易獲取的硬件會進一步吞噬我們的編碼能力,讓我們的程序毫無性能可言。雖然現在出現了一些具備劃時代意義的硬件,例如,蘋果公司的M系列芯片[22]、RISC-V標準[23]以及更多實用的量子計算設備,它們都使硬件性能提升到了另一個維度。然而不幸的是,截至2023年,硬件的更迭速度仍然比軟件的性能需求慢得多。

軟件效率提升有助于增強易用性和包容性

就目前而言,條件稍好的IT公司為開發人員配備的開發設備性能普遍較好,在這樣的設備上開發的應用程序表面上看起來運行穩定,但實際情況是,許多組織和客戶仍舊在使用較舊的設備與網絡[24],這時就不得不考慮將應用程序運行在這些舊設備上可能出現的問題。因此在開發過程中考慮效率可以提升軟件的易用性和包容性。

1.2.4 誤區4:使用水平擴展

在前面的章節中,我們知道我們的軟件遲早會處理更多的數據。然而你的項目不太可能一開始就擁有數十億的用戶。切合實際地估計一個較低的目標用戶數量、訪問量或數據量,就能在開發初期避免巨大的軟件復雜性和開發成本。這樣簡化需求是可行的,但在早期設計階段大致預測性能要求也很重要,這就需要準確評估軟件的中長期預期負載情況——即便流量增加,也能快速地進行水平擴展(橫向擴展)。水平擴展是一種在設計階段需要考慮的特性,如果在生產中才考慮水平擴展,會遇到很多問題,難度很大,成本高昂。

即使一個系統今天在可靠地運行,也不能保證將來一定能夠可靠地運行。性能下降的一個常見原因是負載增加:可能是系統的并發用戶數從1萬增加到10萬,或者從100萬增加到1000萬。也可能是因為處理的數據量比以前大得多??蓴U展性是我們用來描述系統應對負載增加的能力的術語。

——Martin Kleppmann,Designing Data-Intensive Applications(O'Reilly,2017)

在討論性能這個話題時,總是會不可避免地觸及一些關于可擴展性的話題。然而,就本章的目的而言,我們可以將軟件的可擴展性分為兩種類型,如圖1-4所示。

圖1-4:垂直擴展和水平擴展

垂直擴展

擴展應用程序的第一種方法,也是最簡單的方法,就是直接提升單機處理能力,即在性能更好的硬件上運行軟件,這種方式叫作垂直擴展。例如,將那些支持并行處理的程序部署到多核CPU平臺上,如果負載繼續增加,再部署到更多核數的CPU平臺。同理,如果我們的程序是內存密集型的,則可能會提高運行要求并增加硬件內存空間。其他資源(如磁盤、網絡或電源)也可以進行類似的垂直擴展。這種簡單、直接的方法雖然短期收效顯著,但從長期角度來看,它的資源也是有限的,終有被耗盡或不能滿足新需求的一天。

如果你的應用程序運行在云上,情況會稍微好一些,因為可以在云上直接買一臺配置更高的服務器實例或虛擬機。截至2022年,在AWS平臺上已經可以將硬件擴展到128核CPU、近4TB內存和14GBps帶寬[25]。在極端情況下,還可以選擇購買一臺擁有190核CPU和40TB內存的IBM大型機(https://oreil.ly/P0auH),當然,這需要不同的編程范式。

不幸的是,垂直擴展在許多方面都有其局限性。即使在云環境或數據中心,也無法無限擴大硬件配置規模。首先,大型機極其昂貴;其次,正如將在本書第4章中介紹的,大型機可能會遇到由許多隱藏的單點故障引起的復雜問題。比如內存總線、網絡接口、NUMA節點和操作系統本身等部件,一旦出現問題就會引起一連串故障,而在大型機上部署的大規模應用程序則面臨中斷服務的風險[26]。

水平擴展

如果不考慮使用大型機,還有另一種方法擴展應用程序,即增加普通服務器,線性擴充系統性能:

? 如果要在一款消息類手機應用程序中搜索帶有“home”一詞的消息,則需要從數百萬條歷史消息中進行循環查找,并對每條消息運行正則匹配。我們可以設計一個API,并遠程調用后端應用,后端應用可以將搜索拆分為100個作業來執行。

? 將不同的功能拆分到不同的組件,轉向“微服務”架構,而不是構建“單體”應用軟件。

? 相比使用PC運行大型游戲(需要使用昂貴的CPU和GPU),可以嘗試云游戲。目前云游戲同樣具備高分辨率的流媒體傳輸能力(https://oreil.ly/FKmTE)。

相比于垂直擴展,水平擴展的易用性更高,且限制較少、具備良好的彈性。例如,如果某款軟件專供某家公司使用,那么晚上可能幾乎沒有用戶,白天流量卻很大。有了水平擴展,就可以很容易地實現基于流量的自動伸縮。

此外,在應用層實現水平擴展比直接進行垂直擴展難度更高。分布式系統、網絡影響、服務無狀態等是此類系統開發過程中需要解決的難題。因此,在某些情況下,堅持垂直擴展通常是最優的方案。

腦海中有了垂直擴展和水平擴展的概念之后,讓我們來看一個特定的場景。目前許多數據庫依靠壓縮來高效地存儲和查找數據。在此過程中,我們可以重用許多索引,去除重復數據,并將碎片收集到順序數據流中,以實現更快的讀取速度。在Thanos項目開始時,為了簡單起見,我們決定使用一個非常幼稚的壓縮算法,因為當時經過測算,理論上并不需要為單個數據塊的壓縮采用并行方式。要處理從單一數據源不斷產生的100GB(或更多)的數據流,只需要單核CPU、少量內存和磁盤空間即可。這種方式最終被證實非常簡單且未進行任何優化,它遵循了YAGNI原則并避免了過早地進行優化,這確實為我們減少了一些優化的工作。但不幸的是,該項目部署之后很快就遇到了壓縮導致的問題:壓縮速率太低,并且每次執行要消耗數百GB的內存,這顯然不能被接受。還有一個大問題是,許多Thanos用戶并未配置可以垂直擴展內存的設備。

乍一看,這個壓縮問題似乎是一個可擴展性問題,壓縮過程依賴于資源。由于用戶希望快速得到解決方案,因此我們與社區一起開始集思廣益,探討水平擴展方案。其中討論引入一個壓縮調度器服務,將壓縮作業分配給不同的機器,或使用gossip協議的P2P網絡。不用說,使用這兩種解決方案都會極大地增加復雜性,使開發和運行整個系統的復雜性增加一倍或兩倍。幸運的是,幾天之后,一些資深的開發人員重新設計了代碼,實現了效率和性能的提升。使較新版本的Thanos能夠以之前兩倍的速度對數據進行壓縮,并直接從磁盤流式傳輸數據,實現最小的峰值內存消耗。幾年后,雖然Thanos項目已被數千名用戶廣泛使用,但仍然沒有設計任何復雜的用于數據壓縮的水平擴展功能(除了簡單的分片),且仍在穩定運行。

現在回想起來,筆者很慶幸當時沒有迫于客戶和技術壓力,去做針對水平擴展的分布式改造。也許這個改造的過程會很有趣,但同時也會面臨許多難題。在未來某一天,我們也許會進行相關的改造,但要首先確保已經沒有任何壓縮優化策略可采用了。在做開發工作的時候,不要一來就為系統考慮水平擴展,而應該對其本體進行優化。在筆者的職業生涯中,類似的情況在各種項目中都遇到過不少,所以這是一個經驗之談。

過早擴展比過早優化更加險惡

在引入復雜的可擴展模式之前,請確保已經充分考慮算法和代碼層面的優化。

Thanos這個關于壓縮的例子其實給我們敲響了警鐘,如果我們不關注軟件的效率,就會被過早卷入水平擴展當中。這是一個巨大的陷阱,通過一些優化工作,其實可以完全避免掉入這個陷阱當中。換句話說,避免復雜性會帶來更大的復雜性。在筆者看來,這是業界目前普遍存在的問題,也是筆者寫這本書的主要原因之一。

復雜性存在于系統或組織的各個層面,它可能導致各種問題和困難。因此,為了應對復雜性,需要仔細考慮和管理各種因素,以確保系統的穩定性和性能(https://oreil.ly/0PcmN)。如果不想使代碼復雜化,復雜性就會轉移到其他方面,如果轉移到系統層面,就會額外付出一些成本。水平擴展的復雜性較高,從設計上講,它涉及網絡操作。從CAP定理[27]中可以知道,一旦開始做負載均衡,就不可避免地會遇到可用性或一致性問題。處理水平擴展帶來的問題要比再優化io.Reader接口困難百倍。

本節內容似乎只涉及基礎設施,其實它還適用于軟件的整個生命周期。例如,如果你編寫了一個前端軟件或動態網站,你可能會考慮將客戶端計算轉移到后端。其實只有在計算需要依賴硬件資源并且超出用戶空間硬件能力的情況下才應該這樣去做。過早地將其轉移到服務器可能會增加額外的網絡調用開銷,需要處理更多的bug,并且可能遭受DoS攻擊[28]

另一個例子來自筆者的經驗。筆者的碩士論文是關于“使用計算集群的粒子引擎”的,目標是在Unity引擎(https://unity.com)中為3D游戲添加粒子引擎。核心論點是粒子引擎不應該在客戶端機器上運行,而是將“昂貴”的計算部分轉移到筆者所在大學附近一臺名為Tryton[29]的超級計算機上。結果卻是,盡管它有超快的無限帶寬網絡[30],但當筆者進行模擬的時候,超級計算機并沒有在這個場景展現出預期的效果,也沒有任何可靠性。直接在PC機上計算不僅不那么復雜,而且速度更快。

如果有人對你說,“不要優化程序,直接做水平擴展”,請保持懷疑的態度。一般來說,在將應用升級到可擴展性這個級別之前,做性能優化更簡單、更經濟。但當性能優化遇到瓶頸時,可擴展性應該是更好的選擇,我們將在第3章中了解更多信息。

1.2.5 誤區5:盡快投入市場

時間是寶貴的,對于一款軟件來說尤其重要,這關乎它的命運。如果希望應用程序或系統擁有更多的功能,那么需要花費更多的時間來設計、實現、測試、安全防護和優化性能,但這樣一來,公司或個人交付軟件產品的時間就會變長,也就意味著軟件的“上市時間”就越長,這可能會直接影響營收。

人們常說時間就是金錢,如今它比金錢更有價值。麥肯錫的一項研究報告表明,公司延遲六個月發貨,平均會損失33%的稅后利潤。而在產品開發上超支50%,則會損失3.5%。

——Charles H.House和Raymond L.Price,“The Return Map:Tracking Product Teams”(https://oreil.ly/SmLFQ

似乎很難衡量時間對產品的影響,但當你的產品晚于競品上市時,你的產品便不再占有先機,從而可能會錯過寶貴的機會。這就是為什么很多公司通過采用敏捷方法論(Agile Methodologies)或概念驗證(POC)和最小可行產品(MVP)模式來降低這種風險。

雖然敏捷研發、POC、MVP模式對縮短產品上市周期有幫助,但為了實現更快的上市周期,公司也會嘗試其他方法:擴大團隊規模(雇用更多的人,調整組織架構)、簡化產品、實現更多的自動化流程或建立合作伙伴關系。有時甚至試圖降低產品質量來換取速度。例如,Facebook早期有一句口號是“Move fast and break things”(快速行動,打破傳統)[31],在代碼可維護性、可靠性和效率等方面合理降低軟件質量,以拔得市場頭籌。

這就是關于性能的最后一個誤解,通過降低軟件效率來更快地將產品投入市場在前期可能很有效,但終究不是銀彈。如果始終堅持這樣做,則要覺察風險與后果。

性能優化過程困難且昂貴。許多工程師認為,它會推遲產品進入市場的時間,降低利潤。但這顯然忽略了性能不佳的產品的后期成本(尤其是在存在殘酷的市場競爭情況下)。

——Randall Hyde,“The Fallacy of Premature Optimization”(https://oreil.ly/mMjHb

當產品bug、安全問題頻發且性能低下時,公司的利潤會受到較大影響。這并不是個例,波蘭最大的游戲發行商CD Projekt在2020年底發布了一款游戲,《賽博朋克2077》(https://oreil.ly/ohJft),它被公認為是一款質量上乘的作品。彼時銷量不錯,因為它由一家聲譽良好的游戲出版商發行,盡管出現了延期,但世界各地激動的玩家還是獻上了800萬的預售訂單。不幸的是,在2020年12月發布時,這款被認為絕對出色的游戲出現了巨大的性能問題。它在所有主機平臺和大多數PC上都出現了bug、閃退和低幀率的現象。在一些較舊的主機平臺(如PS4或Xbox One)上,游戲更是無法運行。當然,在接下來的幾個月和幾年時間里,開發人員做了大量的修復工作。

不幸的是,為時已晚。如此多的問題造成了巨大的影響。雖然這些問題在筆者看來都很常見,但它們足以對CD Projekt市值造成重創。游戲上架五天后,該公司股價下跌了三分之一,創始人為此損失了超過10億美元(https://oreil.ly/x5Qd8)。數以百萬計的玩家要求退款,投資者被起訴(https://oreil.ly/CRKg4),游戲的主要開發人員引咎辭職(https://oreil.ly/XwcX9),雖然CD Projekt不至于因此倒閉,但已造成的聲譽損失是無法估量的。

一些更有經驗和成熟的公司非常清楚軟件性能的價值所在,尤其那種toC的公司。例如,亞馬遜發現,如果其網站加載速度慢1s,每年將損失16億美元(https://oreil.ly/cHT2j)。亞馬遜還報告稱,每增加100ms的延遲,就會減少1%的利潤(https://oreil.ly/Bod7k)。谷歌也發現,其搜索引擎響應速度從400ms減慢到900ms會導致流量下降20%(https://oreil.ly/hHmYJ)。對于一些初創企業來說,情況更糟。據估計,如果股票經紀人使用的交易平臺比競爭對手慢5ms,就可能會損失1%的現金流,甚至更多。如果慢10ms,這個比例會增加到10%(https://oreil.ly/fK7mE)。

實際上,在大多數場景下,慢個幾ms可能并不會引起什么。例如,假設開發一個將PDF轉為DOCX的文件轉換器,轉換過程需要4s還是100ms有什么不一樣嗎?但在許多情況下,情況并非如此。在競爭激烈的商業環境,這會直接關系到客戶體驗問題,進而影響獲客結果。所以性能現在已經成為一個繞不開的主題,即使是開源項目,也會將性能作為其核心能力之一。這雖然感覺像是一種廉價的營銷技巧,但很有效,因為如果你面對兩個有相同功能的開源項目,你大概率會選擇性能好的那個。但是,性能并不僅僅體現在速度上——資源消耗問題同樣重要。

在市場上,效率通常比功能更重要

在筆者擔任基礎設施系統顧問期間,很多客戶選擇了資源消耗更少的方案,即使方案的功能相對并不豐富[32]。

權衡效率優先還是功能優先其實很簡單。如果想快速開拓市場,在核心功能具備的時候,一定要優先考慮效率優化。從另一方面來說,盡快將軟件產品投入市場非常重要,因此在前期要為效率優化規劃足夠的時間和空間,一種方法是盡早設定非功能性目標(將在3.3.2節中討論)。在本書中,我們將重點關注找到合理的平衡點,并減少效率優化帶來的工作量(從而減少研發周期)。下面我們將從實用性的角度來看軟件的性能。

主站蜘蛛池模板: 项城市| 吉木萨尔县| 丽水市| 阿拉善左旗| 安多县| 锡林郭勒盟| 新乡县| 遵义县| 崇文区| 那曲县| 江川县| 疏附县| 克东县| 武夷山市| 胶州市| 南丹县| 巴南区| 揭阳市| 蒙山县| 灵山县| 平度市| 东乌珠穆沁旗| 宁城县| 兴义市| 兴城市| 襄城县| 绥宁县| 河西区| 陕西省| 界首市| 三原县| 佳木斯市| 阿瓦提县| 景德镇市| 荥经县| 抚松县| 徐闻县| 凯里市| 兴海县| 礼泉县| 维西|