作者 | Jake McGinty
譯者 | 王強
策劃 | 趙鈺瑩
tonari 的目標是在虛擬世界為人們建立真正自然的交互體驗。我們開發了2年,它應該是時延最低的高解析度「電話會議」產品,並且準備好投入生產環境。
我們對比了 Zoom 和 WebRTC 的典型延遲,在辦公室同一網絡上的兩臺筆記本電腦(X1 Carbon 和 MacBook Pro)之間測得 315-500ms 的數字,其中的差距非常巨大。如此大的延遲意味著通話雙方要不斷打斷對方,而 tonari 卻可以做到自然而流暢的遠程交流。從圖像質量來說,市面方案只能提供模糊的面部畫面,攝像頭看起來對準的是對方的鼻子;而 tonari 則能傳輸寬視角的高保真圖像,順暢地通過網絡遠程傳遞當面交流中所見的那些微妙肢體語言。
自 2 月份啟動第一個試點項目以來,我們沒有遇到過軟體導致的停機時間(乙太網電纜斷開是另一回事)。雖然我們很想認為自己是天才般的工程師,但我們真要感謝 Rust,沒有它我們實在沒自信能達到如此高的穩定水平。
https://blog.tonari.no/changing-communication-and-culture-in-an-organization
1為什麼我們不用 WebRTC
tonari 的第一個概念驗證產品使用了一臺基本的投影儀、藍牙揚聲器和一個運行在標準 WebRTC(JavaScript)之上的網站。自那時以來,我們已經走了很長一段旅程。
儘管這個原型(以及我們對未來的願景)為我們贏得了投資,但我們知道 tonari 只靠它是會撞得頭破血流的,除非我們可以實現比 WebRTC 更低的延遲和更高的保真度——在 2020 年的今天,這兩種特性都是市面上的視頻通話產品不具備的。
我們想到了一個法子:「好吧,所以我們可以直接修改 WebRTC 並用 C++ 寫的漂亮 UI 給它打個包,然後就能快速發布了。」
在 WebRTC 將近 75 萬 LoC 的龐大代碼庫中苦苦掙扎一周之後,我們意識到哪怕僅僅是一個小小的更改都可能帶來巨大的痛苦——要測試這些代碼是很難的事情,想要真正對它們產生信心更是痴心妄想。
因此,在一次討論會上,我們認定從頭開始重新實現整個技術棧是容易得多的路線。我們想要了解和理解在我們的硬體上運行的每一行代碼,並且應該針對我們想要的具體硬體進行針對性設計。
因此,我們開始了全新的旅程,從瀏覽器或現有 RTC 項目之類的高級界面深入到更內核的部分,並從零開始進入了底層系統和硬體交互的世界。
我們需要系統本身具有足夠的安全性,以保護 tonari 用戶的隱私。我們需要它具有強大的性能,以使通話過程儘可能人性化和實時流暢。而且,隨著新員工的不斷加入,他們要學習我們的現有工作並在此基礎上進行擴展,我們需要在代碼變得更加成熟的同時使其具備良好的可維護性。
我們討論並排除了一些選項:
安全性:C 和 C++ 的內存和並發都是不安全的,並且它們紛繁複雜的構建系統使我們很難獲得一致且簡單的開發體驗。
性能:Java、C# 和 Go 的內存管理是不透明的,在對延遲敏感的應用程式中,如果要完全控制內存,它們可能會很難用。
可維護性:Haskell、Nim、D 和其他幾種定製語言在工具鏈、社區和人才資源方面不盡如人意。
實際上在我們看來,Rust 是能滿足這些需求的唯一一種可用於生產的語言。
2從 Rust 開始
Rust 的優勢來源於開發社區做出的無數決策。
它的構建系統是 opinionated,而且設計簡潔。它本身就是一個完整的生態系統,你可以輕鬆引導新工程師進入項目並建立開發環境。
內存和並發安全性保證簡直不能更贊。我們相信,如果我們繼續使用 C++ 來開發,那麼到現在都無法進行第一次部署——我們可能還是卡在許多細節上。
我們通過 CUDA 之類的 API 與硬體進行最底層交互的能力,通常是通過現有的板條箱(crate,Rust 的代碼庫術語)實現的,這使我們有能力為第一個生產版本的延遲定下更高的標準。
隨著 tonari 不斷前進,我們現在選擇的嵌入式微控制器的固件可以用 Rust 編寫,因此我們就不用在不安全的編程系統舊世界上興建世外桃源了。
3我們依賴的工具箱
這裡不談 cat Cargo.toml,而是會專注於一些精挑細選過的工具箱。
「優於 std 的」工具箱
在幾乎所有方面,crossbeam 都比 std::sync::mpsc 更適合線程間通信,並且最終可能會合併到 std 中。
parking_lot 在幾乎所有方面都具有優於 std::sync::Mutex 的 mutex 實現,並且某一天可能合併到標準庫中。它還提供了其他許多有用的同步原語。
與 Vec 相比,bytes 是一種更健壯且通常性能更高的字節處理方式。
如果你要進行底層網絡優化,socket2 會是你的最終選項。
錦上添花
fern 是一種自定義和美化日誌記錄輸出的簡單方法。我們使用它來保持日誌的可讀性和內部標準化。
structopt 是你一直夢寐以求的 CLI 參數處理方式。除非你的依賴項幾乎沒有,否則沒有理由不使用它。
Cargo 經典傳奇
cargo-release 使我們能夠輕鬆減少內部版本。
cargo-udeps 可以識別未使用的依賴項,並儘可能減少我們的構建時間。
cargo tree(最近集成進了 cargo)顯示了一個依賴樹,它在許多方面都很有用,但主要用於找出最小化依賴項的途徑。
cargo-geiger 幫助我們快速評估外部依賴,以解決可能的安全性(或正確性)問題。
cargo-flamegraph 在跟蹤代碼中的性能熱點時給了我們巨大的幫助。
4項目結構
tonari 代碼庫是單體架構。從本質上講,我們有一個帶有 binaries 板條箱和許多支持庫板條箱的 Cargo 工作區。
我們將工具箱放在一個存儲庫中,這樣就很容易在我們的 binaries 板條箱中引用,而無需發布到 crates.io,或在我們的 Cargo.toml 中指定 git 依賴項那麼麻煩了。當需要將這些庫開源發布時,很容易就能把它們分解成單獨的存儲庫
庫,二進位,為什麼不兩者並用?
我們有一個主庫,其中包含一個用來與硬體、媒體編解碼器、網絡協議等通信的統一 API。除了這個私有 API 外,我們在工作區中還有獨立的板條箱,我們將這些板條箱視為開放原始碼的候選人。例如,我們已經自行編寫了適合長期運行的高吞吐量 actor 的 actor 框架,以及用於可靠、高帶寬、低延遲媒體流的網絡協議。
我們將不同的二進位文件用於 tonari 系統的不同部分,並且每個二進位文件都位於 binaries 中。它的庫模塊包含一組可重用的 actor,將我們的私有 API 與 actor 系統結合在一起,然後是消費這些 actor 並定義它們之間管道的單個二進位文件的集合。
視野所及的標誌
我們廣泛使用功能標誌,以在不同的 OS(例如古老的 MacBook Pro)或不同的硬體配置上開發項目。這使我們能夠輕鬆更換攝像頭硬體,而無需進行額外的運行時檢查或使用可怕的 sed 編程技巧。
例如,Linux 使用 v4l2(Video For Linux...2)來訪問大多數網絡攝像頭,但是其他網絡攝像頭可能有自己的 SDK。要針對不使用 v4l2 的平臺或在特定作業系統不可用的 SDK 進行編譯時,我們可以將這些 SDK 放在功能標誌後面,並導出一個公共接口。
舉一個(簡化的)具體示例,假設我們有一個定義為一個 trait 的通用攝像頭接口:
pub trait Capture {/// Capture a frame from a camera, returning a Vec of RGB image bytes.fn capture(&mut self) -> Vec;}
假設我們有三種不同的攝像頭界面:v4l2、corevideo 和 polaroid。我們可以讓 binaries 專門針對此 trait 來獲得靈活性,並且可以使用功能標誌切換不同的 Capture 實現。
#[cfg(feature = "v4l2")]mod v4l2 {pub struct V4l2Capture {...}
impl Capture for V4l2Capture {fn capture(&mut self) -> Vec {...}}}
#[cfg(feature = "corevideo")]mod corevideo {pub struct CoreVideoCapture {...}
impl Capture for CoreVideoCapture {fn capture(&mut self) -> Vec {...}}}
#[cfg(feature = "polaroid")]mod polaroid {pub struct PolaroidCapture {...}
impl Capture for PolaroidCapture {fn capture(&mut self) -> Vec {...}}}
#[cfg(feature = "v4l2")]pub type VideoCapture = v4l2::V4l2Capture;
#[cfg(feature = "corevideo")]pub type VideoCapture = corevideo::CoreVideoCapture;
#[cfg(feature = "polaroid")]pub type VideoCapture = polaroid::PolaroidC
如果讓我們的代碼與實現 Capture trait 的事物搭配,而不是與具體類型搭配,那麼現在我們可以簡單地切換功能標誌來在各種目標平臺上編譯。例如,我們可以有一個結構,該結構具有一個欄位 video_capture: Box ,它能使我們存儲可從攝像機 Capture 的任何類型。
一個支持上面我們編寫的捕獲實現的示例 Cargo.toml 文件可能是這個樣子:
[package]name = "tonari"version = "1.0.0"edition = "2018"
[features]default = ["v4l2"]macos = ["corevideo"]classic = ["polaroid"]v4l2 = ["rscam"]
[dependencies]rscam = { version = "0.5", optional = true } # v4l2 linux camera librarycorevideo = { version = "0.1", optional = true } # MacOS camera librarypolaroid = { version = "0.1", optional = true } # Polaroid camera librar
這樣,我們就可以避免構建並連結到特定於平臺的庫,例如 v4l2 這樣並非完全通行的選項。
5在工作中學習 Rust
轉換到 Rust 一年後,我們的第四位工程師加入了團隊,他在 Rust 或系統工程方面都沒有很多經驗。雖然學習曲線是不可否認的,但我們發現 Rust 為那些剛接觸底層編程的新手提供了驚人的力量。
如前所述,該語言內置的內存和並發安全性意味著一籮筐問題不僅是無法編譯的,而且編譯器本身往往就是你唯一需要的老師,因為它給出的警告解釋得非常清楚。關於 Rust 出色的編譯器消息以及出色的文檔(可以看一下關於字符串的長長討論),已經有很多文獻做出了介紹。對於我們來說,這些都是非常有用的資源。
https://doc.rust-lang.org/stable/book/
https://doc.rust-lang.org/book/ch08-02-strings.html
與其他許多語言不同,在 Rust 中通常有一種顯而易見的「正確方法」來做各種事情。並非以「正確方式」編寫的代碼往往會非常顯眼,並且很容易在審核中跳出來,往往是由 cargo clippy 自動識別出來的。
實際上,這意味著新工程師可以快速開始貢獻可用於生產的代碼。代碼審查可以繼續專注於實現,而不是花費更多精力手動做正確性檢查。
IDE 普查
在 IDE 部門中,我們發現 Rust 與某些前輩相比還相對不夠成熟。尤其是今年,我們取得了長足的進步,每個人都找到了一個非常舒適的開發環境。
我們經常分享設置並互相嘗試對方的環境(Brian 除外,他在 29 歲之後就停滯不前了),並且我們一直在關注可以幫助我們更好地協作的新開發工具。
代碼風格指南已死,rustfmt 萬歲
體驗過狂野的開發人生嗎?提交代碼之前,我們沒有必須閱讀的代碼樣式指南文件。我們不需要這種東西。我們只是強制執行 rustfmt。告訴你吧:這確實是代碼審查的前沿陣地。
我們如何審查代碼
我們的代碼審查非常簡單,因為到目前為止我們只有四個人,而且我們很幸運在彼此之間贏得了很多信任。我們的主要目標是在每一行代碼上至少有兩對眼睛盯著,並且不要互相擋路,以便我們保持活力。
6持續測試
我們使用谷歌的 Cloud Builder 來運行 CI 構建,因為我們的基礎架構棧主要基於 GCP 構建,並且可以輕鬆調整構建機器規格和自定義構建映像。每次提交都會觸發它,並運行 cargo clippy 和 cargo build。我們將 -D warnings 傳遞給編譯器,以將警告升級為錯誤,確保我們的更改不會在可憐的同事下次拉取更改時劈頭蓋臉迎來大堆 rustc 警告。
為了縮短配置項構建時間,我們將 target 和.cargo 目錄緩存在 Cloud Storage 中,以便下次可以下載並增量構建。
for crate in $(ls */Cargo.toml | xargs dirname); dopushd $crate
# Lint.cargo +$NIGHTLY_TOOLCHAIN clippy --no-default-features -- -D warnings
# Build.time RUSTFLAGS="-D warnings" cargo build --no-default-features
# Test.time cargo test --no-default-features
popdd
我們也聽說了關於 sccache 的好消息,並將很快對其進行評估!
7與現有的 C/C++ 庫集成
Rust 生態系統很棒,但是有大量現有項目需要大量時間投入才能移植到 Rust。webrtc- audio- processing 就是一個很好的例子。它提供的好處(沒有回聲或刺音的清晰音頻)很明顯,並且不太可能在短期內將其移植到 Rust(它大約包含 8 萬行 C 和 C++ 代碼)。
值得慶幸的是,Rust 很容易使用現有的 C 和 C++ 庫。bindgen 這個板條箱完成了大部分繁重的工作。給它一個用 C 或 C++ 編寫的頭文件,它將自動生成(不安全的)Rust 代碼,該代碼可以調用頭文件中定義的函數。到那時,由你決定是否創建一個更高級別的 Rust 板條箱,以暴露一個安全的 API。
https://crates.io/crates/bindgen
對於具有簡單或常用構建過程的庫,這個過程中的大部分是相當自動化的。但是,創建更高級別的安全 API 很重要——bindgen 提供的 Rust API 不適合直接使用,因為它不安全且不太符合習慣。幸運的是,一旦有了更高級別的 API,你最後就可以將 C 庫換成你自己的 Rust 版本,而板條箱的消費者並不會察覺其中的變化。
這些特性使我們可以使用很多永遠沒有原生 Rust API,或者需要數月或數年才能重新實現的 API 和硬體。底層 OS 庫、大型代碼庫(如 webrtc- audio- processing)和製造商提供的相機 SDK 都可以用在我們的 Rust 代碼庫中,而無需將整個應用程式語言轉移到 C++,同時仍然可以提供良好的性能。
一些 C++ 庫很難直接從 Rust 對接。你必須將類型列入白名單,因為 bindgen 無法處理引入的一部分 std::* 類型,它不適用於模板化函數和複製 / 移動構造器,以及此處記錄的其他許多問題。
https://rust-lang.github.io/rust-bindgen/cpp.html
為了解決這些問題,我們通常會創建一個簡化的 C++ 頭文件和源包裝程序,以導出對 bindgen 友好的函數。這種工作要複雜一些,但與將整個庫移植到 Rust 相比工作量要少得多。你可以在此處查看這個包裝器創建的示例。
https://github.com/tonarino/webrtc-audio-processing/tree/2a973929c3afbc24beea75aa235f3341a7be275a/webrtc-audio-processing-sys/src
由於 Rust 的所有生態系統以及 C/C++ 項目僅僅是對 bindgen 的調用,我們可以輕鬆訪問現有的一些最高質量的軟體包,而不必犧牲執行速度。
8Rust 的痛點
Rust 並非沒有問題。這是一種相對較新的語言,並且在不斷發展,大家在評估向 Rust 的遷移選項時應該考慮到它的一些缺點。這裡是我們總結的一部分痛點清單:
編譯時間長。著名的 xkcd 漫畫諷刺說等待 Rust 代碼編譯時可以去喝咖啡休息一陣兒,這是很真實的。例如,我們的代碼庫大約需要 8 分鐘才能在中等性能的筆記本電腦上以非增量方式完成編譯,但實際情況可能會更糟。Rust 編譯器有很多工作要做,以實施強大的語言保證,並且它必須從原始碼編譯整個依賴樹。增量構建的情況會好些,但是一些板條箱附帶了構建腳本,這些腳本可以拉出並編譯非 Rust 的依賴項代碼,並且在升級版本和切換分支時可能需要清除構建緩存。
庫覆蓋率。Rust 的庫生態系統已經相當成熟,但與 C/C++ 相比覆蓋範圍還是有限。我們最終實現了自己的抖動緩衝區,並且還使用 Rust 的 bindgen 包裝了多個 C/C++ 庫,這意味著我們的 Rust 代碼中存在 unsafe 區域。不常見的項目往往會有少量的不安全代碼,這惡化了學習曲線,帶來了更多出現內存錯誤的機會。
Rust 要求你首先編寫正確和明確的代碼。如果弄錯了,編譯器不會漏掉它的。如果你不太在意並發性和內存保證,那麼開發時會感覺到速度緩慢,卻並沒有必要。但是,Rust 開發人員一直在努力改善錯誤消息。它們友好且可操作,通常包含修復建議。良好的內存和並發基礎模型還有助於更快地克服最初的駝峰,因此我們建議你花一些時間來真正理解語言及其保證。
Rust 的類型推斷器是如此強大,它使你有時感覺就像正在使用動態類型的語言一樣。就是說,有時它並不能完全按照你想要的方式工作,特別是當涉及到泛型和 deref coercion 時,你最後不得不四處摸索才能讓這個推斷器滿意。這可能會帶來挫敗感,而團隊中如果有人經歷了這一學習階段,那就太有幫助了。有了足夠的耐心,這種挫敗感通常會變成令人驚嘆的時刻,可以加深我們對語言設計及其用途的理解,避免可能會引入的錯誤。
語言進化。Rust 語言正在不斷發展。某些語言結構(例如 async)仍然是脆弱的,你可能會發現最好還是堅持使用線程和標準庫。
9選擇 Rust 後,到目前為止的情況
到目前為止,我們沒有發生與軟體相關的停機,這既令人驚喜,又是 Rust 保證提供的安全性的證明。Rust 還使我們可以輕鬆使用高效資源來編寫高性能代碼——我們的 CPU 和內存使用率都是可預測且一致的。因為沒有垃圾收集器,我們可以保證一致的延遲和幀速率。
我們維護 Rust 代碼庫的體驗也很棒。對我們的代碼庫進行大量更改後,我們可以放心地對延遲部分進行重大改進。乾淨的編譯並不總是意味著一切都會正常工作,但老實說,這種情況還是很平常的。
我們的最終成果是獲得了可靠的產品,也不需要噩夢般的維護工作,並且可以符合我們要求的幀速率、延遲和資源效率的高標準。同樣,很難想像如果沒有 Rust,我們現在會是什麼樣子!
到目前為止,我們已經開源了一個 FFI 板條箱,即 webrtc-audio-processing。這是過去我們存儲庫中最高級的板條箱之一,在開源過程中還有更多類似的板條箱。
https://doc.rust-lang.org/nomicon/ffi.html
以後,隨著我們發布更多代碼,關於這個主題的內容將會越來越多。但有一件事情是不變的:在開源之前,我們私下創建的每個板條箱都已經做好了開源的準備。這種理念使我們板條箱之間的界限更加清晰,並鼓勵我們更流暢地做出要將代碼庫中哪些部分開源的決策。
https://blog.tonari.no/why-we-love-rust