透過 Rust 探索系統的本原:編程語言

連續寫了好幾篇和 Rust 相關的文章,有朋友說:你好像還沒有寫過一篇比較正式的介紹 Rust 的文章哦。想了想確實如此,我寫過不少介紹編程語言的文章,包括:Python,JavaScript,Racket,Clojure,Elixir,甚至我個人不怎麼喜歡的 Golang,卻沒有正兒八經寫一篇關於 Rust 特點或者 Rust 世界觀的文章。

於是,我開始構思一篇從編程語言設計的角度出發,來探尋 Rust 的獨到之處,以及這樣的思想可以如何應用在我們的工作當中。正巧,我司(Tubi)內部也漸漸有越來越多的同學對 Rust 感興趣,想讓我做個 BBL 講講;我想了想,一個 BBL 似乎很難全面地介紹一門語言,於是構思了九講,分九周完成;而這其中,第一講是至關重要的,我需要讓一羣對 Rust 並不瞭解的工程師能在 45 分鐘內 get 到 Rust 的精髓,這樣,後續的講座纔有存在的意義。

結果就誕生了這篇文章,以及與之相關的 slides。

編程語言的世界觀

一門編程語言的誕生,一定有它想解決的問題。而圍繞着這個問題,語言會有自己的一個世界觀。比如說我們熟知的 Erlang 的世界觀:

在這個世界觀下,Erlang 使用了 6 個基本函數:spawnsendreceiveregisterwhereisself,構建了一個恢弘大氣的分佈式系統。

再比如 Golang,其基本的處理併發的思想人人熟知:Do not communicate by sharing memory; instead, share memory by communicating。但要想輕鬆地用幾句話概括整個 golang 這門語言的世界觀,似乎有些費勁。Rob Pike 介紹過 golang 的最初想法 [2]:

所謂種瓜得瓜種豆得豆,語言誕生初期的世界觀,決定了語言之後的走向。很多人抱怨 golang 沒有泛型,但泛型恰恰和 C-like 這個思想是衝突的,因而如果要引入泛型, golang 勢必需要對其世界觀進行妥協,這種妥協帶來的代價將會是巨大的(起碼會急劇影響 golang 語言的簡單性,以及其引以自豪的編譯速度)。

對於 Rust 而言,它誕生的初衷是作爲一門可以替代 C++ 的系統級語言,滿足如下核心價值觀 [3]:

顯然,Memory safety 和 productivity 是 C++ 開發者的痛點。一門新的系統級語言可以在不失其效率的情況下達到內存安全麼?C++ 做了很多探索,創造了不少智能指針,把 RAII(Resource Acquisition Is Initialization)引入語言當中,但由於歷史的包袱,使得 C++ 只能在現有的體系下修補,無法從根上解決這一問題。Rust 則不然,它站在 C++ 的肩膀上,引入了所有權(ownership)和借用機制來提供內存安全,並創造性地使用了類型安全來輔助併發安全。所有權和借用機制雖然優雅且很容易理解,但它和我們所熟知的編程範式大爲不同,讓程序員無法隨心所欲寫出錯誤的代碼,再加上 Rust 有意地在編譯時就把這些錯誤暴露給開發者,使得 Rust 初期的學習曲線變得非常陡峭,對那些學了個一知半解就喜歡上手的開發者來說,尤其陡峭。

也因爲 Rust 對自己系統級語言的定位,使得它對性能有一種偏執 —— 語言提供給開發者的抽象需要儘可能達到零成本(Zero-cost abstraction):任何功能只要你不用,就沒有成本;你如果使用,你自己手工優化的代碼也不可能比語言爲你生成的代碼效率更高。因爲這種偏執,使得 Rust 即便早期對異步 IO 有 green thread 這樣在其他語言看來已經是相當不錯的支持,在 Rust 進入 1.0 版本時,這個功能還是被徹底拿掉 [3],因爲它會像 golang 一樣,即便開發者不使用任何 green thread,其運行時帶來的額外開銷還在那裏。所以 Rust 對異步 IO 的支持,直到 1.0 發佈數年後的 Rust 1.39 async/await 正式 stable,才重新完美具備。既然提到了 async/await,容我再多說一句,我認爲 Rust 在 async/await 的零成本抽象上帶給整個編程語言世界的影響是巨大而長遠的,它就像那個打破了「四分鐘一英里」魔咒的 Roger Banister [4],讓世界知道:高級的抽象並不必然以犧牲性能或者添加額外的運行時爲代價。

Rust 還有一個重要的,被大家低估的世界觀:公開透明(explicitness)。使用者可以對他所寫的代碼做到完全瞭解和掌控。

