Discord 為何從 Go 轉為使用 Rust

了解近幾年來的技術演變和選擇

William
WilliamDesk
17 min readNov 26, 2023

--

Discord (Ref: https://store.epicgames.com/zh-Hant/news/what-is-discord-and-what-is-it-used-for)

從 Discord 官方部落格上看到的內容,因為沒有人翻譯,因此順手翻譯加上自己的解讀和註釋,希望讓更多人知道技術脈動。

原文出處 (2020/2/4):
https://discord.com/blog/why-discord-is-switching-from-go-to-rust

Rust 是近幾年來異軍突起的語言,被廣泛地使用在許多領域。在 Discord 眾多服務裡,Rust 也成功地被使用在了 server 和 client 端。

舉例來說,Discord 在 client side 應用在了影片直播編碼 (video encoding) 的 pipeline 上;在 server side 應用在 Elixir NIFs 上。

Elixir NIFs (Native Implemented Functions)

讓 Elixir 程式語言的開發者以其他系統原生語言(如 C 或者 C++)編寫高效能的代碼。
並可模組化使用在 Elixir 或 Erlang 之中。

Reference:
https://elixirforum.com/t/how-to-use-nifs/47268

在近期, Discord 團隊透過從 Go 轉換成 Rust 的方式,徹底改進我們 server 上的效能。這篇文章是來解釋為什麼 Discord 想要做這件事,如何完成,以及達到了多少的效能改善。

讀取狀態服務 (The Read States service)

Discord 是一家產品導向的公司,因此將從產品背景開始講起。

Discord 團隊從 Go 切換到 Rust 的是 「讀取狀態」(Read States) 服務。該服務用來追蹤用戶在 Discord 上閱讀過的頻道和消息。每次只要用戶連接至 Discord、每次發送和閱讀訊息時,都會存取 「讀取狀態」(Read States)。

簡短來說,「讀取狀態」(Read States) 服務是用戶在操作上使用頻率非常高的服務, 因此 必須要時刻都要保持反應快速

在 Go 語言為基底打造的 「讀取狀態」(Read States) 服務時期,並沒有達到Discord 的產品要求。大多時候速度很快,但每隔幾分鐘我們觀測到了大量的延遲峰值 (latency spikes),這會帶來非常不好的使用體驗。

透過調查, Discord 團隊確認這些峰值是因為 Go 的語言本質:「記憶體模組 (Memory Model)和 GC (Garbage Collector) 機制 」所造成。

為什麼 Go 沒有達到我們的效能目標?

為了解釋為什麼 Go 沒有達到 Discord 團隊要求的效能目標,需要討論「讀取狀態」(Read States) 服務的資料結構、規模、存取模式和架構。

Discord 用來儲存 「讀取狀態」(Read States) 資訊的資料結構,為方便直觀,都稱為 「讀取狀態」(Read States,和服務同名)。

Discord 內部有數十億個「讀取狀態」(Read States) 的資料結構。 每個 Discord 頻道的每個使用者都有一個讀取狀態。 每個「讀取狀態」(Read States) 都有數個會自動更新或重置為 0 的計數器。例如,其中一個計數器是應用在頻道中有多少個 @ 被提及。

為了快速獲得每個計數器的更新,每個「讀取狀態」(Read States) 伺服器都有配置一個 Least Recently Used (LRU) 快取。 每個快取中有數百萬個用戶;且每個快取中有數千萬個讀取狀態。每秒有數十萬次快取更新。

LRU (Least Recently Used Cache)
快取的實做方式,概念是會儲存最近用過的內容,會透過 Hash Map與 Double Linked List 來搭配實做,如果欲常被使用,內容會被擺在 List愈前方的位置,如果快取滿了,則會從 List最末端元素開始移除。

Reference: https://josephjsf2.github.io/data/structure/and/algorithm/2020/05/09/LRU.html

為了持久性,我們使用 Cassandra 資料庫叢集支援快取。 在快取的 key 值清除 (eviction) 時,就會將該讀取狀態 commit 寫進資料庫。 Discord 也做了排程,在 30 秒內只要「讀取狀態」(Read States) 被更新時,就會comit 寫入資料庫。每秒有數萬次資料庫寫入。

在下圖中,可以觀察到 Go 服務¹ 的回應時間和系統 CPU在時間段內一直有峰值出現,大約每 2 分鐘就會出現延遲和 CPU 峰值。

Go 的服務回應時間和 CPU 狀態 (Ref: https://discord.com/blog/why-discord-is-switching-from-go-to-rust )

[1] Go 服務:上圖的版本是 Go 1.9.2。Discord 嘗試了 1.8、1.9 和 1.10 版本的 Go,但皆未有任何改進。最初從 Go 移植到 Rust 完成的時間是在 2019 年 5 月。

為什麼會有 2 分鐘的峰值?

在 Go 語言的設計中,當快取鍵值被清除 (eviction) 時,記憶體並不會立即釋放,而是透過 Garbage Collector 的運行查找不使用的記憶體內存並進行釋放。

簡單來說,在 Go 的世界裡,記憶體並不會在不使用的當下立即被釋放,而是需要一段時間後,當 Garbage Collector 機制偵測到並確定不再使用後才釋出。

在 GC (Garbage Collection) 時間,Go 必須執行大量的工作去決定哪些閒置記憶體該被釋放,但這會減緩了整個程式的速度。

這些固定的延遲峰值直覺看起來就像是被 GC 效能所影響,但 Discord 認為他們已經將 Go 的程式碼做了最有效益的設計使用。結論上來說,Discord 的研究下來,他們並未製造大量的閒置記憶體需要讓 GC 運行負載過大。

在深入研究 Go 的 source code 之後,Discord 發現 Go 會強制至少在每 2 分鐘就運行一次 GC。也就是說,如果在 2 分鐘內沒有任何 GC 機制被觸發的情況下,無論 memory heap 的增長如何, Go 會強制觸發 GC 機制進行清掃。

Go Source Code — GC 機制

在一開始,Discord 團隊認為可以調整 GC 機制,使其更頻繁地觸發 GC 機制進而減少過長的處理運行時間和延遲峰值。但不幸的是,無論怎麼去配置 GC 百分比結果都沒有改變。

Go function: SetGCPercent

事實證明,是因為分配記憶體的速度不夠快,所以無法強制讓 GC 機制更頻繁的發生。

隨著不斷深入嘗試找出峰值很大的原因,Discord 發現並不是因為有大量需要釋放的記憶體內存導致,而是因為 GC 需要掃描整個 LRU cache 之後,才能決定記憶體內存是否要真正釋放。

因此,下一步的處理便是驗證是否更小的 LRU 快取就會更快,因為 GC 要掃描的量體就會更少。基於此推論,Discord 將服務增加了另一個設定來調整 LRU 快取的大小,並調整架構,將每個 server 都擁有許多 partitioned LRU 快取。

結論:Discord 是對的,隨著 LRU 快取變小,GC 造成的峰值影響也不再顯著。

但不幸的是,LRU 快取變小的代價是 p99 的延遲時間更長。因為快取較小,也就代表用戶的 「讀取狀態」(Read States) 不在快取內的機率將會上升。一旦不在快取中,進而要從 DB 存取時,時間就會拉長。

經過大量的負載測試不同的快取空間後,Discord 找到了一個還不錯的設定空間值。雖然沒有辦法完全滿意,但至少已足夠使用。畢竟團隊還有其他優先權更高的事情要處理,因此就這樣讓服務運行了很長一段時間。

在這段時間裡,在 Discord 上的使用 Rust 的其他服務取得了越來越多的成功。因此我們最終決定來創建一個完全用 Rust 建立的新服務所需的框架 (frameworks) 和 函式庫 (library)。

「讀取狀態」(Read States) 服務非常適合移植到 Rust,因為它非常小且獨立,也同時希望藉由 Rust 重構能修掉這些峰值發生的情況,進而改善使用者體驗²。

[2] 需要特別聲明,Discord 團隊認為不應該僅僅因為這樣就用 Rust 重寫整個服務內容。

Rust 中的記憶體管理

Rust is blazingly fast and memory-efficient: with no runtime or garbage collector, it can power performance-critical services, run on embedded devices, and easily integrate with other languages.
Reference: https://www.rust-lang.org/

Rust 沒有 GC 機制,因此 Discord 團隊認為透過 Rust 重構不會再有 Go 相同的峰值。

Rust 採用了相對獨特的記憶體管理方法,它導入了 “ownership” 的點子。基本上,Rust 會追蹤誰可以讀取和寫入記憶體,因此:

  1. Rust 知道程式何時使用內存,並在不再需要時立即釋放內存。
  2. Rust 在編譯時強制執行記憶體規則,幾乎不可能出現執行時間記憶體錯誤³。
  3. Rust 不需要開發者特別追蹤記憶體,編譯器會處理它。

所以在 Rust 版本的 「讀取狀態」(Read States) 服務中,當使用者的 Read State 從 LRU 快取中清除 (evicted) 時,該記憶體也會立即被釋放,不需要等待 GC 來回收。

[3] 當然,除非你用了 unsafe (https://doc.rust-lang.org/nomicon/meet-safe-and-unsafe.html)。

Rust 知道記憶體不再使用後會立即釋放,不再需要任何 runtime 程序來確定是否應該釋放不再使用的記憶體。

Async Rust

Rust 生態系統中有個問題,就是 Rust 的 stable 版本 (當時時間是 2019 年) 並沒有很好的非同步機制 (asynchronous)。非同步機制是網路服務中不可或缺的一環。

當時雖然有一些第三方社群可使用的 library 支援 Rust 非同步機制,但需要非常繁瑣的設定和處理,且錯誤訊息非常遲鈍。

但幸運的是,Rust 團隊非常努力的在將非同步機制在 Rust 裡面處理變得簡單,且是可以在還未穩定版本的 nightly 使用。

Rust 共有三種版本:
stable 版本: 每六週 beta 版若穩定,就會產出 stable 版本
beta 版本:每六週會將 master branch 上的 commit 撿進去變測試版本
nightly 版本: 每個晚上都會產出一個新版本 commit 至 master branch
Reference:
https://rust-lang.tw/book-tw/appendix-07-nightly-rust.html

Discord 團隊的精神是勇於嘗試任何擁有前瞻願景的新技術。例如 Discord 在是 Elixir, React, React Native, and Scylla 的早期使用者。如果一項技術是有願景且可以帶給 Discord 優勢,那 Discord 團隊是不畏懼和處理這些走在前端新技術的困難和不確定性。這是 Discord 團隊用不到 50 名工程師,但卻可讓用戶快速增長到 2.5 億以上用戶的秘訣之一。

回到 Rust,Discord 團隊擁抱 Rust nightly 版本也是另一個實際的例子。以工程團隊的視角而言,採用 Rust nightly 版本的決定是非常值得的,且我們一直持續使用 nightly 版本直到 stable 版本正式導入非同步機制⁴ 為止。這樣的賭注帶來巨大的回報。

[4] https://areweasyncyet.rs/

實作、負載測試和啟用

實際使用 Rust 重構時非常簡單,一開始是簡略的翻譯 (從 Go 至 Rust),並在 Discord 團隊認為有意義的地方去做一些精簡調整。例如 Rust 有非常棒的類型系統 (type system),對泛型有廣泛的支持,所以 Discord 團隊可以捨棄 Go 語言上僅因為泛型而存在的程式碼。此外,Rust 的記憶體模型能夠處理跨線程的記憶體安全,因此能夠捨棄一些在 Go 中所需的一些手動跨 goroutine 記憶體保護。

Reference:
Go 泛型介紹:
https://alankrantas.medium.com/%E7%B0%A1%E5%96%AE%E7%8E%A9-go-1-18-%E6%B3%9B%E5%9E%8B-1d09da07b70

GoLang — 多線程全局變數加鎖 (goroutine):
https://hoohoo.top/blog/golang-multithreaded-global-variable-locked/

當開始執行負載測試 (load testing) 時,對結果相當滿意。Rust 版本的延遲與 Go 版本一樣好,甚至是沒有峰值的延遲。

值得注意的是,在重構 Rust 版本時,Discord 團隊只對最佳化進行基本的思考。也正因為僅僅只做了基本的最佳化而已,Rust 也能夠輕鬆超越 超超超級手動調教的 Go 版本。與必須要非常深入研究 Go 語言的本質相比,充分證明了 Rust 用來處理高效的程式是多麼容易。

但 Discord 團隊並不僅僅滿足在僅匹配 Go 版本的效能。在經過後續分析及性能優化後,Discord 團隊讓 Rust 版本在每項項能指標上都擊敗了 Go 版本:包含延遲、CPU 和記憶體的使用都更好。

Rust 版本的效能優化內容包含了:

  1. 將 LRU 快取的 HashMap 改成 BTreeMap,用來優化記憶體的使用。
  2. 將最初的 metrics library 替換成現在 Rust 的併發性 metrics library。
  3. 減少記憶體副本數量。

由於在正式釋出前有做了負載測試,因此上線後相當順利。也透過放入單個金絲雀節點,找到了一些邊際問題是漏掉的,併順利的修復完畢。不久之後,將整個節點全數覆蓋完畢。

以下是 Go 版本 (紫色) 和 Rust 版本 (藍色) 的差異。

Go 版本和 Rust 版本各指標的差異 (Ref: https://discord.com/blog/why-discord-is-switching-from-go-to-rust)

提高快取空間

服務成功運行數天後,Discord 團隊決定將之前因為 Go 版本 GC 問題而將快取空間降低的措施進行調整,是時候調高 LRU 快取空間了。如之前所述,調高 LRU 快取空間上限會導致 GC 處理時間拉高,但現在 Rust 版本不再需要 GC,因此認為提高 LRU 快取上限將可以得到更好的效能。

Discord 團隊增加了記憶體空間的容量,並優化了資料結構使其使用更少的記憶體 (for fun),快取空間調整到 800 萬個「讀取狀態」(Read States) 服務。

下圖的結果不需要再多做說明。可以注意平均時間先在是以 「微秒」為單位測量,最大 @ mention 以「毫秒」為單位測量。

調整記憶體後的各項指標變化 (Ref: https://discord.com/blog/why-discord-is-switching-from-go-to-rust)

不斷演變的生態系統

Rust 另一個非常棒的優點在於不斷進化和發展的生態系統。最近 tokio (Discord 使用的非同步 runtime) 釋出了 0.2 版本,Discord 順勢進行了升級。

升級之後,可以發現 CPU 的消耗從 16 號開始顯著變低。

Tokio v0.2.0 在 2019/11 發布 (Ref: https://github.com/tokio-rs/tokio/releases/tag/tokio-0.2.0)
Discord 團隊導入 Tokio v0.2.0 後 CPU 消耗下降 (Ref: https://discord.com/blog/why-discord-is-switching-from-go-to-rust)

結語

目前 Discord 團隊在許多地方使用了 Rust 作為構建,像是 遊戲 SDK、直播視訊擷取和編碼、Elixir NIF 以及多種後端服務。

現在當開始一個新專案或是軟體元件時,Discord 團隊會考慮使用 Rust 作為開發選擇。但當然,只使用在認為合理且有意義的地方。

除了效能之外,Rust 對於工程團隊來說還有許多其他優勢。例如它的 type safety 和 borrow checker 讓產品有需求變化或發現該語言新知識時的重構代碼變得非常容易。此外,Rust 的生態系統和工具非常出色,有巨大的動力推動這個語言不停發展。

Reference
Rust type safety:
https://rust-lang.github.io/api-guidelines/type-safety.html
Rust borrow checker:
https://doc.rust-lang.org/1.8.0/book/references-and-borrowing.html

如果你有經歷過或面臨上述經驗,你應該對 Rust 語言感到好奇,或是已經感興趣一段時間了。如果你對如何專業的使用 Rust 語言解決有趣的問他,你可以考慮在 Discord 工作。

另外一個有趣冷知識是:Rust 團隊使用 Discord 溝通,也有一個對開發者非常有幫助的 Rust 社群頻道在 Discord 上,不妨加入瞧瞧

讀後感

本身是 Ruby on Rails 開發團隊的工程師兼主管,可以感同身受當追求服務效能到一個盡頭之後,就是要開始進入研究使用語言本質的階段。

許多程式語言都有使用 GC 機制來控制冗余閒置記憶體,像是 Python、Ruby、Javascript 等等都有,其實都有和 Go 一樣的問題,只是明不明顯,以及 「是否已顯著到影響自家產品的使用體驗」

Ruby 的 GC 演算法機制採用 Tri-Color Mark-Sweep Garbage Collection、Mark-Sweep Garbage Collection 以及 Generational garbage collector 混搭而成。一樣也會有當 GC 觸發時會有峰值產生,但不太明顯,我認為是在 Discord 服務的特性所導致這個問題被放大。

回到是否切換語言這件事,切換語言需要審慎的考量,面向其實很多。

  1. 人力市場是否有足夠的開發人員可以招募?
    如果招募對象設定在台灣工程師,那選用 Rust 作為主要開發語言將會相對辛苦一些。
  2. 如何擴散新語言的知識給團隊成員?
    不可能整個開發 team,只有一個人會 Rust 風險超高。
  3. 是否適配你目前所使用的服務或應用?
  4. 如果是企業,有多少的資源可以切部分人力研究 Rust 並重構服務,重構的重點和目標是什麼?
    成本和帶來的效益是主管必須審慎思考的。
  5. 初期挑選重構對象時,以服務單體是簡單且獨立運行的服務為主
  6. 是否有完善的可觀測性系統去監測線上運行服務?
    才有辦法再轉換之際可以更好的監控邊際問題以及細部狀況。

許多問題需要思考,因此看到這篇文章時,才更深刻體會到 Discord 團隊擁抱新技術和走在技術前端的 DNA,真的了不起,值得借鏡。

--

--