導語 |
本文推選自騰訊雲開發者社群-【技思廣益 · 騰訊技術人原創集】專欄。該專欄是騰訊雲開發者社群為騰訊技術人與廣泛開發者打造的分享交流視窗。欄目邀約騰訊技術人分享原創的技術積澱,與廣泛開發者互啟迪共成長。本文作者是騰訊前端開發工程師於玉龍。
本文主要對rust相關內容進行解讀分析,
希望本文能對此方面感興趣的開發者們
提供一些經驗和幫助。
關於Rust
ru
st是一門強型別的、編譯型的、記憶體安全的程式語言。最早版本的Rust原本是Mozilla基金會的一名叫Graydon Hoare的員工的私人專案,2009年開始,Mozilla開始贊助者們專案的發展,並於2010年,Rust實現了自舉——使用Rust構建了Rust的編譯器。
Mozilla將Rust應用到構建新一代瀏覽器排版引擎Servo當中——Servo的CSS引擎在2017年開始,整合到了FireFox當中去。
Rust原本作為一種記憶體安全的語言,其初衷是代替C++或者C,來構建大型的底層專案,如作業系統、瀏覽器等,但是因為Mozilla的這一層關係,前端業界也注意到了這門語言,並將它應用在了其他領域,其生態也慢慢繁榮起來。
記憶體安全——Rust的一大殺手鐧
眾所周知,當下主流的程式語言當中一般分為兩類,一類是自動GC的,如Golang、Java、JavaScript等,另一類則是C++和C,使用者需要手動管理記憶體。
大部分語言的記憶體模型都是大同小異的。
當代碼被執行時,一個個變數所對應的值,就被依次入棧,當代碼執行完某一個作用域時,變數對應的值也就跟著出棧,棧作為一個先進後出的結構非常符合程式語言的作用域——最外層的作用域先宣告、後結束。但是棧無法在中間插入值,因此棧當中只能儲存一旦宣告、佔用空間就不會改變的值,比如int、char,或者是固定長度的陣列,而其他值,比如可變長度的陣列vector,可變長度的字串String,是無法被塞進棧當中的。
當程式語言需要一個預先不知道多大的空間時,就會向作業系統申請,作業系統開闢一塊空間,並將這一塊空間的記憶體地址——指標返回給程式,於是程式語言就成功將這些資料存到了堆中,並將指標存到棧當中去——因為指標的大小是固定的,32位程式的指標一定是32bit,64位程式的指標也肯定是64bit。
棧中的資料是不需要做記憶體管理的,隨著程式碼執行,一個變數很容易被判斷還有沒有用——只要這個變數的作用域結束,那麼再也無法讀取到這個變數的值,那麼這個變數肯定沒用了。只需要隨著作用域的宣告與結束,不斷的入棧和出棧就足以管理棧的記憶體了,不需要程式設計師操心。
但是堆當中的資料就不行了,因為程式拿到的只是一個記憶體指標,實際的記憶體塊不在棧當中,無法隨著棧自動銷燬。程式也不能在棧當中的記憶體指標變數銷燬時,就將指標對應的空間自動清理——因為可能有多個變數儲存的指標都指向
了同一個記憶體塊,此時清理這個記憶體塊,會導致意料之外的情況。
基於此,有的程式自帶一套非常複雜的GC演算法,比如透過引用計數,統計一個記憶體區塊的指標到底儲存在多少個變數當中,當引用計數歸0時,就代表所有的指向此處的指標都被銷燬了,此處記憶體塊就可以被清理。而有的程式則需要手動管理記憶體空間,任何堆當中開闢的空間,都必須手動清理。
這兩種辦法各有優劣,前者導致程式必須帶一個runtime,runtime當中存放GC演算法,導致程式體積變大,而後者,則變得記憶體不安全,或者說,由於記憶體管理的責任到了程式設計師頭上,程式設計師的水平極大程度上影響了程式碼安全性,忘記回收會導致程式佔用的記憶體越來越大,回收錯誤會導致刪掉不應該刪的資料,除此以外還有透過指標修改資料的時候溢位到其他區塊導致修改了不應修改的資料等等。
而Rust則採取了一種全新的記憶體管理方式。這個方式可以簡單概括為:程式設計師和編譯器達成某一種約定,程式設計師必須按照這個約定來寫程式碼,而當程式設計師按照這個約定來寫程式碼時,那麼一個記憶體區塊是否還在被使用,就變得非常清晰,清晰到不需要程式跑起來,就可以在編譯階段知道,那麼編譯器就可以將記憶體回收的程式碼,插入到程式碼的特定位置,來實現記憶體回收。換句話說,Rust本質上是透過限制引用的使用,將那些【不好判斷某塊地址是否還在使用】的情況給規避了,剩餘的情況,都是很好判斷的情況,簡單到不需要專業的程式設計師,只需要一個編譯器,就能很好的判斷了。
這樣的一大好處是:
不需要GC演算法和runtime,本質上還是手動回收,只不過編譯器把手動回收的程式碼插入進去了,程式設計師不需要自己寫而已。
只要編譯可以透過,那麼就一定是記憶體安全的。
(一)實現原理
rust的記憶體安全機制可以說是獨創的,它有一套非常簡單、便於理解的機制,叫做所有權系統,這裡面會涉及到兩個核心概念,所有權和借用。
(二)所有權
任何值,包括指標,都要繫結到一個變數,那麼,我們就稱這個變數擁有這個值的所有權,比如以下程式碼,變數str就擁有“hello”的所有權。
let
str =
“hello”
當str所在的作用域結束時,str的值就會被清理,str也不再有效。這個和幾乎所有主流語言都是一致的,沒有什麼問題。也很好理解。
但是注意一下,Rust本身區分了可變長度的字串和不可變長度的字串,上文是一個不可變長度的字串,因為其長度不可變,可以儲存在棧當中,於是下面這一段程式碼可以正確執行,就像其他幾乎所有主流語言一樣:
let
str =
“hello world”
;
let
str2 = str;
println
!(
“{}”
, str);
println
!(
“{}”
, str2);
但如果我們引入一個儲存在堆裡、長度可變的字串,我們再來看看同樣的程式碼:
fn main() {
let
str =
String
::
from
(
“hello world”
);
let
str2 = str;
println!(
“{}”
, str);
println!(
“{}”
, str2);
}
此時,我們會驚訝地發現,程式碼報錯了。為什麼呢?
原因在於,第一段程式碼當中,str這個變數的值,儲存在棧裡,str這個變數所擁有的,是hello world這一串字串本身。所以如果令str2=str,那麼相當於又建立了一個str2變數,它也擁有這麼一串一模一樣的字串,這裡發生的是“記憶體複製”。兩個變數各自擁有hello world這一個值的所有權,只不過兩者的hello world不是同一個hello world。
而第二段程式碼當中,我們拿到的str,本質上只是一個指向到某一個記憶體區塊的地址,而這個地址,當我們另str2=str的時候,實際上是將這一個地址的值賦值給str2,如果是在其他語言當中,這麼寫極大機率是沒問題的,但是str和str2會指向同一個記憶體地址,修改str的時候,str2也變了。但是rust當中,同一個值只能被繫結到一個同一個變數,或者說,某一個變數對這一個值有所有權,就像一個東西同一時間只能屬於同一個人一樣!當令str2=str的時候str儲存的地址值,就不再屬於str了,它屬於str2,這叫做【所有權轉移】。所以str失效了,我們使用一個失效的值,那麼自然報錯了。
以下這些情況都能導致所有權轉移:
上文提到的賦值操作:
let str = String::from(“hello world”); let str2=str; //str失去所有權!
將一個值傳進另一個作用域,比如函式:
let str=String::from(“hello world”); some_func(str); //此時str失效。
這樣,我們就可以很簡單的發現,對於同一個記憶體區塊地址,它同時只能儲存在一個變數裡,這個變數如果出了作用域,導致這個變數讀取不到了,那麼這個記憶體地址就註定永遠無法訪問了,那麼,這個記憶體區塊,就可以被釋放了。這個判斷過程非常簡單,完全可以放在靜態檢查階段讓編譯器來實現。所以rust可以很簡單的實現記憶體安全。
但,上述的寫法是很反人類的,這確實解決了記憶體安全的問題,但是不好用。比如我需要將str傳入一個方法做一些邏輯操作,做完操作之後我還希望我能讀取到這個str,比如類似於下面這段程式碼:
fn main() {
let
mut str1 =
String
::
from
(
“hello world”
);
// 這裡的mut只是標註這個變數是可變的變數,而非常量。
add_str(mut str1,
“!!!”
);
println!(
“{}”
, str1);
}
fn add_str(str_1:
String
,
str_2
: &str) {
str_1。push_str(str_2);
}
我們希望對str進行操作,後面新增三個感嘆號然後打印出來,這段程式碼肯定是錯誤的,因為當str傳入add_str方法時,就將所有權轉移到了add_str方法內的變數str_1上,它不再具備所有權,所以就不能使用了,這種情況其實很常見,單純的所有權機制讓這個問題複雜化了,所以rust還有一個機制來解決下面的問題:【引用和借用】。
借用
雖然一個值只能有一個變數擁有其所有權,但是,就像人可以把自己的東西借給其他人用,借給不同的人用一樣,變數也可以把自己擁有的值給借出去,上述程式碼稍作修改:
fn main() {
let
mut str1 =
String
::
from
(
“hello world”
);
add_str(&mut str1,
“!!!”
);
println!(
“{}”
, str1);
}
fn add_str(str_1: &mut
String
,
str_2
: &str) {
str_1。push_str(str_2);
}
add_str傳入的不再是mut str,而是&mut str1,這就相當於從mut str1上借了這份資料來使用,但實際上的所有權仍在str1上,記憶體區塊的回收條件,仍然是【str1所在的作用域執行完畢,str1儲存的記憶體地址北出棧而銷燬】。
這兩種機制,所形成的本質是:對於一塊記憶體的引用計數,變得異常簡單,只要這個記憶體地址對應的變數在堆裡,引用計數就是1,否則就是0,只有這兩種情況。絕對不存在,多個變數都指向同一個記憶體地址的情況,這一下子就把引用計數GC演算法的複雜度給大幅度降低了。降低到不需要一個複雜的執行時,
靜態檢查階段就可以得到所有需要GC的時機並進行GC了。
Rust的其他特性
rust作為一個非常年輕的程式語言,它擁有許多新語言常見的特性,在特性方面有點類似於Golang、ts和高版本C++的混合。比如說:
沒有繼承,只有組合,類似於Golang。繼承帶來的子型別會帶來數學上的不可判定性,即存在一種可能,可以構造出一段包含子型別的程式碼,無法對它進行型別推倒和型別檢查,因為型別不可判定,表現在工程上,那就是編譯器在型別推倒時陷入死遞迴,無法停止。同時,多層的繼承也讓程式碼變得難以維護,越來越多的新語言拋棄了繼承。
有一個好用的包管理器cargo,可以方便的管理各項依賴。依賴存在專案間的隔離,而非統一放在一塊,這一點類似於nodejs,golang也在推進依賴的專案間隔離。專案內安裝的依賴被寫在cargo。toml當中,並且存在cargo。lock,將依賴鎖定在特定版本(幾乎和npm一致)。
大量高階的語言特性:模式匹配、沒有但是有Option(任何可能報錯、返回空指標的地方,都可以返回一個Option列舉,基於模式匹配來匹配成功和失敗兩種情況,不再對開發者暴露)、原生的非同步程式設計支援等等。
對前端的影響?
Rust加上上述的一些特性,使得它成為了一個C++的完美替代。目前,前端領域使用Rust有以下兩個方向,一個,是使用Rust來打造更高效能的前端工具,另一個是作為WASM的程式語言,編譯成可以在瀏覽器當中跑的WASM模
塊。
(一)高效能工具
在之前,前端領域如果希望做一個高效能的工具,那麼唯一選擇就是gyp,使用C++編寫程式碼,透過gyp編譯成nodejs可以呼叫的API,saas-loader等大家耳熟能詳的庫都是這樣實現的。但更多的情況下,前端的大部分工具都是完全不在乎效能,直接用js寫的,比如Babel、ESLint、webpack等等,有很大一部分原因在於C++實在不太好入門,光是幾十個版本的C++特性,就足夠讓人花掉大量的時間來學習,學習完之後還要大量的開發經驗才可以學會如何更好的做記憶體管理、避免記憶體洩露等問題。而Rust不一樣,它足夠年輕,沒有幾十個版本的標準、有和npm一樣現代的包管理器,還有更關鍵的,不會記憶體洩露,這使得即便rust的歷史不長,即便C++也能寫Nodejs擴充套件,但前端領域仍然出現了大量的Rust寫的高效能工具。比如:
swc一個Rust寫的,封裝出Nodejs API的,功能類似Babel的JS polyfill庫,但在Rust加持之下,它的效能可以達到Babel的40倍。
Rome也是基於Rust實現,其作者也是是Babel的作者Sebastian。Rome 涵蓋了編譯、程式碼檢測、格式化、打包、測試框架等工具。它旨在成為處理JavaScript原始碼的綜合性工具。
RSLint
,一個Rust寫的JS程式碼lint工具,旨在替代ESLint。
隨著前端愈發複雜,我們必定會逐漸追求效能更好的工具鏈,也許過幾年我們就會看到使用swc和Rome正式版的專案跑在生產環境當中了。
(二)WASM
另外,在有了WASM之後,前端也在尋找一個最完美支援WASM的語言,目前來看,也很有可能是Rust。對於WASM來說,帶執行時的語言是不可接受的,因為帶有執行時的語言,打包成WASM之後,不僅包含了我們自己寫的業務程式碼,同時還有執行時的程式碼,這裡麵包含了GC等邏輯,這大大提高了包體積,並不利於使用者體驗,將帶執行時的語言剔除之後,前端能選擇的範圍便不大了,C++、Rust裡面,Rust的優勢使得前端界更願意選擇Rust。同時,Rust在這方面,也提供了不錯的支援,Rust的官方編譯器支援將Rust程式碼編譯成WASM程式碼,再加上wasm-pack這種開箱即用的工具,使得前端是可以很快的構建wasm模組的。這裡做一個簡單的演示,下面這一串程式碼是我從上文提到的swc裡面挖出來的:
#![deny(warnings)]
#![allow(clippy::unused_unit)]
// 引用其他的包或者標準庫、外部庫
use
std
::
sync
::
Arc
;
use
anyhow
::{
Context
,
Error
};
use
once_cell
::
sync
::
Lazy
;
use
swc
::{
config
::{
ErrorFormat
,
JsMinifyOptions
,
Options
,
ParseOptions
,
SourceMapsConfig
},
try_with_handler
,
Compiler
,
};
use
swc_common
::{
comments
::
Comments
,
FileName
,
FilePathMapping
,
SourceMap
};
use
swc_ecmascript
::
ast
::{
EsVersion
,
Program
};
// 引入wasm相關的庫
use
wasm_bindgen
::
prelude
::*;
// 使用wasm_bindgen宏,這裡的意思是,下面這個方法編譯成wasm之後,方法名是transformSync,
// TS的型別是transformSync
#[wasm_bindgen(
js_name =
“transformSync”
,
typescript_type =
“transformSync”
,
skip_typescript
)]
#[allow(unused_variables)]
// 定義一個可以方法,總共方法由於是pub的,因此可以被外部呼叫。這個方法的目的是:將高版本JS轉義成低版本JS
// 具體的內部邏輯我們完全不去管。
pub fn transform_sync(
s: &str,
opts: JsValue,
experimental_plugin_bytes_resolver: JsValue,
) -> Result
console_error_panic_hook::set_once();
let c = compiler();
#[cfg(feature = “plugin”)]
{
if
experimental_plugin_bytes_resolver。is_object() {
use
js_sys
::{
Array
,
Object
,
Uint8Array
};
use
wasm_bindgen
::
JsCast
;
//
TODO:
This is probably very inefficient, including each transform
// deserializes plugin bytes。
let plugin_bytes_resolver_object: Object = experimental_plugin_bytes_resolver
。try_into()
。expect(
“Resolver should be a js object”
);
swc_plugin_runner::cache::init_plugin_module_cache_once();
let entries = Object::entries(&plugin_bytes_resolver_object);
for
entry in entries。iter() {
let entry:
Array
= entry
。try_into()
。expect(
“Resolver object missing either key or value”
);
let name: String = entry
。get(
0
)
。as_string()
。expect(
“Resolver key should be a string”
);
let buffer = entry。get(
1
);
//https://github。com/rustwasm/wasm-bindgen/issues/2017#issue-573013044
//We may use https://github。com/cloudflare/serde-wasm-bindgen instead later
let data =
if
JsCast::is_instance_of::
JsValue::from(
Array
::from(&buffer))
}
else
{
buffer
};
let bytes: Vec
。into_serde()
。expect(
“Could not read byte from plugin resolver”
);
// In here we ‘inject’ externally loaded bytes into the cache, so
// remaining plugin_runner execution path works as much as
// similar between embedded runtime。
swc_plugin_runner::cache::PLUGIN_MODULE_CACHE。store_once(&name, bytes);
}
}
}
let opts: Options = opts
。into_serde()
。context(
“failed to parse options”
)
。map_err(|e| convert_err(e, ErrorFormat::Normal))?;
let error_format = opts。experimental。error_format。unwrap_or_default();
try_with_handler(
c。cm。
clone
(),
swc::HandlerOpts {
。。
Default
::default()
},
|handler| {
c。run(|| {
let fm = c。cm。new_source_file(
if
opts。filename。is_empty() {
FileName::Anon
}
else
{
FileName::Real(opts。filename。
clone
()。into())
},
s。into(),
);
let out = c
。process_js_file(fm, handler, &opts)
。context(
“failed to process input file”
)?;
JsValue::from_serde(&out)。context(
“failed to serialize json”
)
})
},
)
。map_err(|e| convert_err(e, error_format))
}
這一段Rust程式碼的特殊之處在於一些方法上加了這樣的派生,所謂的派生,指的是我們只要加上這一段程式碼,編譯器就會幫我們實現約定好的邏輯:
#[wasm_bindgen(
js_name
=
“transformSync”
,
typescript_type =
“transformSync”
,
skip_typescript
)]
當加上這一段派生之後,編譯器就會將下面的函式編譯為二進位制的WASM函式供JS呼叫。
我們使用wasm-pack對程式碼進行編譯打包:
wasm-
pack
build ——scope swc -t nodejs ——features plugin
拿到以下這些
檔案:
。
├──
binding_core_wasm
。d
。ts
├──
binding_core_wasm
。js
├──
binding_core_wasm_bg
。js
├──
binding_core_wasm_bg
。wasm
├──
binding_core_wasm_bg
。wasm
。d
。ts
└──
package
。json
然後就可以在JS當中呼叫了:
//
index。js
let
settings = {
jsc
:
{
target
:
“es2016”,
parser
:
{
syntax
:
“ecmascript”,
jsx
:
true,
dynamicImport
:
false,
numericSeparator
:
false,
privateMethod
:
false,
functionBind
:
false,
exportDefaultFrom
:
false,
exportNamespaceFrom
:
false,
decorators
:
false,
decoratorsBeforeExport
:
false,
topLevelAwait
:
false,
importMeta
:
false,
},
},
};
let
code = `
let
a = 1;
let
b = {
c
:
{
d
:
1
}
};
console。log(b?。c?。d);
let
MyComponent = () => {
return
(
Hello
World!
}
`;
const
wasm = require(‘。/pkg/binding_core_wasm’);
console。log(wasm。transformSync(code,
settings))
可以看出,只要當下已存在一個Rust庫,那麼將其轉變為WASM是非常簡單的,讀者也可以去折騰一下Golong、C++的WASM,會發現Rust的整個折騰過程比Golang、C++要簡單不少。
有沒有啥問題?
雖然我上文說了許多Rust的好,但我在學習Rust的時候卻有些備受打擊,很大的一個原因在於,Rust過於特立獨行了。
舉一個很簡單的例子,在一般的程式語言當中,宣告變數和常量,要麼有不同的宣告方式,如javascript區分let 和const,go區分const和var,要麼就是宣告出來預設是變數,常量需要額外宣告,比如Java宣告的變數前面加final就會是常量,而Rust就很特殊,宣告出來的預設是常量,變數反而需要額外宣告,let a=1得到的是常量,let mut a=1才是變數。
上述提到的,Rust比較特別的點非常多,雖然大部分都只是設計理念不同,沒有高下優劣之分,但如此設計確實會給其他語言的開發者帶來一部分心智負擔。
從我的學習經驗來看,Rust本身的學習難度並不低,學習起來實際上未必就比C++簡單,社群內也有想學好Rust得先學習C++,不然完全領會不到Rust優雅的說法。想學習Rust的同學,可能需要做好一些心理準備。
作者簡介
於玉龍
騰訊雲開發者社群【技思廣益·騰訊技術人原創集】作者
騰訊前端開發工程師,畢業於湘潭大學,目前負責騰訊醫療健康工作室下醫療SaaS產品的前端開發工作,負責組內部分前端工具鏈開發和維護的工作。
—— 活動推薦——
視覺化服務編排在金融APP中的實踐
HttpClient 在vivo內銷瀏覽器的高併發實踐最佳化
B站雲原生混部技術實踐
會員服務優雅上下線實踐
本文由高可用架構轉載。技術原創及架構實踐文章,歡迎透過公眾號選單「聯絡我們」進行投稿。