很多「高級」編程語言會營造一種易於學習的氛圍:你不需要了解一切,不需要熟悉計算機工作原理,不需要掌握操作系統的基本知識,你也可以「高效」編程。這其實是一種假象。如果你做的事情僅僅和 CRUD 相關,那麼掌握一些高層次的 API 的確可以很好地完成工作,但當你面臨更復雜的系統設計時,當你想成爲一名有追求的開發者時,你會遭遇瓶頸 —— 你還是得老老實實構建需要的知識體系,可是當初的「輕鬆」已經成爲負擔,就像練習鋼琴一開始在雙手的姿勢上走了捷徑,隨着聯繫難度的增高,這捷徑會反噬你。

而且這種假象還會被人才市場無情戳破。Java 工程師的確不需要了解內存的運作機制也能編程,但面試的時候,GC 的原理,Java memory leak 可能產生的原因,Java VM 是如何工作的這類問題還是屢見不鮮。原因無他,你如果不瞭解一切,你無法寫出高效安全且設計良好的代碼。同樣是程序員,在並非供不應求的大環境下,用人單位更青睞那些有追求的程序員。

Rust 沒有試圖遮掩,它將所有你需要了解的細節明確地在編譯環節暴露出來,並且把什麼可爲什麼不可爲的邊界清晰地展現。這的確會給開發者帶來學習的負擔 —— 如果一個開發者對一門語言的從小工到大牛的掌握過程中所經受的 全部痛苦是 100 分的話,Rust 的公開透明 —— 編譯器把醜話說在前面 —— 幫你把 100 分降低爲 90 分,然後在頭 6 個月讓你經受 70 分痛苦,接下來的 5-8 年經受剩下 20 分的痛苦;而其它語言會讓你在頭一兩年只經受 20-30 分的痛苦,哄着你,呵護着你,然後在接下來的 5-8 年讓你慢慢經受之後的 70-80 分的痛苦。

此外,很多語言沒有明確的邊界,有些地方做很多限制,有些地方卻什麼限制都沒有,使得編程本身需要靠開發者額外的自覺或者規範才能保證代碼的正確性。比如 Java 在內存分配和回收上設定了邊界和限制,但在內存的併發訪問上沒有設定邊界和限制,開發者如果不遵循一定規範,很難做到代碼的線程安全。C 語言幾乎沒有設定任何邊界和限制,使得指針的解引用成爲開發者的夢魘。而 Rust 對一個值在某個 scope 下的所有可能訪問做了嚴格的限制,並通過編譯器將這些規則明確地告訴開發者(我們下文再展開)。

編程語言設計上的取捨

不同的編程語言爲了解決不同的問題,形成了自己初始的世界觀和價值觀。而這些世界觀和價值觀,會嚴重影響編程語言設計上的取捨。一般而言,一門語言在設計之初,總需要在:性能(performance),安全(safety)和表達力(expressiveness)上做取捨。我畫了個圖,粗略地概括了不同編程語言在這些方向上的取捨:

Assembly/C/C++ 顯然是爲了效率犧牲(部分)安全性和表達能力。這帶來的後果是開發難度太大。

爲了達到內存安全,以 Java 爲首的很多語言採用了 GC(垃圾回收)。這意味着用其開發出來的系統不得不忍受三大弊病:1) 巨量內存消耗 —— 內存使用可以達到非垃圾回收系統的 1.5-5 倍之多。2) STW(Stop The World),垃圾回收線程工作時會導致其它線程掛起以方便清理內存。Erlang 爲解決這個問題,把垃圾回收的粒度縮小到每個 process。3) 開發者並不太在意堆上內存的分配,甚至會無節制地使用堆上的內存。

OjbC 和 Swift 採用了 ARC(自動引用計數)的方式管理內存,編譯器會分析每個對象的生命週期,爲其插入維護引用計數的代碼,當引用計數爲 0 時釋放對象使用的內存。這種方式相對於 GC,其內存開銷和計算開銷都大大減小,沒有 STW 的問題。但 ARC 無法很好處理循環引用(retain cycle),需要開發者手工處理(使用 weak reference 或者 unowned reference),如果處理不妥則會帶來內存泄漏。

儘管 ARC 帶來的開銷很小,我們要記得,它還是有額外開銷的。

大部分編程語言並不提供太多對併發安全的保護。Java 提供了內存安全,但如果你要保證代碼的線程安全,需要遵循某些規範,比如:

編譯器並不強迫你遵循這些規範,所以,一個不理解併發安全的程序員很可能寫出編譯通過但導致 race condition 的代碼。

而小部分保證了併發安全的語言,則在內存安全的前提下,引入了一些特定的規則:

以上無論內存安全還是併發安全的解決方案,都有不菲的代價。這對於把安全和性能作爲語言核心要素的 Rust 來說是不可接受的。

