天天看點

我的 JavaScript 比你的 Rust 更快

編譯 | 核子可樂、褚杏娟

Josh Urbane 是一位從業多年的軟體架構師,很喜歡在社交媒體分享技術觀點。近日,他寫了一篇文章,記錄了自己憑借經驗赢了與新人開發者打賭的故事,而“我的 JavaScript 比你的 Rust 更快”的結論也是來自這個打賭。他的故事或許可以說明運作政策在研發實踐中的重要性。

對我來說,軟體架構師這活兒最讓人開心的一點就是能指導開發者了解最新的概念、影響他們的技術判斷。有些開發者不是很嚣張嗎,那就用理論加現實啪啪打他們的臉;架構師還得負責營造出寓教于樂的學習氛圍,幫助年輕氣盛的開發者逐漸長大成熟。

最會讓我在心裡暗爽的事兒就是一個愣頭青開發者突然跳出來、想要挑戰我的技術建議(從開發者的視角看,架構師就是一幫總在提「錯誤」建議的傻瓜),而且賭上全部身家堅持認為自己的辦法更好。

問題是,我已經幹這行很久了,不用驗證我就知道問題的正确答案是什麼。是以那就來呗,咱們手底下見真章,我把這段故事記錄了下來、在幾年後整理成了今天的這篇文章。

梭哈是一種“智慧”

老實講,下面要講的這個事已經過去好幾年了,是以很多細節我已經記不清楚。大體情況就是結合當時團隊的知識儲備、可用工具庫和原有技術債務,我給出的建議是讓大家使用 Node.js。

一個新任初級開發者對自己剛拿到的計算機科學學士證書很有信心,想要用“炫技”的方式挫挫我的銳氣。他們聽說我是輔修的計算機科學,是以覺得我壓根不了解計算機底層原理。其實剛畢業那會我也認為自己很懂,但這行幹久了,我越來越覺得計算機系統像是魔法……

他的信心并非毫無來由,這個結論如同“C++ 比 JavaScript 速度快”,基本屬于業界共識。但作為典型的架構師,我仍然堅持認為“要視情況而定”。

更具體地講,“經過充分優化的 C++,确實比具有同等優化水準的 JavaScript 跑得更快”,畢竟 JavaScript 有着無法避免的執行開銷(即便如此,我們也可以把代碼編譯成靜态程式來獲得高度接近 C++ 的性能)。反正話已至此,那就梭了呗。

意外的是,JavaScript 代碼确實要比 C++ 版本更快一點,而且從架構設計的角度來看,JS 版本可以由目前團隊一力維護、不需要借助其他部門的技術能力。

還好還好,其實我也不敢百分之百确定自己是對的,但考慮到這個用例中的記憶體對象大小可能是動态的、再加上那位年輕開發者确實經驗不足,是以我願意賭上一把。

JS 比 C++ 還快,怎麼實作的?

我猜大多數開發者都了解不了這樣的結果。這明顯跟“編譯”語言快于“解釋”語言、“靜态”程式快于“VM”程式的基本原則背道而馳啊。但請注意,這些隻是經驗、而非真理。

我之前也提到,“優化”才是決定速度的關鍵。畢竟即使 C++ 語言自身的性能優勢再強,糟糕的編寫品質也會讓程式身陷泥潭。另一方面,Node.js(使用基于 C++/C 的 V8 與 libuv 庫)則更具優化空間,是以實際運作速度并不差。甚至可以說,品質同樣差勁的 JS 和 C++ 程式,JS 的性能可能還更好一點。但這隻是宏觀論述,下面咱們來看點細節。

記憶體是關鍵

大多數開發者應該很熟悉棧和堆的概念,但這種了解基本隻停留在了表面——例如隻知道棧是線性的,而堆就是帶有指針的“坨”(并非嚴格術語,大家能了解就行)。

更重要的是,棧和堆的概念對應着多種實作和方法。底層硬體并不知道“堆”是個什麼東西,因為記憶體的管理方式是由軟體來定義的,而記憶體管理方面的選擇必然會對程式的最終性能産生巨大影響。

大家也可以就這個問題深挖下去,很有意義也很有價值。現代硬體和核心都相當複雜,其中往往包含大量具有特殊用途的優化機制,例如更高效地利用進階記憶體布局。這意味着軟體可以(或者必須)借用由硬體提供的記憶體管理功能。此外還有虛拟化的影響……這裡就不多做展開了。

