為什麼Discord從Go切換到Rust?
Rust正在成為各種領域的一流語言。在Discord,我們已經在客戶端和伺服器端看到了Rust的成功。例如,我們在客戶端將其用於Go Live的影片編碼管道,在伺服器端將其用於Elixir NIF。最近,我們透過將服務的實現從Go切換到Rust來極大地提高了服務的效能。這篇文章解釋了為什麼重新實現服務對我們有意義,它是如何完成的,以及由此帶來的效能改進。
讀取狀態服務
Discord是一家專注於產品的公司,因此我們將從一些產品上下文入手。我們從Go切換到Rust的服務是“讀取狀態”服務。其唯一目的是跟蹤您已閱讀的頻道和訊息。每次您連線到Discord,每次傳送訊息和每次閱讀訊息時,都會訪問“讀取狀態”。簡而言之,“讀取狀態”正處於熱點中。我們要確保Discord始終都感覺超級敏捷,因此我們需要確保Read State快速。
透過Go實施Read States服務卻不支援產品要求。在大多數情況下,速度雖然很快,但是每隔幾分鐘,我們就會看到大量的延遲峰值,這不利於使用者體驗。經過調查,我們確定峰值是由於Go的核心功能:其記憶體模型和垃圾收集器(GC)引起的。
為什麼Go無法達到我們的績效目標
為了解釋Go為什麼不能達到我們的效能目標,我們首先需要討論服務的資料結構,規模,訪問模式和體系結構。
我們用來儲存讀取狀態資訊的資料結構通常稱為“讀取狀態”。可能會擁有數十億個讀取狀態。每個使用者每個通道有一個讀取狀態。每個讀取狀態都有幾個需要自動更新的計數器,通常需要將其重置為0。
為了獲得快速的原子計數器更新,每個讀取狀態伺服器都具有讀取狀態的最近最少使用(LRU)快取。每個快取中有數百萬個使用者。每個快取中有數千萬個讀取狀態。每秒有數十萬個快取更新。
為了保持永續性,我們使用Cassandra資料庫叢集支援快取。在刪除快取鍵時,我們將某個使用者的讀取狀態提交到資料庫。每當讀取狀態被更新時,我們還將在未來30秒內計劃資料庫提交。每秒有數萬次資料庫寫入。
在Go服務的峰值取樣時間範圍的響應時間和系統cpu測試中,會注意到,大約每2分鐘就會有延遲和CPU峰值。
那麼為什麼要2分鐘峰值呢?
在Go中,在逐出快取鍵時,不會立即釋放記憶體。取而代之的是,垃圾收集器會如此頻繁地執行以查詢沒有引用的任何記憶體,然後將其釋放。換句話說,不是在記憶體用完之後立即釋放,而是掛了一段時間,直到垃圾回收器可以確定它是否真正用完為止。在垃圾回收期間,Go必須做很多工作來確定哪些記憶體可用,這可能會使程式變慢。
這些延遲峰值肯定聽起來像垃圾回收效能影響,但是我們已經非常高效地編寫了Go程式碼,並且分配很少。我們並沒有創造很多垃圾。
深入研究Go原始碼後,我們瞭解到Go將強制至少每2分鐘執行一次垃圾收集。換句話說,如果垃圾收集沒有執行2分鐘,無論堆增長如何,go仍將強制執行垃圾收集。
我們認為我們可以調整垃圾收集器的發生頻率,以防止出現大的峰值,因此我們在服務上實現了一個終結點,以動態更改垃圾收集器的GC百分比。不幸的是,無論我們如何配置GC百分比,都沒有改變。怎麼可能 事實證明,這是因為我們分配記憶體的速度不夠快,無法迫使垃圾回收更頻繁地發生。
我們不斷進行挖掘,並瞭解到峰值之所以巨大,並不是因為有大量隨時可用的記憶體,而是因為垃圾收集器需要掃描整個LRU快取以確定記憶體是否真正沒有引用。因此,我們認為較小的LRU快取會更快,因為垃圾收集器的掃描量更少。因此,我們在服務中新增了另一項設定以更改LRU快取的大小,並更改了體系結構以使每個伺服器具有多個分割槽的LRU快取。
沒錯 LRU快取較小時,垃圾回收會導致較小的峰值。
不幸的是,降低LRU快取的權衡取捨導致了第99個延遲時間的增加。這是因為如果快取較小,則使用者的“讀取狀態”在快取中的可能性較小。如果它不在快取中,那麼我們必須進行資料庫載入。
在對不同的快取容量進行了大量的負載測試之後,我們發現了一個設定似乎還可以。雖然不完全滿意,但足夠滿意,所以我們讓這種服務執行了一段時間。
在這段時間裡,Rust在Discord的其他部分獲得了越來越多的成功,我們共同決定要建立在Rust中完全構建新服務所需的框架和庫。這項服務體積小且自包含,因此非常適合移植到Rust,但我們也希望Rust可以解決這些延遲峰值。因此,我們承擔了將“讀取狀態”移植到Rust的任務,希望證明Rust是一種服務語言,並改善使用者體驗。
Rust中的記憶體管理
Rust速度極快且記憶體效率高:沒有執行時或垃圾收集器,它可以為效能至關重要的服務提供支援,可以在嵌入式裝置上執行,並且可以輕鬆地與其他語言整合。
Rust沒有垃圾回收,因此我們認為它不會像Go那樣具有相同的延遲峰值。
Rust使用相對獨特的記憶體管理方法,該方法結合了記憶體“所有權”的概念。基本上,Rust跟蹤誰可以讀取和寫入記憶體。它知道程式何時使用記憶體,並在不再需要時立即釋放記憶體。它會在編譯時強制執行記憶體規則,從而幾乎不可能出現執行時記憶體錯誤。⁴您無需手動跟蹤記憶體。編譯器會處理它。
因此,在“讀取狀態”服務的Rust版本中,當使用者的讀取狀態從LRU快取中逐出時,它將立即從記憶體中釋放出來。讀取狀態記憶體不會等待垃圾回收器對其進行收集。Rust知道它已不再使用,並立即釋放它。沒有執行時過程來確定是否應釋放它。
非同步Rust
但是Rust生態系統存在問題。在重新實現該服務時,Rust stable對於非同步Rust並沒有一個很好的故事。對於網路服務,必須進行非同步程式設計。有一些啟用非同步Rust的社群庫,但是它們需要大量的儀式,並且錯誤訊息非常晦澀。
幸運的是,Rust團隊努力使非同步程式設計變得容易,並且在Rust不穩定的夜間頻道都可以使用。
Discord從未懼怕採用看起來很有前途的新技術。例如,我們是Elixir,React,React Native和Scylla的早期採用者。如果一項技術很有前途並給我們帶來優勢,那麼我們不介意處理前沿技術的固有困難和不穩定。這是我們用不到50名工程師迅速達到250+百萬使用者的方法之一。
每晚在Rust中使用新的非同步功能是我們願意接受有前途的新技術的另一個例子。作為一個工程團隊,我們認為每晚使用Rust值得,並且我們致力於每晚執行,直到在穩定狀態下完全支援非同步為止。我們共同處理了出現的任何問題,此時Rust穩定器支援非同步Rust。
實施,負載測試和啟動
實際的重寫相當簡單。它起初只是一個粗略的翻譯,然後我們將其精簡到合理的程度。例如,Rust有一個很棒的型別系統,它對泛型提供了廣泛的支援,因此我們可以丟棄僅僅由於缺少泛型而存在的Go程式碼。同樣,Rust的記憶體模型能夠推理出執行緒間的記憶體安全性,因此我們能夠丟棄Go中所需的一些手動跨goroutine記憶體保護。
當我們開始負載測試時,我們立即對結果感到滿意。Rust版本的延遲與Go一樣好,並且沒有延遲峰值!
值得注意的是,在編寫Rust版本時,我們僅將最基本的思想用於最佳化。即使僅進行基本最佳化,Rust仍能勝過超級手動調整的Go版本。與我們對Go進行的深入研究相比,這充分證明了用Rust編寫高效的程式是多麼容易。
但是我們對簡單匹配Go的效能並不滿意。經過一些效能分析和效能最佳化後,我們能夠在每個效能指標上擊敗Go。在Rust版本中,延遲,CPU和記憶體都更好。
Rust效能最佳化包括:
- 在LRU快取中更改為BTreeMap而不是HashMap以最佳化記憶體使用。
- 交換使用現代Rust併發性的初始指標庫。
- 減少我們正在執行的記憶體副本數量。
滿意後,我們決定推出該服務。
由於我們進行了負載測試,因此釋出是相當無縫的。我們將其放到單個金絲雀節點上,找到了一些遺漏的邊緣案例,並進行了修復。此後不久,我們將其推廣到整個艦隊。
提高快取容量
服務成功執行了幾天後,我們決定是時候重新提高LRU快取容量了。如上所述,在Go版本中,提高LRU快取的上限會導致更長的垃圾回收。我們不再需要處理垃圾回收,因此我們認為我們可以提高快取的上限並獲得更好的效能。我們增加了包裝盒的記憶體容量,最佳化了資料結構以使用更少的記憶體(用於娛樂),並將快取容量增加到800萬個讀取狀態。
以下結果不言而喻。
總結思想
此時,Discord正在其軟體堆疊的許多地方使用Rust。我們將其用於遊戲SDK,Go Live的影片捕獲和編碼,Elixir NIF,若干後端服務等等。
在開始新專案或軟體元件時,我們考慮使用Rust。當然,我們只在有意義的地方使用它。
除效能外,Rust對工程團隊還具有許多優勢。例如,它的型別安全性和借位檢查器可以很容易地隨著產品需求的變化或發現有關該語言的新知識而重構程式碼。此外,生態系統和工具非常出色,並且背後蘊藏著巨大的動力。
如果到目前為止,您可能對Rust剛感到興奮,或者已經有一段時間了。如果您想專業地使用Rust來解決有趣的問題,則應考慮在Discord工作。
還有一個有趣的事實:Rust團隊使用Discord進行協調。甚至還有一個非常有用的Rust社群伺服器,您可以發現我們不時在聊天。點選這裡檢視。
相關文章
- 為什麼我從Java切換到Rust? Opensource.comJavaRust
- 為什麼我們從Yarn切換到pnpmYarnNPM
- 為什麼我們從RabbitMQ切換到apache kafka?MQApacheKafka
- 為什麼我們從Webpack切換到Vite - ReplitWebVite
- 為什麼應該切換到實時渲染
- Kotlin可以從Rust中學到什麼 - CedricKotlinRust
- 從NodeJS切換到Ruby on Rails - nikodunkNodeJSAI
- 如何從 Docker Desktop 切換到 ColimaDocker
- 我為什麼從php轉go?PHPGo
- 為什麼 Go 不支援 []T 轉換為 []interfaceGo
- Go 為什麼不像 Rust 用 ?!做錯誤處理?GoRust
- Rust 問答之從 HelloWorld 中可以學到什麼Rust
- Android逆向之路—為什麼從後臺切換回app又顯示廣告了AndroidAPP
- 為什麼我從 Mac 換到了 LinuxMacLinux
- Rust vs. Go:為什麼強強聯合會更好RustGo
- kvm切換器是什麼?
- ABP VNext從單體切換到微服務微服務
- 我將從VS Code切換到VS Codium
- 為什麼亞馬遜、臉書和Discord的開發人員喜歡Rust程式語言? - businessinsider亞馬遜RustIDE
- 系統呼叫時為什麼發生任務切換?
- 為什麼我不用ViewPager或RecyclerView來做上下滑切換Viewpager
- 為什麼 Python、Go 和 Rust 都不支援三元運算子?PythonGoRust
- 為什麼我從 npm 到 yarn 再到 npm?NPMYarn
- 為什麼要學習 RustRust
- 為什麼選擇使用Rust?Rust
- Aembit為什麼選擇 Rust?Rust
- Redis主從切換Redis
- 實戰:如何優雅的從 Skywalking 切換到 OpenTelemetry
- 178-ABP VNext從單體切換到微服務微服務
- Linux核心態是什麼?使用者態如何切換到核心態?Linux
- 將Debian從Legacy切換為UEFI啟動模式模式
- RVM切換到rbenv[MacOS]Mac
- github從一個倉庫切換到另一倉庫Github
- 2. Jetpack原始碼解析---Navigation為什麼切換Fragment會重繪?Jetpack原始碼NavigationFragment
- 手工切換MySQL主從MySql
- Redis sentinel主從切換Redis
- 從VPS切換到雲伺服器的幾大理由伺服器
- 從 Python 2 切換到 Python 3 你所需要了解的Python