所以 Rust 註定要另闢蹊徑。這個「蹊徑」就是上文提到的所有權和借用規則,其原理非常簡單:

  1. 在一個作用域(scope)內,一個值(value)只能有一個所有者(owner):

  2. 當所有者離開作用域時,值被丟棄(Drop)

  3. 值可以從一個作用域移動(move)到另一個作用域,但當前所有者立刻失去對值的所有權

  4. 值可以被借用(reference),但借用的生存期不能超過所有者的生存期(lifetime):

  5. 在一個作用域內,允許有多個不可變借用

  6. 或者至多一個可變借用(可變借用是獨佔的)

這個規則非常簡單,如果你回顧我們寫線程安全代碼所遵循的規則,二者非常類似。只不過,Rust 把這些規則變得更明確,並且在編譯器裏強制執行。如果開發者的代碼違反了任何一條規則,代碼將無法編譯通過。

這成爲 Rust 帶給開發者極大痛苦的一個根源。和 C/C++/Java 相比,Rust 編譯器苛責你不僅要寫出語法正確的代碼,還必須寫出符合內存安全和併發安全的代碼,否則,不讓你編譯通過。因而,你需要非常仔細地考慮你是如何設計你的系統的:數據是否共享,如果共享,它們是如何傳遞,如何被引用,都被誰引用,生存期有多長等等。

也就是說,Rust 強迫你好好設計你的系統,你的接口,減少寫代碼的隨意性,減少內存訪問的隨意性,把大部分問題都扼殺在搖籃。這看似不近人情的處理方式,其實極大地幫助我們成爲更好的程序員。

當然,Rust 的強迫和其它語言的強迫有本質的不同。爲了併發安全,Golang 強迫你使用 channel,Erlang 強迫你接受 actor model,它們都剝奪了你創建線程的權力,即便你很明確你要做的事情是計算密集而非 I/O 密集的任務。在這些體系之下,開發者沒有太多的選擇。Rust 只是強迫你寫「對」的代碼,但並不強迫你選用什麼樣的工具來達到這一路徑。同樣是併發處理,你可以使用 atomics,可以共享內存(Arc<RwLock<T>>),可以使用異步任務,當然也可以使用 channel(類似 Golang)。你可以使用線程,使用異步任務,甚至混用它們。Rust 不關心你實現的手段,只是強迫你把代碼寫對。

單單靠上面的所有權和借用規則,還不足以完全保證併發安全,Rust 巧妙地使用了類型系統,來幫助構建一個完整的併發安全體系。在 Rust 裏,類型的行爲是通過 Trait 來賦予的,幾乎所有的數據結構都實現了一個到多個 Trait。其中,有一些特殊的 Trait 來說明類型安全:

比如,Rc<T>(線程內引用計數的類型)被標記爲沒有實現 SendSync,因而,要想跨線程使用 Rc<T> ,Rust 編譯器會報錯,並且告訴你,你在某個上下文中使用了類型不安全的數據(在這裏,你只能使用 Arc  - Atomic Reference Counter)。

這是一個非常漂亮地對類型系統的使用,它是如此地簡單,優雅,且可擴展,使得編譯器只需做好類型安全的檢查。

同時,Rust 還有其它一些 Trait,它們跟線程安全沒有直接關係,但巧妙地輔助了線程安全:

上面說的這麼多內容,可以用一張圖來濃縮:

當你對這幅圖理解地越深,你會愈發感慨 Rust 設計上的巧妙:從定義一個變量的值如何在不同場景下訪問,得出幾條簡單的規則,再輔以類型安全,不引入任何運行時額外的開銷,就保證了內存安全和併發安全。這種迴歸本源的做法,很像物理學的「第一性原理」:我們撥開表面的紛繁複雜,迴歸事物的本質,往往能得到最簡單,同時也是最精妙的解決方案。所謂「大道至簡」,不過如此。

下圖的代碼,我刻意違背幾乎所有的所有權和借用規則,Rust 編譯器就像坐在我身旁跟我 peer review 的老司機一樣,清晰地告訴我代碼中所存在的問題:

以及,當我試圖像一個 C 程序員那樣,寫出非併發安全的代碼時,Rust 的所有權和借用規則,以及類型系統一起幫助我發現所有的併發安全方面的問題:

賢者時刻

Rust 對我們做系統的啓示:首先是把系統的運行規則清晰地定義出來,然後對其做類似從特殊化的定理到一般化的公理那樣的推敲和抽象,找到滿足系統運行的最小化的核心規則,最後用這些規則來限制系統的行爲。

參考資料

[1] 思考,問題和方法

[2] Go at Google: Language Design in the Service of Software Engineering: https://talks.golang.org/2012/splash.article

[3] How rust views tradeoffs: https://www.infoq.com/presentations/rust-tradeoffs/

[4] 四分鐘一英里

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ZA-_BARVAWe0Q4eM0lYgwg