魔法的核心:垃圾回收

沒錯,Node.js 解決方案的啟動時間肯定更長,因為它需要通過 JIT 編譯器來實作腳本的加載和運作。不過一旦加載完成,Node.js 代碼其實反而擁有一項神秘的優勢——垃圾回收機制。

而在 C++ 程式中,應用程式往往會在堆中建立動态大小的對象,之後再将其删除。這意味着程式的配置設定器必須一遍又一遍地在堆中配置設定和釋放記憶體。這項操作本身速度較慢,而且實際性能基本由配置設定器中的算法決定。在多數情況下,dealloc 的速度會特别慢,即使是精簡後的 alloc 也沒好太多。

對于 Node.js 程式,這項絕技就是程式隻運作一次就會退出。Node.js 同樣運作腳本并配置設定必要的記憶體,但後面的删除操作會由垃圾回收器挑選空閑時間再推遲執行。

誠然,垃圾回收機制在本質上并不比其他記憶體管理政策更好或者更差(一切都是權衡),但在我們打賭的這個特定程式中,垃圾回收确實能顯著提升性能,因為這個程式壓根就沒真正運作過。我們隻是把一大堆對象塞進記憶體,再在退出時一次性丢棄。

垃圾回收肯定是有代價的,Node.js 程序占用的記憶體容量明顯大于 C++ 程式。這就是“省 cpu= 費記憶體”和“省記憶體 = 費 cpu”的經典難題,但我的目标就是打那小子的臉,是以費點記憶體也無所謂。

而我之是以能赢,是因為對方選擇了一個幼稚的政策。其實他要想赢,最好的辦法就是添加記憶體洩漏,故意把所有配置設定都保留在記憶體當中。這樣 C++ 程式的記憶體占用量還是更小,但速度卻比原先快得多。或者,他也可以用給棧配置設定緩沖區之類的設計來進一步提高性能,這種辦法在實際生産中其實經常用到。

另外還有如何選擇性能基準的問題。一般來說,大家比較的就是每秒操作數量。這裡的 JS 對 C++ 就是個很好的例子,證明了“先了解總體性能成本,再做選擇”往往更加靠譜。在軟體架構中,我們必須得時刻關注資源層面的“總體擁有成本”。

步入現代:有請 Rust 上場

Rust 是我目前最喜歡的語言之一。它提供了很多現代特性、速度很快,而且具備良好的記憶體模型,生成的代碼也相當安全。

Rust 當然不是完美的,它的編譯時間比較長、涉及不少奇奇怪怪的語義,但總體來說還是值得推薦。大家可以對 Rust 中的記憶體管理方式進行靈活控制,但其“棧”記憶體始終遵循所有者模型(ownership model),這也是其實作引以為傲的高安全性能的基礎。

我目前參與的一個項目就是用 Rust 編寫的 FaaS(函數即服務)主機,負責執行 WASM(WebAssembly)函數。它能快速安全地執行各項隔離函數,最大限度降低 FaaS 的運作開銷。它的速度也很快,每核心每秒能夠處理 90000 個簡單請求。更重要的是,它的總記憶體占用量隻有 20 MB 上下,可以說相當誇張了。

但這跟 Node.js 與 C++ 的賭局有什麼關系?

簡單來說,我是把 Node.js 視為“合理”的性能基準(Go 屬于「夢幻」級基準,它的性能絕對不是那些專為 Web 服務設計的語言能比肩的,這裡就别降維打擊了),畢竟我們那款程式的早期 C++ 版本性能實在不咋的,唯一的好處就是記憶體占用量隻有 Node.js 版本的不到十分之一。

雖然先讓代碼跑起來、再對代碼做優化确實沒啥毛病,但在 C++ 這種“快”語言上輸給了 JavaScript 肯定讓人非常沮喪。而我之是以敢當場梭哈,靠的就是對明顯瓶頸的基本判斷。這個瓶頸就是記憶體管理。

每個 guest 函數都被配置設定到一個記憶體數組,但在函數之内配置設定記憶體,以及在函數記憶體與主機記憶體間複制資料肯定會帶來大量性能開銷。由于動态資料被四處亂扔,配置設定器相當于是飽受四面八方的重拳打擊。至于解決辦法嘛,作弊喽!

加堆,兩個堆、三個堆......

從本質上講,堆代表的是配置設定器用來管理映射的一部分記憶體。程式會請求 N 個記憶體單元,配置設定器在可用的記憶體池裡搜尋這些單元(或者向主機請求更多記憶體)及存儲哪些單元已被占用,之後再傳回該記憶體的位置指針。當程式用盡記憶體時,就會告知配置設定器,再由配置設定器更新映射以明确現在哪些單元已經再次可用。挺簡單的,對吧?

但如果我們需要配置設定一大堆生命周期有别、大小各異的記憶體單元時,麻煩就來了。這一定會産生大量碎片,進而放大了新記憶體的配置設定成本。于是性能損失開始産生,畢竟配置設定器的功能太過簡單,隻是在尋找可用的存儲位置。

這個問題顯然沒有太好的解決方案,雖然目前可選的配置設定算法很多,但它們還是各有權衡、要求我們結合用例特點選擇最适方法(也可以像大多數開發者一樣,直接用預設選項)。

再來說作弊。作弊的辦法可不隻一種:對于 FaaS,我們可以釋放每次運作的 dealloc,并在每次運作完成後清除整個堆;我們也可以在函數生命周期的不同階段使用不同的配置設定器,例如明确區分初始化階段和運作階段。這樣無論是幹淨的函數(每次運作,都會被重置為相同的初始記憶體狀态)還是有狀态函數(在每次運作之間保留狀态),都能獲得與之對應且經過優化的記憶體政策。

在我們的 FaaS 項目裡,大家最終建構了一個動态配置設定器,它會根據使用情況選擇配置設定算法、且實際選擇會在每次運作之間持續留存。

對于“使用率較低”的函數(也就是大多數函數),隻使用簡單的棧配置設定器用指針指向下一個空閑槽即可。當調用 dealloc 時,如果該單元為棧上的最後一個單元,則復原指針;如果不是最後一個單元,則無操作。當函數完成時,指針将被設定為 0(相當于 Node.js 在垃圾回收前退出)。如果函數的 dealloc 失敗數和用量達到一定門檻值,則在其餘調用中使用其他配置設定算法。結果就是,這套方案在大多數情況下都能顯著加快記憶體配置設定。

運作時中還用到了另一個“堆”——主機(或者說是函數共享記憶體)。它使用同樣的動态配置設定政策,并允許繞過早期 C++ 版本中的複制步驟、直接寫入函數記憶體。如此一來,I/O 就能直接從核心中複制 guest 函數,并繞過主機運作時,進而顯著提高吞吐量。

Node.js 對陣 Rust

經過優化,Rust FaaS 運作時最終比我們的 Node.js 參考實作快了 70% 以上,而記憶體占用量更是不到後者的十分之一。

但這裡的關鍵在于“經過優化”,它的初始實作其實速度反而更慢。我們的優化還要求對 WASM 函數做出一些限制,具體限制在編譯過程中完全公開透明,而且極少出現不相容的情況。

Rust 版本的最大優勢就是記憶體占用小,省下來的 RAM 可以用作緩存或者分布式記憶體存儲等其他用途。這意味着 I/O 開銷進一步降低,生産運作的效率更高,其效果甚至比拉高 CPU 配置還更明顯些。

後續我們還有更多優化計劃,但主要是為了解決主機層中一些具有重大安全影響的問題。雖然跟記憶體管理或者性能沒啥關系,但畢竟也算支援了 “Rust 比 Node 更快”黨們的觀點。

總 結

其實全文寫下來,我也得不出特别明确的結論。下面隻給出幾個粗淺的觀點:

記憶體管理很有趣,每種方法都是在做取舍。隻要政策運用得當,任何一種語言都能獲得巨大的性能提升。

我仍然推薦大家根據實際目标靈活使用 Node.js 和 Rust,是以這裡不做優劣判斷。JavaScript 的可移植性确實更好,而且特别适合雲原生開發場景;但如果大家特别看重性能,那 Rust 可能是個更好的選擇。

從頭到尾我都在說 JavaScript,但這裡實際指的是 TypeScript。

歸根結底,大家得根據實際情況選擇最适合的技術方案。我們越是了解不同棧的不同特征,在選擇的時候就越是從容有數。

https://medium.com/@jbyj/my-javascript-is-faster-than-your-rust-5f98fe5db1bf

繼續閱讀